NARA

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.

sample · body shapejson
{
  "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

What you receive on each delivery.

HeaderMeaning
Content-Typeapplication/json
User-AgentNara-Webhooks/1.0
X-Nara-EventEvent type, e.g. credits.low or webhook.test
X-Nara-Delivery-IdUnique per delivery; idempotency key
X-Nara-TimestampUnix seconds at sign time. Reject > 300s skew.
X-Nara-Signaturesha256=<hex-encoded HMAC-SHA256(signing_key, `${ts}.${body}`)>
X-Nara-Attempt1 on first send, 2-5 on retries

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.

Drop into your handler.

verifier · Node.jsjavascript
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

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.

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 (timingSafeEqual in Node, hmac.compare_digest in Python). A naive == check leaks the signature byte-by-byte through timing.
  • De-dupe on X-Nara-Delivery-Id if 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.

What we send.

credits.lowBucket fell below the warning threshold
credits.depletedBucket hit zero
billing.invoiceInvoice issued
billing.payment_failedAutomated charge failed
request.completedSuccessful API request
request.failedFailed API request
image.generation_completedImage render finished
chat.completionChat completion finalized
key.createdAPI key minted
key.revokedAPI key revoked
team.member_addedOrg member added
team.member_removedOrg member removed
webhook.testSent by the 'Send test' button