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_atfield, covered by the signature. TheX-Arrota-Timestampheader carries the same value for convenience.
Verify a delivery
- Read the raw body as bytes. Parsing and re-serialising JSON changes the bytes and breaks the signature.
- Compute
sha256=plus the hex HMAC-SHA256 of the raw body, keyed with your secret. - Compare it with
X-Arrota-Signatureusing a constant-time comparison. - Parse the body and reject it if
signed_atis more than five minutes from your current time. - Respond with a
2xxstatus, 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;
}import hashlib
import hmac
import json
from datetime import datetime, timezone
def verify_arrota_webhook(raw_body: bytes, signature_header: str, secret: str):
expected = "sha256=" + hmac.new(secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(signature_header or "", expected):
return None
event = json.loads(raw_body)
signed_at = datetime.fromisoformat(event["signed_at"].replace("Z", "+00:00"))
if abs((datetime.now(timezone.utc) - signed_at).total_seconds()) > 300:
return None
return eventBoth 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.