/docs/webhooks
Webhook verifiers,and the wire format.
How to receive NARA webhooks, verify their signatures, and tell a real dispatch from a forgery. Reference code in Node, Python, and Go.
{
"event": "credits.low",
"delivery_id": "j572gf2x9k3p4qzbrmcs",
"delivered_at": "2025-05-03T14:32:11.421Z",
"payload": {
"organizationId": "j570abc...",
"bucket": "image",
"available": 412,
"threshold": 500
}
}signed + timestampedreplay-window 5m
#wire-format
What you receive on each delivery.
| Header | Meaning |
|---|---|
Content-Type | application/json |
User-Agent | Nara-Webhooks/1.0 |
X-Nara-Event | Event type, e.g. credits.low or webhook.test |
X-Nara-Delivery-Id | Unique per delivery; idempotency key |
X-Nara-Timestamp | Unix seconds at sign time. Reject > 300s skew. |
X-Nara-Signature | sha256=<hex-encoded HMAC-SHA256(signing_key, `${ts}.${body}`)> |
X-Nara-Attempt | 1 on first send, 2-5 on retries |
#signing
Why your verifier hashes the secret first.
We never store your raw signing secret on our server — only sha256(secret). So the HMAC key our worker uses when signing is sha256(secret), not the secret itself.
When you verify, you must hash your stored whsec_… the same way before using it as the HMAC key. Each sample below does this on the first line. Skipping the hash will produce signatures that never match.
#verifiers
Drop into your handler.
import crypto from "node:crypto";
// Your stored secret from the Webhooks page (whsec_...). We hash it
// to match the key the server signs with — the server never stores
// the raw secret, so verifiers must derive the HMAC key the same way.
const SIGNING_KEY = crypto
.createHash("sha256")
.update(process.env.NARA_WEBHOOK_SECRET)
.digest("hex");
export function verifyNaraWebhook(req) {
const sig = req.headers["x-nara-signature"] || "";
const ts = req.headers["x-nara-timestamp"] || "";
const body = req.rawBody; // raw bytes, NOT the parsed JSON
// Replay-window guard: reject anything older than 5 minutes.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(ts)) > 300) {
return { ok: false, reason: "stale_timestamp" };
}
const expected = crypto
.createHmac("sha256", SIGNING_KEY)
.update(`${ts}.${body.toString("utf8")}`)
.digest("hex");
// Constant-time compare to defeat timing attacks.
const got = sig.replace(/^sha256=/, "");
if (got.length !== expected.length) return { ok: false, reason: "bad_sig" };
if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
return { ok: false, reason: "bad_sig" };
}
return { ok: true };
}HMAC-SHA256constant-time compare
#retries
What we retry, and when.
A failed delivery is retried up to 5 times with exponential backoff: 30s · 5m · 30m · 2h · 12h. If your endpoint sends a Retry-After header (in seconds), we honor it (capped at 12h).
Retried only on transient failures: connection errors, timeouts, and HTTP 408 / 425 / 429 / 5xx. Other 4xx responses are treated as permanent client errors and not retried — fix your handler.
After 10 consecutive delivery failures across separate events, we auto-disable the webhook so a dead endpoint doesn't keep consuming our retry budget. Re-enable from the webhooks page.
#security
Beyond verifying the signature.
- Always verify the timestamp. Reject deliveries older than 5 minutes — that defeats replay attacks where an attacker re-POSTs an old, validly-signed dispatch.
- Use the raw body for HMAC. JSON-stringifying a parsed object can change byte-for-byte ordering or whitespace; the signature is over the raw bytes we sent.
- Constant-time compare (
timingSafeEqualin Node,hmac.compare_digestin Python). A naive==check leaks the signature byte-by-byte through timing. - De-dupe on
X-Nara-Delivery-Idif your handler isn't idempotent — a successful delivery you accidentally 500'd will be retried. - Rotate your secret from the webhooks page if you suspect leakage. The old secret stops working immediately on rotation.
#events
What we send.
credits.lowBucket fell below the warning thresholdcredits.depletedBucket hit zerobilling.invoiceInvoice issuedbilling.payment_failedAutomated charge failedrequest.completedSuccessful API requestrequest.failedFailed API requestimage.generation_completedImage render finishedchat.completionChat completion finalizedkey.createdAPI key mintedkey.revokedAPI key revokedteam.member_addedOrg member addedteam.member_removedOrg member removedwebhook.testSent by the 'Send test' button