Skip to content
Developers
Menu

Webhooks

Verify signatures

Confirm every webhook came from Arrota and was not altered, using the X-Arrota-Signature HMAC-SHA256 header and a replay window.

Updated

On this page

Every delivery is signed with your endpoint's signing secret. Verify the signature before you trust or act on a payload.

The scheme

  • Header: X-Arrota-Signature: sha256=<hex digest>
  • Algorithm: HMAC-SHA256.
  • Key: your signing secret, exactly as shown when you created or rotated it, used as a UTF-8 string. Do not decode it.
  • Message: the raw request body, byte for byte, before any JSON parsing.
  • Timestamp: the body's signed_at field, covered by the signature. The X-Arrota-Timestamp header carries the same value for convenience.

Verify a delivery

  1. Read the raw body as bytes. Parsing and re-serialising JSON changes the bytes and breaks the signature.
  2. Compute sha256= plus the hex HMAC-SHA256 of the raw body, keyed with your secret.
  3. Compare it with X-Arrota-Signature using a constant-time comparison.
  4. Parse the body and reject it if signed_at is more than five minutes from your current time.
  5. Respond with a 2xx status, then process the event.

Use signed_at from the verified body for the time check, not the header: the header is not covered by the signature.

Examples

import crypto from "node:crypto";

// rawBody: the request body as a Buffer, before JSON parsing.
export function verifyArrotaWebhook(rawBody, signatureHeader, secret) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const received = Buffer.from(signatureHeader ?? "", "utf8");
  const wanted = Buffer.from(expected, "utf8");
  if (received.length !== wanted.length || !crypto.timingSafeEqual(received, wanted)) return null;

  const event = JSON.parse(rawBody.toString("utf8"));
  const ageMs = Math.abs(Date.now() - Date.parse(event.signed_at));
  if (!Number.isFinite(ageMs) || ageMs > 5 * 60 * 1000) return null;
  return event;
}

Both functions return the parsed event when the delivery is genuine and fresh, and nothing otherwise. Reject anything else with a 4xx status.

Replay protection

The five-minute window stops an attacker from re-sending a captured delivery later. Arrota re-signs every delivery attempt, including retries, with a fresh signed_at, so a genuine retry always passes the window. To guard against duplicates inside the window, record the IDs you have processed. See Retries and replay.

Keep your server clock synchronised, for example with NTP, or valid deliveries may fall outside the window.

Rotating the secret

Select Rotate secret on the endpoint in API & Webhooks. The new secret is shown once, and the old one stops working as soon as the rotation completes. Deliveries sent after that are signed with the new secret only, so update your receiver promptly. Deliveries that fail while you switch over are retried, and you can retry any that run out of attempts.

Using Arrota rather than building on it? The Help Center covers product workflows.