Webhooks
Subscribe an HTTPS endpoint to nRouter virtual-key lifecycle events, verify the HMAC signature, and read delivery status
Last updated
Register an HTTPS endpoint and nRouter POSTs a signed JSON payload when a virtual key is created, revoked, or rotated. Subscriptions are managed on the dashboard API at https://nrouter.ai/api/webhooks.
Alert channels — email, Slack, Teams, Jira, and a webhook-shaped channel used by the Alerts surface — are a separate system with a different payload and a different API. See Alert channels are a different surface at the bottom.
Events
These three events exist. There are no others; an event name outside this set is rejected at registration.
| Event | Fired by |
|---|---|
key.created | Creating a virtual key, and every row of a bulk key import |
key.revoked | Deleting a virtual key, and every key in a bulk revoke |
key.rotated | Rotating a virtual key |
Each subscription chooses which of the three it wants; a delivery goes only to subscriptions whose events array contains that event.
Authentication
/api/webhooks is a dashboard API. It authenticates with your logged-in dashboard session cookie and requires the organization on every call:
https://nrouter.ai/api/webhooks?orgId=<your-organization-uuid>There is no bearer-token path here. Your virtual key (sk-nrouter-…) authenticates inference at api.nrouter.ai/v1/* and nothing else — never send it to nrouter.ai/api/*.
Every method on this route requires organization Owner or org_admin, reads included: the rows carry your egress hosts, so a plain member or viewer gets 403. POST additionally requires Content-Type: application/json and an Origin (or Referer) header matching https://nrouter.ai; DELETE requires the matching origin.
The examples below use COOKIE for the session cookie header copied from a signed-in browser. Treat it as a credential — it is equivalent to being logged in.
Register a subscription
ORG_ID="<your-organization-uuid>"
API="https://nrouter.ai/api/webhooks"
curl -X POST "$API?orgId=$ORG_ID" \
-b "$COOKIE" \
-H "Origin: https://nrouter.ai" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.your-app.com/nrouter",
"events": ["key.created", "key.revoked", "key.rotated"],
"secret": "a-long-random-string"
}'The body is strict — an unknown field is rejected:
| Field | Required | Rules |
|---|---|---|
url | yes | must start with https://, at most 2048 characters, host must not be private, loopback, link-local, or metadata |
events | yes | array with at least one of key.created, key.revoked, key.rotated |
secret | no | 8 to 128 characters; enables HMAC signing |
A valid request returns the stored row. The secret is never echoed back and never appears in any later read:
{
"id": "3f9c8b21-77ad-4e3f-9c11-6d2ab0f4e8c5",
"organization_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"url": "https://hooks.your-app.com/nrouter",
"events": ["key.created", "key.revoked", "key.rotated"],
"enabled": true,
"last_delivery_at": null,
"last_status": null,
"created_at": "2026-05-27T10:00:00Z"
}Registration is rate-limited to 5 per minute per user; exceeding it returns 429.
The secret is optional but strongly recommended. Without it nRouter sends no signature header, and anyone who learns your endpoint URL can post whatever they like to it.
List subscriptions and read delivery status
curl "$API?orgId=$ORG_ID" -b "$COOKIE"{
"webhooks": [
{
"id": "3f9c8b21-77ad-4e3f-9c11-6d2ab0f4e8c5",
"organization_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"url": "https://hooks.your-app.com/nrouter",
"events": ["key.created", "key.revoked", "key.rotated"],
"enabled": true,
"last_delivery_at": "2026-05-27T11:42:03.118Z",
"last_status": 200,
"created_at": "2026-05-27T10:00:00Z"
}
]
}last_status is the HTTP status your endpoint returned on the most recent attempt. 0 means the delivery never completed — a network failure, a timeout, a refused redirect, or a URL that failed re-validation at send time. This pair is the only delivery record; there is no per-delivery log and no replay.
Delete a subscription
curl -X DELETE "$API?orgId=$ORG_ID&id=3f9c8b21-77ad-4e3f-9c11-6d2ab0f4e8c5" \
-b "$COOKIE" \
-H "Origin: https://nrouter.ai"Returns {"ok": true}. An id that is not in your organization returns 404 Webhook not found in this organization — a deletion that changed nothing is never reported as success.
Payload shape
Every delivery, for all three events, carries the same envelope:
{
"event": "key.created",
"organization_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"timestamp": "2026-05-27T11:42:03.118Z",
"data": {
"key_name": "sk-...a91c",
"key_alias": "support-summariser",
"models": ["gpt-4.1-mini", "claude-haiku-4-5"],
"actor_user_id": "9a2b6f04-31c5-4d19-b0e7-5c3a8e17d240"
}
}event— one of the three names above.organization_id— the organization the key belongs to.timestamp— ISO-8601, stamped when the batch is serialised.data— event-specific, and it never contains a plaintext key or a key hash.key_nameis the masked form (sk-...plus the last four characters); that is the only key identifier you receive.
data by event:
| Event | Fields |
|---|---|
key.created | key_name, key_alias, models, actor_user_id, plus bulk_import: true on a bulk import row |
key.revoked | key_name, key_alias, actor_user_id, plus bulk: true on a bulk revoke |
key.rotated | old_key_name, new_key_name, old_key_expires_at, grace_hours, actor_user_id |
On key.rotated, old_key_expires_at is null when the expiry could not be set on the old key — the field reports what actually happened rather than the intended value.
Request headers
Content-Type: application/json
User-Agent: nRouter-Webhook/1.0
x-nr-signature: sha256=<hex-digest>x-nr-signature is present only when the subscription has a secret.
Signature verification
When a secret is configured, nRouter signs the exact bytes of the request body with HMAC-SHA256 and sends the hex digest as x-nr-signature: sha256=<digest>.
The signed bytes are the body as sent — standard JSON serialisation, key order as written above. Do not re-serialise, re-order keys, or reformat before verifying: read the raw body and hash that.
Node.js verification
import crypto from "node:crypto";
export function verifyNRouterSignature(
rawBody: string,
signatureHeader: string | undefined,
secret: string,
): boolean {
if (!signatureHeader?.startsWith("sha256=")) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const provided = signatureHeader.slice(7);
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
// Length check first: timingSafeEqual throws on a length mismatch.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}Python verification
import hmac
import hashlib
def verify_nrouter_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
if not signature_header.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header[7:])Endpoint requirements
- HTTPS only. An
http://URL is rejected at registration, and re-checked before every send. - Public host. Loopback, RFC1918 private ranges, link-local (including the cloud metadata address), IPv6 unique-local, and
.local/.internal/.cluster.localnames are rejected at registration and again at send time. - No credentials in the URL. A URL carrying a username or password is refused at send time.
- No redirects. nRouter refuses to follow a
3xx; the delivery is recorded as failed withlast_status: 0. Give the final URL. - Respond within 5 seconds with any 2xx. A slower response is aborted and recorded as
last_status: 0. - nRouter reads only the status code. Do not put anything sensitive in the response body.
Delivery semantics
Deliveries are fire-and-forget with no automatic retry. If your endpoint returns a non-2xx, times out, or cannot be reached, the status is written to last_status and the event is dropped. There is no queue, no backoff, and no replay endpoint.
A dispatch failure never affects the originating action: a key that was created is still created even if every subscriber is unreachable.
If you need stronger delivery guarantees, treat the webhook as a fast-path signal and reconcile against the keys API on a schedule.
Testing
There is no test-fire endpoint for a webhook subscription. Exercise it with a real event: create a throwaway virtual key against a registered key.created subscription, then read last_status on the subscription to confirm the delivery landed.
# after creating a key in the dashboard or via the keys API:
curl "$API?orgId=$ORG_ID" -b "$COOKIE" \
| jq '.webhooks[] | {url, last_status, last_delivery_at}'Alert channels are a different surface
The Alerts area of the dashboard has its own destinations — notification channels — with types email, slack, teams, jira, and webhook. They are unrelated to the subscriptions above: different table, different API, different payload, and they never carry the key lifecycle events.
Manage them at https://nrouter.ai/api/notification-channels (Owner / org_admin, ?orgId= on every call, same session-cookie auth):
| Operation | Request |
|---|---|
| List channels | GET /api/notification-channels |
| Create a channel | POST /api/notification-channels |
| Update or delete one | PATCH / DELETE /api/notification-channels/<channelId> |
| Send a test notification | POST /api/notification-channels/<channelId>/test |
A webhook channel's config takes a url and an optional secret; a slack or teams channel takes webhook_url; jira takes base_url, email, api_token, project_key; email takes a comma-separated recipients. Any config value that is an HTTP(S) URL must be a public HTTPS endpoint. Secrets in a channel's config are redacted on every read — they never round-trip to the browser.
The test endpoint delivers this payload to a webhook channel, signed with the same x-nr-signature: sha256=<hex> scheme when the channel config has a secret:
{
"title": "nRouter test notification",
"message": "Your notification channel is configured correctly.",
"severity": "low",
"test": true
}It allows 5 tests per minute per organization, aborts after 10 seconds, refuses redirects, and returns 502 if the destination rejects or cannot be reached. A successful test stamps verified_at on the channel.
What fires into a channel automatically today: nothing. Channel bindings on alert rules and budget thresholds are stored and validated — a channel id from another organization is rejected at write time — but no live dispatcher currently sends a real event to a notification channel. Budget and low-balance notifications go to your organization's owners and admins by email, not through a channel. Until that changes, use the subscriptions at the top of this page for anything you need delivered over HTTP, and treat a channel's verified_at as proof of reachability rather than proof of coverage.
Next Steps
- API Key Management — The create, revoke, and rotate operations that fire these events
- Budget Controls — Caps per organization, team, and key
- Chat Completions API — The inference path, authenticated with a virtual key rather than a session