Notifikasi
kenari memberi tahu kamu saat kuota paket hampir habis, saat paket akan berakhir, dan saat saldo menipis. Setiap peringatan muncul di lonceng pada dasbor. Kamu juga bisa mengirimnya ke Telegram atau ke webhook, jadi peringatan sampai ke kamu atau ke tool milikmu tanpa perlu membuka dasbor. Halaman ini menjelaskan apa saja yang dikirim, cara menghubungkan tiap saluran, cara memverifikasi webhook, dan cara skrip atau agen membaca angka yang sama.
Apa yang diberitahukan kenari
Section titled “Apa yang diberitahukan kenari”Peringatan dikelompokkan dalam kategori. Setiap chat Telegram dan setiap webhook memilih kategori mana yang diterima, di Pengaturan, Notifikasi.
| Kategori | Isinya | Aktif secara bawaan |
|---|---|---|
| Kuota paket | Jendela kuota mingguan atau bulanan mencapai ambang peringatanmu, atau habis. | ya |
| Langganan | Paket berakhir dalam 3 hari atau besok, perpanjangan otomatis berhasil atau gagal, saldo akan kurang untuk perpanjangan otomatis, paket berhenti, kupon kedaluwarsa. | ya |
| Saldo | Saldo menipis, dan kredit yang masuk ke saldo. | ya |
| Kunci API | Sebuah API key mencapai batas biaya. | ya |
| Pod dan domain | Kejadian pod dan domain, balasan dukungan, dan pemberitahuan saat salah satu salurannya dimatikan. | ya |
| Pengumuman | Pengumuman produk dari kenari. | tidak |
Lonceng selalu menerima semuanya, apa pun pilihanmu untuk saluran. Peringatan kuota dikirim sekali per jendela saat pemakaian mencapai ambangmu (80% kalau tidak kamu ubah), dan sekali lagi saat jendela habis. Peringatan saldo menipis dikirim saat saldo turun di bawah Rp 20.000 (kalau tidak kamu ubah). Lihat Ambang.
Di Telegram, peringatan awal, pemberitahuan paket 3 hari, perpanjangan otomatis yang berhasil, dan pengumuman datang tanpa suara. Sisanya berbunyi.
kenari mengirim paling banyak 20 peringatan per jam ke saluranmu. Kalau ada yang mengirim lebih banyak, sisanya hanya tampil di lonceng.
Kamu bisa menghubungkan sampai 5 saluran, termasuk Telegram.
Hubungkan Telegram
Section titled “Hubungkan Telegram”- Buka Pengaturan di dasbor dan masuk ke Notifikasi.
- Di bagian Telegram, pilih Hubungkan. Ini membuka
@kenarihq_botdengan tautan sekali pakai. - Tekan Start di Telegram. Bot menjawab bahwa chat sudah terhubung ke akunmu.
Tautan hanya berlaku sekali dan kedaluwarsa setelah 10 menit. Kalau sudah kedaluwarsa, pilih Hubungkan lagi. Hanya chat pribadi yang berfungsi. Pesan di grup dan channel diabaikan. Satu chat Telegram hanya milik satu akun kenari. Kalau chat itu sudah terhubung ke akun lain, kirim /stop di chat tersebut dulu, lalu buka tautan baru.
Bot mengenali empat perintah:
| Perintah | Fungsinya |
|---|---|
/quota | Menampilkan sisa tiap jendela kuota paketmu, lengkap dengan waktu reset. |
/balance | Menampilkan saldo yang tersedia. |
/stop | Memutuskan chat dan menghentikan peringatan. |
/help | Menampilkan daftar perintah. |
Setiap peringatan punya tombol yang membuka halaman dasbor yang sesuai. Untuk memutuskan dari dasbor, pilih Putuskan di bagian Telegram. Kalau kamu memblokir bot atau menghapus chat, Telegram menolak pesannya dan kenari mematikan salurannya.
Webhook
Section titled “Webhook”Tambahkan webhook di Pengaturan, Notifikasi, Tambah webhook. kenari mengirim pesan tes dulu dan hanya menyimpan webhook kalau servermu menjawab dengan status 2xx. Kalau tes gagal, alasannya ditampilkan dan tidak ada yang disimpan.
URL harus memakai https. kenari menolak URL yang memuat username atau password, dan URL yang mengarah ke alamat jaringan privat. kenari tidak mengikuti redirect dan menunggu jawabanmu paling lama 5 detik.
Format
Section titled “Format”kenari memilih format dari URL.
| URL | Format |
|---|---|
https://discord.com/api/webhooks/... atau https://discordapp.com/api/webhooks/... | Embed Discord dengan tautan ke halaman dasbor. |
https://hooks.slack.com/... | Pesan Slack dengan tombol Buka. |
| Selain itu | JSON, bertanda tangan. |
Hanya format JSON yang bertanda tangan dan punya secret. Webhook Discord dan Slack membawa tokennya sendiri di URL.
Body JSON
Section titled “Body JSON”Webhook JSON menerima POST dengan content-type: application/json dan body ini:
| Field | Tipe | Keterangan |
|---|---|---|
type | string | Jenis peringatan, dengan titik, misalnya quota.week.warn. Lihat tabel di bawah. |
id | string | Id notifikasi. Dikirim juga di header webhook-id dan tetap sama di setiap percobaan ulang. |
created_at | integer | Waktu peringatan dibuat, dalam detik Unix. |
title | string | Judul singkat. |
body | string | Isi pesan. |
link | string | URL absolut ke halaman dasbor yang sesuai. |
category | string | Salah satu dari quota, plan, balance, keys, services, announcements. |
data | objek atau null | Disediakan untuk field tambahan dari suatu jenis. Nilainya null kalau peringatan tidak punya, jadi tangani keduanya. |
{ "type": "quota.week.warn", "id": "5b7c0d0e-3f5a-4a56-9c8e-0a1d2f9e7b41", "created_at": 1791622800, "title": "Kuota mingguan 80% terpakai", "body": "Sisa Rp 40.000 dari Rp 200.000, berputar 15 Okt 14:00 WIB. Setelah habis, pemakaian lanjut ditagih dari saldo.", "link": "https://kenari.id/plan", "category": "quota", "data": null}Nilai type yang paling sering muncul:
| Kategori | type |
|---|---|
quota | quota.week.warn, quota.week.out, quota.month.warn, quota.month.out |
plan | sub.expiring.3d, sub.expiring, autorenew.short, sub.autorenewed, sub.autorenew.failed, sub.lapsed, coupon.expired |
balance | wallet.low |
keys | key.cap.hit |
services | channel.disabled, test, dan kejadian pod dan domain |
announcements | announcement |
Abaikan type yang tidak kamu kenali dan jawab 2xx, supaya jenis baru tidak merusak penerimamu. Pesan tes dari Kirim tes punya type test dan id yang diawali test-.
Header
Section titled “Header”| Header | Nilai |
|---|---|
webhook-id | Sama dengan id di body. Pakai untuk membuang duplikat, karena percobaan ulang mengirim id yang sama. |
webhook-timestamp | Waktu percobaan ini dikirim, dalam detik Unix. Percobaan ulang mendapat timestamp baru. |
webhook-signature | v1, diikuti tanda tangan base64. |
Header ini mengikuti konvensi Standard Webhooks, jadi library Standard Webhooks bisa memverifikasinya.
Webhook signature
Section titled “Webhook signature”Verifikasi setiap request sebelum kamu mempercayainya. Tanda tangan membuktikan bahwa pesan datang dari kenari dan tidak diubah di perjalanan.
Secret penandatanganan berbentuk whsec_... dan hanya ditampilkan sekali, saat kamu menambahkan webhook JSON. Kalau hilang, pilih Buat ulang secret. Secret lama langsung berhenti berlaku, jadi perbarui penerimamu segera setelahnya.
Cara memverifikasi request:
- Ambil body request mentah, persis seperti yang diterima. Jangan parse lalu serialisasi ulang, karena itu mengubah byte-nya.
- Susun string
{webhook-id}.{webhook-timestamp}.{body}. - Hapus awalan
whsec_dari secret, lalu decode sisanya dari base64. Hasilnya adalah key HMAC. - Hitung HMAC-SHA256 dari string itu dengan key tersebut, lalu encode hasilnya ke base64.
- Nilai header adalah
v1,diikuti hasil itu. Bandingkan dengan hasil hitunganmu dalam waktu konstan. - Tolak request kalau
webhook-timestampberselisih lebih dari 5 menit dari jam servermu. Ini mencegah pesan lama diputar ulang.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http";
const TOLERANCE_SECONDS = 300;
// headers: header request Node dengan huruf kecil. rawBody: Buffer atau string persis seperti yang diterima.export function verifyKenariWebhook(secret, headers, rawBody) { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key) .update(`${id}.${timestamp}.`) .update(rawBody) .digest();
// Header bisa memuat beberapa tanda tangan yang dipisah spasi, masing-masing "v1,<base64>". for (const entry of signatures.split(" ")) { const [version, value] = entry.split(","); if (version !== "v1" || !value) continue; const given = Buffer.from(value, "base64"); if (given.length === expected.length && timingSafeEqual(given, expected)) return true; } return false;}
const secret = process.env.KENARI_WEBHOOK_SECRET;
createServer((req, res) => { const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { const rawBody = Buffer.concat(chunks); if (!verifyKenariWebhook(secret, req.headers, rawBody)) { res.writeHead(401).end(); return; } const event = JSON.parse(rawBody.toString("utf8")); console.log(event.type, event.title); res.writeHead(204).end(); });}).listen(3000);Python
Section titled “Python”import base64import hashlibimport hmacimport jsonimport osimport timefrom http.server import BaseHTTPRequestHandler, HTTPServer
TOLERANCE_SECONDS = 300
def verify_kenari_webhook(secret: str, headers, raw_body: bytes) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: return False
try: age = abs(time.time() - int(timestamp)) except ValueError: return False if age > TOLERANCE_SECONDS: return False
key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = hmac.new(key, signed, hashlib.sha256).digest()
# Header bisa memuat beberapa tanda tangan yang dipisah spasi, masing-masing "v1,<base64>". for entry in signatures.split(" "): version, _, value = entry.partition(",") if version != "v1" or not value: continue try: given = base64.b64decode(value) except ValueError: continue if hmac.compare_digest(given, expected): return True return False
class Receiver(BaseHTTPRequestHandler): def do_POST(self): raw_body = self.rfile.read(int(self.headers.get("content-length", 0))) secret = os.environ["KENARI_WEBHOOK_SECRET"] if not verify_kenari_webhook(secret, self.headers, raw_body): self.send_response(401) self.end_headers() return event = json.loads(raw_body) print(event["type"], event["title"]) self.send_response(204) self.end_headers()
if __name__ == "__main__": HTTPServer(("", 3000), Receiver).serve_forever()Jawab dengan status 2xx begitu tanda tangan terbukti benar. Kerjakan proses yang lambat setelah kamu menjawab, karena kenari hanya menunggu 5 detik.
Percobaan ulang dan penonaktifan
Section titled “Percobaan ulang dan penonaktifan”kenari menganggap jawaban 2xx sebagai terkirim. Kalau pengiriman gagal, karena timeout, error koneksi, atau status selain 2xx, kenari mencoba lagi setelah 1 menit, 5 menit, 30 menit, dan 2 jam. Kalau percobaan terakhir gagal, peringatan itu dibuang dari saluran dan tetap ada di lonceng. Telegram mengikuti jadwal yang sama.
Sebuah saluran mematikan dirinya dalam dua kasus:
- Pengiriman gagal 5 kali berturut-turut. Satu keberhasilan mengatur ulang hitungannya.
- Servermu menjawab
404atau410, yang memberi tahu kenari bahwa endpoint-nya sudah tidak ada. Saluran langsung mati.
Saat saluran mati, kenari menambahkan pemberitahuan ke lonceng beserta alasannya dan tidak mengirim apa pun lagi ke saluran itu. Setelah penyebabnya kamu perbaiki, pilih Aktifkan lagi pada saluran tersebut.
Kirim tes mengirim pesan tes ke sebuah saluran saat itu juga dan menampilkan hasilnya. Tes tidak dihitung sebagai kegagalan yang bisa mematikan saluran. Kirim tes dan Tambah webhook berbagi batas 10 pengiriman per menit untuk akunmu. Lewat dari itu, kenari menjawab bahwa percobaannya terlalu banyak, dan kamu bisa mencoba lagi sebentar lagi.
Ambang
Section titled “Ambang”Di Pengaturan, Notifikasi, Ambang kamu mengatur kapan peringatan dikirim:
| Pengaturan | Pilihan | Bawaan |
|---|---|---|
| Ingatkan saat kuota paket mencapai | 50%, 80%, atau 90% dari jendela kuota | 80% |
| Ingatkan saat saldo di bawah | Jumlah Rupiah bulat sampai Rp 10.000.000. 0 mematikan peringatan saldo. | Rp 20.000 |
Mengubah ambang kuota di tengah jendela tidak mengirim peringatan kedua untuk jendela itu. Peringatan saldo dikirim sekali saat saldomu turun di bawah jumlah itu, dan baru dikirim lagi setelah saldo diisi hingga di atas jumlah itu lalu turun lagi. Peringatan saldo menipis hanya dikirim ke akun yang membayar dari saldo dalam 14 hari terakhir atau yang perpanjangan otomatisnya aktif.
Untuk agen dan skrip
Section titled “Untuk agen dan skrip”Skrip atau agen bisa memeriksa anggarannya tanpa membuka dasbor.
Header response
Section titled “Header response”Balasan sukses dari /v1/chat/completions, /v1/messages, dan /v1/responses bisa membawa dua header, baik streaming maupun tidak:
| Header | Kapan muncul | Nilai |
|---|---|---|
x-kenari-plan-remaining | Key adalah key tanpa pembatasan milik akunmu sendiri dan paketmu aktif. | Pecahan sisa tiap jendela kuota, misalnya week=0.19, month=0.70. Tiap nilai berkisar 0 sampai 1 dengan 2 desimal. Jendela tanpa plafon tidak ditampilkan. |
x-kenari-balance-micro-idr | Request ditagih dari saldo. | Saldo tersedia dalam micro-Rupiah, dengan Rp 1 sama dengan 1.000.000. Ini saldo dikurangi dana yang ditahan request yang sedang berjalan, angka yang sama dengan available_micro_idr di Saldo akun. |
x-kenari-plan-remaining menunjukkan keadaan sebelum request ini ditagih. API key dibagikan dan key yang dibatasi tidak pernah mendapatkannya. Sebuah balasan hanya membawa header yang syaratnya terpenuhi.
Anggap keduanya sebagai petunjuk, bukan jaminan. Saat balasan non-streaming memakan waktu sangat lama, kenari mengirim status 200 lebih awal dan menjaga koneksi tetap terbuka selagi model bekerja. Header dari balasan yang sudah selesai tidak bisa ditambahkan lagi setelah itu, jadi balasan non-streaming yang sangat lama bisa tiba tanpa kedua header ini. Balasan streaming dan balasan dengan durasi normal membawanya. Kalau kamu butuh angka yang pasti, panggil Kuota akun atau Saldo akun.
curl -si https://kenari.id/v1/chat/completions \ -H "Authorization: Bearer $KENARI_API_KEY" \ -H "Content-Type: application/json" \ -d '{"model": "step-3-7-flash:free", "messages": [{"role": "user", "content": "Halo!"}]}' \ | grep -i '^x-kenari-'Field kuota
Section titled “Field kuota”GET /v1/account/quota mengembalikan angka yang sama dengan lebih rinci. Setiap jendela punya budget_rp (plafonnya dalam Rupiah utuh) dan used_frac (pecahan yang terpakai, dari 0 sampai 1 dengan 4 desimal), dan level atas punya alert_pct, ambang peringatan kuotamu. Agen bisa berhenti sendiri saat used_frac * 100 mencapai alert_pct. Lihat Kuota akun.
Status line
Section titled “Status line”kenari status --line mencetak satu baris berisi pemakaian kuota dan saldo, lalu keluar dengan kode 0. Perintah ini dibuat untuk status line Claude Code dan untuk prompt shell:
kuota mgg 81% · bln 40% · saldo Rp 12.400Baris ini menampilkan persentase terpakai dari setiap jendela yang punya plafon, lalu saldo tersedia. Kalau tidak ada key yang diatur, perintah mencetak baris kosong, jadi prompt tidak pernah rusak. Saat kenari tidak terjangkau, baris tetap menampilkan bacaan terakhir sampai 24 jam dan mencoba lagi sekali per menit. Hasilnya di-cache selama 60 detik, dan pengambilan data menunggu paling lama 3 detik. Key harus berupa key tanpa pembatasan milik akunmu sendiri, sama seperti untuk endpoint kuota.
Status line berjalan setiap kali prompt digambar ulang, jadi atur KENARI_API_KEY di shell kamu, jangan mengandalkan keychain sistem yang bisa meminta sandi atau menunggu keyring yang terkunci.
Untuk menampilkannya di Claude Code, tambahkan ini ke ~/.claude/settings.json:
{ "statusLine": { "type": "command", "command": "kenari status --line" }}Saat kamu menjalankan tool lewat CLI kenari dan sebuah jendela kuota sudah mencapai atau melewati ambang peringatanmu, CLI juga mencetak satu baris peringatan sebelum tool dijalankan. Peringatan ini hanya membaca bacaan yang sudah di-cache dan tidak pernah menunggu jaringan. Lihat CLI kenari.