Alerts and webhooks
Get a signed POST to your own URL when a budget is nearly spent, the balance runs low, spend jumps, a key is created, paused, revoked or found in public, a top-up goes through or fails, auto reload pauses, or Auditor finishes a review. Budget, balance and spend alerts are also emailed to the team's owners, admins and billing contacts.
Events
Spend and budgets:
| Type | When | Set by |
|---|---|---|
budget.threshold | A budget for the team, a project, an environment, a key or a tag reached an alert percentage of its monthly or daily amount. If one request passes several percentages, only the highest is sent. | The budget, and Budget alerts at under Alerts in Directives (alerts.budgetPercentages; 50, 80, 95 and 100 by default). Each level can set its own |
balance.low | The available balance (posted minus reserved) fell below your threshold. Sent once; sent again only after the next successful top-up. | Low balance alert under Alerts in the team's Directives (alerts.lowBalanceUsd). Off until you set it |
spend.anomaly | An hour of settled spend was at least $1 and at least five times the team's average hour over the week before. It names the keys whose own hour was that far above their usual, and whether each was paused. | Always on. Keys are paused only under Pause keys on unusual spend in Directives |
end_user.budget_exhausted | One of your end users' daily or monthly budget refused a request. Sent once per end user per day or month, however often that end user retries; other end users are unaffected. Webhook only, no email. | endUser.dailyUsd and endUser.monthlyUsd under End users in Directives. Requests name the end user with ib-user |
Keys:
| Type | When | Set by |
|---|---|---|
key.created | A project key or a management key was created: in the console, with the management API, or by rotating a key (rotated_from names the old one). | Always on |
key.revoked | A key stopped working for good: revoked by hand, by a rotation with no overlap, with its project, or (a management key) because it was found in public. A key that simply reaches its expiry is not announced. | Always on |
key.paused | A key was paused: by hand (console or API), because its own hour of spend was at least $1 and five times its usual (even when the team's hour was not), or because it was found published. Its requests get 401 until it is resumed. | Pause keys on unusual spend under Alerts in Directives (alerts.pauseOnAnomaly; off by default). Each level can set its own |
key.compromised | A key was found in a public place, such as a GitHub repository. A project key is paused, a management key revoked. Sent once per key and place. | Always on; see Leaked keys |
Billing and Auditor:
| Type | When | Set by |
|---|---|---|
topup.succeeded | Credit was added to the balance: through Stripe Checkout, by charging the saved card from Billing, or by auto reload. | Always on |
topup.failed | A top-up's payment didn't go through. A Checkout page left unpaid until it expires is not a failure and isn't announced. | Always on |
autoreload.paused | An auto reload charge failed, so auto reload stopped. It stays off until someone turns it back on under Billing; the failed charge is also a topup.failed. | Auto reload, under Billing |
auditor.review.completed | Auditor finished reviewing a pull request and published its check: the conclusion, new findings by severity, what is still open, and what the review cost. A review skipped (nothing changed, a spend cap) or superseded by a newer push is not announced. | Repositories turned on under Auditor |
A pause for unusual spend is emailed in that hour's unusual spend email, or on its own when the team's hour was not unusual; a found key gets its own email; a failed auto reload charge its own email. A pause by hand, new and revoked keys, top-ups and Auditor reviews are webhook only.
Payload
Every event is a JSON object with the same four fields. id is the event's identity: the same event is never sent twice to an endpoint unless you replay it, so your handler should treat a repeated id as already handled.
{
"id": "budget:bp_01j9…:80",
"type": "budget.threshold",
"created_at": "2026-10-11T17:02:11.204Z",
"data": {
"scope": "project",
"scope_id": "prj_…",
"period": "month",
"period_start": "2026-10-01T00:00:00.000Z",
"period_end": "2026-11-01T00:00:00.000Z",
"percent": 80,
"spent_usd": "80.412000000000",
"budget_usd": "100.000000000000"
}
}| Type | data |
|---|---|
budget.threshold | scope (organization, project, environment, key or tag), scope_id, period, period_start, period_end, percent, spent_usd, budget_usd. A tag budget adds tag, and level and level_id for the level that set it |
end_user.budget_exhausted | scope (the level whose endUser budget applies), end_user, environment_id, period (day or month), period_start, period_end, budget_usd, spent_usd (settled), reserved_usd (held for requests in flight) |
balance.low | available_usd, threshold_usd |
spend.anomaly | hour_start, hour_usd, usual_hour_usd, factor, keys (up to 10: key_id, key_name, key_hint, project_id, environment_id, hour_usd, usual_hour_usd, paused). A key is measured with the keys it was rotated from |
key.created | key_id, key_name, key_hint, project_id, environment_id, management (true for a management key), expires_at, and rotated_from for a rotation. Never the secret |
key.revoked | The key fields as above, management, reason (manual, rotation, project_deleted or secret_scanning), revoked_at |
key.paused | The key fields, reason (manual, spend_anomaly or secret_scanning), paused_at. For spend_anomaly also hour_start, hour_usd, usual_hour_usd; for secret_scanning, source |
key.compromised | The key fields, source (the URL it was found at), github_source (such as content or commit), action (paused for a project key, including one already paused; revoked for a management key), detected_at |
topup.succeeded | topup_id, amount_usd, source (checkout, saved_card or auto_reload), completed_at |
topup.failed | As topup.succeeded, and failure_code (Stripe's decline or error code, when there is one) |
autoreload.paused | topup_id (the charge that failed), amount_usd, failure_code, reason (a sentence, as in the email) |
auditor.review.completed | run_id, repository, pull_request, title, head_sha, status (published, or stopped when an error such as a refused or failed model call ended it partway, with stopped_reason, the error code), conclusion (the check's: success, failure or neutral), findings (new, high, medium, low, resolved, open), failing, cost_usd, pull_request_url, check_url |
webhook.test | Sent by Send test on the Webhooks page: message |
Amounts are decimal strings in USD. Requests also carry IB-Event-Id, IB-Event-Type and IB-Delivery-Id headers.
Endpoints and projects
Owners and admins add endpoints under Webhooks, in the sidebar's team links, or with the management API; a team can have five. Each takes every event (All events, which includes types added later) or only the types you check, and Send test sends a webhook.test event to check it. Edit changes its URL, events or project without changing its secret.
An endpoint can be scoped to one project. It then gets only that project's events: its budgets and those of its environments, keys and tags, its keys being created, paused, revoked or found, and its end users running out of budget. The team's own events (the team budget, low balance, unusual spend, top-ups, auto reload, Auditor) go only to endpoints for the whole team. Emails are sent whether or not you have an endpoint.
Verify the signature
Each request has an IB-Signature header: t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> under the endpoint's signing secret. While an old secret is being rotated out the header has a second v1, made with the old secret; accept the request if any v1 matches. Check it before trusting the body, and reject timestamps more than five minutes old so a captured request cannot be replayed.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, before any JSON parsing.
export function verify(rawBody: string, header: string, secret: string): boolean {
const t = /(?:^|,)t=(\d+)/.exec(header)?.[1];
// One v1 per signing secret: two while an old secret is being rotated out.
const sigs = [...header.matchAll(/(?:^|,)v1=([0-9a-f]{64})(?=,|$)/g)].map((m) => m[1]);
if (!t || !sigs.length || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const want = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return sigs.some((v1) => timingSafeEqual(Buffer.from(v1, "hex"), want));
}The signing secret (whsec_…) is shown once, when you add the endpoint or rotate its secret.
Rotate the secret
Rotate secret on the Webhooks page (or POST /v1/webhooks/{id}/rotate) makes a new secret and shows it once. For the overlap you choose (none, an hour, 24 hours or 7 days; 24 hours by default) every delivery is signed with both secrets, so a receiver that checks for any matching v1 keeps working while you deploy the new secret. When the overlap ends only the new secret signs. Rotating again during an overlap drops the oldest secret at once.
Delivery, retries and replay
- Answer with any
2xxwithin 10 seconds. Do slow work after answering. - Anything else is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and twice more 12 hours apart, then marked failed.
- After 15 deliveries in a row fail, the endpoint is disabled. Fix it and choose Enable.
- Endpoints must be public HTTPS URLs on the default port. Redirects are not followed.
Deliveries on the Webhooks page lists every delivery, newest first, by endpoint and status, with the body that was sent (API). Replay sends a delivered or failed one again: the same body and event id, signed with the endpoint's current secret, its retries starting over (API). Use it after fixing a receiver that failed, or to process an event again.