Webhooks
Webhooks let Belarel call your endpoint when something happens to your API keys, so you can react without polling. One event is available today, api_key.cap_reached; more events will follow.
Set up a webhook
Section titled “Set up a webhook”Webhooks are managed by an owner or admin in your organization dashboard, not through the public API.
- Open Governance → API keys → Webhooks and select New webhook.
- Give it a name, choose the event An API key reached its spend cap, and enter your endpoint. It must be an
https://URL. - Copy the signing secret. It is shown once. Store it next to your other secrets; you need it to verify every delivery.
From the same tab you can see each webhook’s last delivery (Pending, Delivered, Failed, Dropped), rotate its secret, or delete it.
The api_key.cap_reached event
Section titled “The api_key.cap_reached event”It fires when a call is refused with 402 key_cap_exceeded because a key has reached its daily or monthly spend cap. If the refusal came from the parent’s cap (a sub-key whose parent is capped), the event names the parent key.
It is sent at most once per key and per UTC day: further refusals of the same key on the same day do not produce new events.
Payload
Section titled “Payload”{ "id": "6d1f4b3e-2a8c-4f0e-9b7d-1c5e3a9f2b40", "type": "api_key.cap_reached", "version": "v1", "occurred_at": "2026-10-02T14:03:11.482+00:00", "object": { "type": "api_key", "id": "0b8e6c2a-5d14-4f3b-a9e7-2f6d8c1b4a53", "key": "bel_live_k3x9a0qz", "saas_entity_id": "…" }, "data": { "organization_id": "…", "key_name": "customer-acme", "caps": { "usd_day": 50, "usd_month": null }, "spent": { "usd_day": 50.0123, "usd_month": 412.88 } }}| Field | Meaning |
|---|---|
id |
The delivery id. Same value as the x-belarel-delivery header, and stable across retries — use it to deduplicate. |
type, version |
api_key.cap_reached, payload version v1. A future change of shape gets a new version. |
occurred_at |
When the cap refusal happened. |
object.id, object.key |
The id and the prefix of the capped key — never the key itself. |
object.saas_entity_id |
An opaque account identifier. Compare it, but do not read meaning into it; you can ignore it. |
data.organization_id |
An opaque identifier of your organization. |
data.key_name |
The key’s name. |
data.caps |
The key’s daily and monthly caps in USD (null = no cap). |
data.spent |
What is counted against each cap in the key’s most recent daily and monthly windows, in USD — settled spend plus calls in progress. |
Headers
Section titled “Headers”| Header | Value |
|---|---|
content-type |
application/json |
user-agent |
Belarel-Events/1.0 |
x-belarel-event |
The event type, api_key.cap_reached. |
x-belarel-delivery |
The delivery id (same as id in the body). |
x-belarel-signature |
t=<unix seconds>,v1=<hex HMAC-SHA256> |
Verify the signature
Section titled “Verify the signature”Belarel signs each delivery with your webhook’s signing secret:
- the signed string is the timestamp
t, a dot, and the raw request body:`${t}.${body}`; - the signature
v1is the hex HMAC-SHA256 of that string, keyed with the secret.
Use the secret exactly as shown, as a UTF-8 string. Do not hex-decode or base64-decode it before computing the HMAC.
To verify, read the raw body before any JSON parsing, rebuild the string, compute the HMAC, compare it to v1 in constant time, and reject timestamps more than five minutes away from your clock to block replays.
import hmac, hashlib, os, timefrom flask import Flask, request, abort
app = Flask(__name__)SECRET = os.environ["BELAREL_WEBHOOK_SECRET"].encode()TOLERANCE_S = 300
def verify(header: str, body: bytes) -> bool: parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p) try: t = int(parts["t"]) v1 = parts["v1"] except (KeyError, ValueError): return False if abs(time.time() - t) > TOLERANCE_S: return False expected = hmac.new(SECRET, f"{t}.".encode() + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1)
@app.post("/belarel/webhooks")def belarel_webhook(): body = request.get_data() # raw bytes, before any parsing if not verify(request.headers.get("x-belarel-signature", ""), body): abort(400) event = request.get_json() if event["type"] == "api_key.cap_reached": ... # deduplicate on event["id"], then alert, pause the customer, etc. return "", 204import express from 'express';import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.BELAREL_WEBHOOK_SECRET!;const TOLERANCE_S = 300;
function verify(header: string, body: string): boolean { const parts = new Map( header.split(',').map((p) => { const i = p.indexOf('='); return [p.slice(0, i).trim(), p.slice(i + 1).trim()] as const; }), ); const t = Number.parseInt(parts.get('t') ?? '', 10); const v1 = parts.get('v1') ?? ''; if (!Number.isFinite(t) || v1 === '') return false; if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_S) return false; const expected = createHmac('sha256', SECRET).update(`${t}.${body}`, 'utf8').digest('hex'); return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));}
const app = express();
// Raw body: the signature covers the exact bytes Belarel sent.app.post('/belarel/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const body = req.body.toString('utf8'); if (!verify(req.header('x-belarel-signature') ?? '', body)) return res.sendStatus(400); const event = JSON.parse(body); if (event.type === 'api_key.cap_reached') { // deduplicate on event.id, then alert, pause the customer, etc. } res.sendStatus(204);});
app.listen(3000);Delivery and retries
Section titled “Delivery and retries”- A delivery succeeds when your endpoint answers with a 2xx status within 10 seconds. Answer fast and do the work asynchronously.
- Redirects are not followed: a
3xxcounts as a failure. Point the webhook at the final URL. - Endpoints must use
https://and resolve to a public address; private and local addresses are refused. - A failed delivery is retried, up to 5 attempts in total, with growing delays between attempts: about 1 minute, 5 minutes, 15 minutes, then 1 hour. After the fifth failure, the delivery is marked Failed.
- Deliveries can arrive more than once, and not necessarily in order. Deduplicate on
id(orx-belarel-delivery). - Each webhook has an hourly delivery limit. Events beyond it within the same hour are not sent; a single Dropped entry per webhook per hour records that the limit was reached, however many events it held back. Deliveries for a webhook that was deleted or is no longer active also appear as Dropped.
Rotating the secret
Section titled “Rotating the secret”Select Rotate secret on the webhook. The new secret is shown once and replaces the old one for every following delivery. Deploy the new secret to your endpoint promptly: deliveries signed with the new secret fail verification against the old one.

