# 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

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](#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

1. Open **Settings** in the dashboard and go to **Notifications**.
2. Under Telegram, choose **Connect**. This opens `@kenarihq_bot` with a one-time link.
3. 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 `/disconnect` in that chat first, then open the link again.

The bot speaks English. Its replies and the alerts it sends are in English whatever language your dashboard uses. The bell, email and webhooks keep your dashboard language.

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. When the monthly reset falls on your plan's end date, it shows that date instead. |
| `/balance` | Shows your available balance. |
| `/disconnect` | Unlinks the chat and stops alerts. To get alerts here again, choose **Connect** in the dashboard. `/stop` does the same. |
| `/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

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

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

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. |

```json
{
  "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

| 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](https://www.standardwebhooks.com) convention, so a Standard Webhooks library can verify them.

## 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:

1. Take the raw request body, exactly as received. Do not parse it and serialize it again, because that changes the bytes.
2. Build the string `{webhook-id}.{webhook-timestamp}.{body}`.
3. Remove the `whsec_` prefix from the secret and base64-decode the rest. These bytes are the HMAC key.
4. Compute HMAC-SHA256 of the string with that key and base64-encode the result.
5. The header value is `v1,` followed by that result. Compare it with the one you computed in constant time.
6. Reject the request if `webhook-timestamp` is more than 5 minutes away from your clock. This stops someone from replaying an old message.

### Node.js

```javascript
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

```python
import base64
import hashlib
import hmac
import json
import os
import time
from 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

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 `404` or `410`, 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

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

A script or an agent can check its budget without opening the dashboard.

### 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](/en/docs/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](/en/docs/account-quota) or [Account balance](/en/docs/account-balance).

```bash
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

`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](/en/docs/account-quota).

### 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:

```text
quota wk 81% · mo 40% · balance Rp 12,400
```

It 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`:

```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](/en/docs/cli).

## See also

- [Account quota](/en/docs/account-quota)
- [Account balance](/en/docs/account-balance)
- [Subscriptions](/en/docs/subscriptions)
- [How billing works](/en/docs/billing)
- [CLI](/en/docs/cli)
- [Claude Code](/en/docs/claude-code)
