Notifications
kenari tells you when your plan quota is running out, when a plan is about to end, and when your balance is low. Every alert appears in the bell in the dashboard. You can also send them to Telegram or to a webhook, so they reach you or your own tools without opening the dashboard. This page covers what is sent, how to connect each channel, how to verify a webhook, and how a script or an agent can read the same numbers.
What kenari notifies
Section titled “What kenari notifies”Alerts are grouped into categories. Each Telegram chat and each webhook picks which categories it receives, under Settings, Notifications.
| Category | What it covers | On by default |
|---|---|---|
| Plan quota | A weekly or monthly quota window reaches your alert threshold, or runs out. | yes |
| Subscription | A plan ends in 3 days or tomorrow, auto-renew succeeded or failed, auto-renew will be short of balance, a plan lapsed, a coupon expired. | yes |
| Balance | Your balance is low, and credits added to your balance. | yes |
| API keys | An API key reached its spend cap. | yes |
| Pods and domains | Pod and domain events, support replies, and a notice when one of your channels is turned off. | yes |
| Announcements | Product announcements from kenari. | no |
The bell always receives everything, whatever you choose for the channels. The quota alert fires once per window when use reaches your threshold (80% unless you change it), and once more when the window is used up. The low balance alert fires when your balance drops below Rp 20,000 (unless you change it). See Thresholds.
On Telegram, early warnings, the 3-day plan notice, successful auto-renewals and announcements arrive without a sound. Everything else makes a sound.
kenari sends at most 20 alerts per hour to your channels. If something sends more, the rest stay in the bell only.
You can connect up to 5 channels in total, Telegram included.
Connect Telegram
Section titled “Connect Telegram”- Open Settings in the dashboard and go to Notifications.
- Under Telegram, choose Connect. This opens
@kenarihq_botwith a one-time link. - Press Start in Telegram. The bot answers that the chat is linked to your account.
The link works once and expires after 10 minutes. If it expires, choose Connect again. Only private chats work. Messages in groups and channels are ignored. A Telegram chat belongs to one kenari account. If the chat is already linked to another account, send /stop in that chat first, then open a new link.
The bot understands four commands:
| Command | What it does |
|---|---|
/quota | Shows what is left in each quota window of your plan, with the reset time. |
/balance | Shows your available balance. |
/stop | Unlinks the chat and stops alerts. |
/help | Lists the commands. |
Each alert has a button that opens the matching dashboard page. To unlink from the dashboard, choose Disconnect under Telegram. If you block the bot or delete the chat, Telegram refuses the message and kenari turns the channel off.
Webhooks
Section titled “Webhooks”Add a webhook under Settings, Notifications, Add webhook. kenari sends a test message first and saves the webhook only if your server answers with a 2xx status. A failed test shows the reason and nothing is saved.
The URL must be https. kenari refuses URLs that contain a username or password and URLs that resolve to a private network address. It does not follow redirects, and it waits at most 5 seconds for your answer.
Formats
Section titled “Formats”kenari picks the format from the URL.
| URL | Format |
|---|---|
https://discord.com/api/webhooks/... or https://discordapp.com/api/webhooks/... | Discord embed with a link to the dashboard page. |
https://hooks.slack.com/... | Slack message with an Open button. |
| Anything else | JSON, signed. |
Only the JSON format is signed and has a secret. Discord and Slack webhooks carry their own token in the URL.
JSON body
Section titled “JSON body”A JSON webhook receives a POST with content-type: application/json and this body:
| Field | Type | Description |
|---|---|---|
type | string | The kind of alert, with dots, for example quota.week.warn. See the table below. |
id | string | The notification id. It is also sent in the webhook-id header and stays the same across retries. |
created_at | integer | When the alert was created, in Unix seconds. |
title | string | The short headline. |
body | string | The message text. |
link | string | An absolute URL to the matching dashboard page. |
category | string | One of quota, plan, balance, keys, services, announcements. |
data | object or null | Reserved for extra fields of a kind. It is null when an alert has none, so handle both. |
{ "type": "quota.week.warn", "id": "5b7c0d0e-3f5a-4a56-9c8e-0a1d2f9e7b41", "created_at": 1791622800, "title": "Weekly quota 80% used", "body": "Rp 40,000 of Rp 200,000 left, resets 15 Oct 14:00 WIB. After that, usage is billed to your balance.", "link": "https://kenari.id/plan", "category": "quota", "data": null}The type values you will see most:
| Category | 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, and pod and domain events |
announcements | announcement |
Ignore a type you do not recognize and answer 2xx, so new kinds never break your receiver. The test message sent by Send test has type test and an id that starts with test-.
Headers
Section titled “Headers”| Header | Value |
|---|---|
webhook-id | The same value as id in the body. Use it to drop duplicates, because a retry sends the same id. |
webhook-timestamp | When this attempt was sent, in Unix seconds. A retry gets a new timestamp. |
webhook-signature | v1, followed by the base64 signature. |
These follow the Standard Webhooks convention, so a Standard Webhooks library can verify them.
Webhook signature
Section titled “Webhook signature”Verify every request before you trust it. The signature proves that the message came from kenari and was not changed on the way.
The signing secret looks like whsec_... and is shown once, when you add a JSON webhook. If you lose it, choose Regenerate secret. The old secret stops working at once, so update your receiver right after.
To verify a request:
- Take the raw request body, exactly as received. Do not parse it and serialize it again, because that changes the bytes.
- Build the string
{webhook-id}.{webhook-timestamp}.{body}. - Remove the
whsec_prefix from the secret and base64-decode the rest. These bytes are the HMAC key. - Compute HMAC-SHA256 of the string with that key and base64-encode the result.
- The header value is
v1,followed by that result. Compare it with the one you computed in constant time. - Reject the request if
webhook-timestampis more than 5 minutes away from your clock. This stops someone from replaying an old message.
Node.js
Section titled “Node.js”import { createHmac, timingSafeEqual } from "node:crypto";import { createServer } from "node:http";
const TOLERANCE_SECONDS = 300;
// headers: Node's lowercase request headers. rawBody: the Buffer or string exactly as received.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();
// The header can hold several space-separated signatures, each as "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()
# The header can hold several space-separated signatures, each as "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()Answer with a 2xx status as soon as the signature checks out. Do slow work after you have answered, because kenari waits only 5 seconds.
Retries and turning off
Section titled “Retries and turning off”kenari treats a 2xx answer as delivered. When a send fails, because of a timeout, a connection error or any status other than 2xx, it tries again after 1 minute, 5 minutes, 30 minutes and 2 hours. After the last retry fails, that alert is dropped from the channel and stays in the bell. Telegram follows the same schedule.
A channel turns itself off in two cases:
- It fails 5 sends in a row. A success resets the count.
- Your server answers
404or410, which tells kenari the endpoint is gone. The channel turns off at once.
When a channel turns off, kenari adds a notice to the bell with the reason and sends nothing more to it. After you fix the cause, choose Turn on again on the channel.
Send test sends a test message to a channel right now and shows the result. It does not count toward turning the channel off. Send test and Add webhook share a limit of 10 sends per minute for your account. Past that, kenari answers that there were too many attempts, and you can try again shortly.
Thresholds
Section titled “Thresholds”Under Settings, Notifications, Thresholds you set when the alerts fire:
| Setting | Choices | Default |
|---|---|---|
| Alert me when plan quota reaches | 50%, 80% or 90% of a quota window | 80% |
| Alert me when balance drops below | A whole Rupiah amount up to Rp 10,000,000. 0 turns balance alerts off. | Rp 20,000 |
Changing the quota threshold in the middle of a window does not send a second alert for that window. The balance alert fires once when your balance falls below the amount, and again only after it has been topped up above the amount and fallen below it again. A wallet-low alert is only sent to accounts that paid from balance in the last 14 days or have auto-renew on.
For agents and scripts
Section titled “For agents and scripts”A script or an agent can check its budget without opening the dashboard.
Response headers
Section titled “Response headers”Successful replies from /v1/chat/completions, /v1/messages and /v1/responses can carry two headers, streaming or not:
| Header | When it is present | Value |
|---|---|---|
x-kenari-plan-remaining | The key is an unrestricted key of your own account and your plan is active. | The fraction of each quota window that is left, for example week=0.19, month=0.70. Each value is from 0 to 1 with 2 decimals. A window without a ceiling is left out. |
x-kenari-balance-micro-idr | The request was billed from your balance. | Your available balance in micro-Rupiah, where Rp 1 is 1,000,000. This is your balance minus what running requests hold, the same number as available_micro_idr in Account balance. |
x-kenari-plan-remaining shows the state before this request was charged. A shared API key and a restricted key never get it. A reply carries only the headers whose condition holds.
Read them as a hint, not as a guarantee. When a non-streaming reply takes very long, kenari sends the 200 status early and keeps the connection open while the model works. The headers of the finished reply cannot be added after that, so a very long non-streaming reply can arrive without these two headers. Streaming replies and replies of normal length carry them. When you need an exact number, call Account quota or Account balance.
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": "Hi"}]}' \ | grep -i '^x-kenari-'Quota fields
Section titled “Quota fields”GET /v1/account/quota returns the same numbers with more detail. Each window has budget_rp (its ceiling in whole Rupiah) and used_frac (the fraction used, from 0 to 1 with 4 decimals), and the top level has alert_pct, your quota alert threshold. An agent can pause itself when used_frac * 100 reaches alert_pct. See Account quota.
Status line
Section titled “Status line”kenari status --line prints one line with your quota use and balance, then exits with code 0. It is made for the Claude Code status line and for shell prompts:
quota wk 81% · mo 40% · balance Rp 12,400It shows the used percentage of each window that has a ceiling, then your available balance. If no key is configured, it prints an empty line, so a prompt never breaks. When kenari cannot be reached, it keeps showing the last reading for up to 24 hours and tries again once a minute. The result is cached for 60 seconds, and a fetch waits at most 3 seconds. The key must be an unrestricted key of your own account, as for the quota endpoint.
A status line runs on every prompt redraw, so set KENARI_API_KEY in your shell rather than relying on the system keychain, which can prompt for a password or wait on a locked keyring.
To show it in Claude Code, add this to ~/.claude/settings.json:
{ "statusLine": { "type": "command", "command": "kenari status --line" }}When you start a tool through the kenari CLI and a quota window is at or past your alert threshold, the CLI also prints one warning line before it launches. It reads the cached reading only and never waits on the network. See CLI.