Webhooks
Signed outbound events for budget alerts — payload schema, signature verification, and retry policy.
AI Setu can POST events to an HTTPS endpoint you own — today, budget alerts
(see Budgets → Alerts). Register endpoints in the
console under Settings → Notifications, or over GraphQL with
createNotificationWebhook.
Registering an endpoint
| Field | Notes |
|---|---|
| URL | Must be https://. Private, loopback, link-local and other reserved addresses are rejected, both when you save the URL and again on every delivery. Redirects are not followed. |
| Events | One or more of the events below. |
| Scope | All workspaces (default), or one workspace — a workspace-scoped endpoint only receives events about budgets in that workspace. |
Each organization can register up to 10 endpoints. When you create one you
get a signing secret (whsec_…). It is shown once: store it
securely. To get a new secret, delete the endpoint and create it again.
Use Send test (or testNotificationWebhook) to deliver a signed ping
event to an endpoint. The result appears in its delivery log.
Events
type | When |
|---|---|
budget.threshold | A budget's usage crossed one of its alert thresholds. Fires once per threshold per reset period. |
budget.exhausted | A budget's usage reached its limit. Fires once per reset period. |
ping | Test event. Only sent on request; you can't subscribe to it. |
Payload
Every request is a POST with a JSON body:
{
"id": "evt_0b6f1c9e-6a3e-4c1f-9f55-2f1f0c1d9a77",
"type": "budget.threshold",
"created_at": "2026-10-20T12:01:00.000Z",
"data": {
"budget": {
"id": "0192f3c4-…",
"level": "workspace",
"level_id": "0192f3c4-…",
"scope_name": "Production",
"type": "cost",
"limit": 100000000,
"current_usage": 81234567,
"reset": "monthly",
"period_start": "2026-10-01T00:00:00.000Z",
"next_reset_at": "2026-11-01T00:00:00.000Z"
},
"threshold": { "value": 80000000, "percent": 80, "kind": "percent" },
"usage_percent": 81.23
}
}idis the event id. It's the same for every endpoint that receives the event, so use it to deduplicate.limit,current_usageandthreshold.valueare in the budget's unit: USD micros forcostbudgets (1,000,000 = $1) and raw token counts fortokensbudgets.threshold.kindispercentorabsolute.threshold.percentis the share of the limit the threshold represents.next_reset_atisnullfor budgets that never reset.- A
pingevent'sdatais{ "webhook_id": "…", "message": "…" }.
Headers
| Header | Value |
|---|---|
X-AI-Setu-Event | The event type. |
X-AI-Setu-Delivery | Unique id of this delivery (one event → one delivery per endpoint). It stays the same across retries. |
X-AI-Setu-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> — see below. |
Content-Type | application/json |
Verifying the signature
v1 is the hex-encoded HMAC-SHA256 of "<t>.<raw request body>" keyed with
your signing secret. To verify:
- Read the raw body bytes. Do this before any JSON parsing, which can change whitespace.
- Recompute the HMAC and compare it to
v1in constant time. - Reject the request if
tis more than 5 minutes from your clock, to stop replays.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyAiSetuSignature(
secret: string,
rawBody: string,
header: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
header.split(',').map((kv) => kv.split('=', 2) as [string, string]),
);
const t = Number(parts.t);
if (!Number.isInteger(t)) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? '', 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}With Express, mount express.raw({ type: 'application/json' }) on the
webhook route so req.body is the raw Buffer, then pass
req.body.toString('utf8').
Delivery and retries
- Respond with any 2xx within 10 seconds to acknowledge. Do slow work after you respond.
- Any other status, a timeout, or a connection error counts as a failure.
The delivery is retried 5 times with exponential backoff, about 30 s,
1 min, 2 min, 4 min and 8 min after each failure. After that it is marked
failed. - Deliveries to a URL that now resolves to a private or reserved address fail immediately, with no retries.
- Delivery is at least once. You may occasionally get the same event
twice, so deduplicate on
id. - Disabled endpoints receive nothing (except a manual
ping). Deleting an endpoint also deletes its delivery log.
The console shows each endpoint's recent deliveries: status (pending,
retrying, delivered, failed), attempt number, the HTTP status you
returned, and the error, if any. Over GraphQL, use notificationDeliveries.
Permissions
Viewing endpoints and deliveries requires the settings: view permission. Creating, editing, testing and deleting endpoints requires settings: update. Every change is recorded in the audit log.