Backed sends each event an endpoint is subscribed to as an HTTPS POST with a JSON body. Anyone could send a request to your endpoint’s address, so check the signature before you trust the body. Set endpoints up on the Webhooks tab: see Webhooks in the Help Centre. Every event type is in the Event catalogue.
The headers
| Header | What it holds |
|---|---|
Backed-Signature | t=<unix seconds>,v1=<signature>: the time it was signed and the hex HMAC-SHA256 of it. For 24 hours after the secret is rolled there are two v1= values, one for each secret. |
Backed-Event-Id | The event’s id, the body’s id. The same on every retry and replay: de-duplicate on it. |
Backed-Event-Type | The event type, the body’s type. |
Check the signature
- Read
tand everyv1=value from theBacked-Signatureheader. - Build the signed string:
t, a full stop, then the raw request body exactly as it arrived. Parse nothing first: re-serialised JSON is not the same bytes. - Compute the HMAC-SHA256 of that string, keyed with your endpoint’s signing secret (the whole value,
whsec_included), as lowercase hex. - Compare it with each
v1=value in constant time. One match is enough. - Reject the delivery if
tis more than five minutes old, so a captured delivery cannot be replayed later.
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
/**
* Whether a delivery really came from Backed. `header` is the Backed-Signature header,
* `rawBody` the request body exactly as received (a Buffer or string, before any JSON parsing),
* `secret` the endpoint's signing secret, whsec_ included.
*/
export function verifyBackedSignature(header, rawBody, secret, toleranceSeconds = 300) {
let t = null;
const signatures = [];
for (const part of header.split(',')) {
const [key, value] = part.trim().split('=', 2);
if (key === 't' && /^[0-9]{1,12}$/.test(value)) t = Number(value);
else if (key === 'v1' && /^[0-9a-f]{64}$/.test(value)) signatures.push(value);
}
if (t === null || signatures.length === 0) return false;
if (Math.floor(Date.now() / 1000) - t > toleranceSeconds) return false;
const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
/**
* The route's handler. `enqueue(event)` is yours: store the event durably (a queue or a table,
* de-duplicated on event.id, the Backed-Event-Id header) and resolve once it is stored.
* Do the slow work from there, after answering, so the answer comes within 10 seconds.
*/
export function backedWebhookHandler(secret, enqueue) {
return async (req, res) => {
const header = req.get('Backed-Signature') ?? '';
if (!verifyBackedSignature(header, req.body, secret)) return res.sendStatus(400);
try {
await enqueue(JSON.parse(req.body.toString('utf8')));
} catch {
return res.sendStatus(500); // not stored: a non-2xx answer makes Backed retry it
}
return res.sendStatus(204); // stored: a 2xx is final, Backed will not send it again
};
}
// Express: read the raw body for this route, not parsed JSON.
const app = express();
app.post(
'/backed/webhooks',
express.raw({ type: 'application/json' }),
backedWebhookHandler(process.env.BACKED_WEBHOOK_SECRET, enqueue),
);import hashlib
import hmac
import os
import re
import time
from flask import Flask, request
def verify_backed_signature(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
"""Whether a delivery really came from Backed.
header is the Backed-Signature header, raw_body the request body exactly as received,
secret the endpoint's signing secret, whsec_ included.
"""
t = None
signatures = []
for part in header.split(","):
key, _, value = part.strip().partition("=")
# ASCII only: str.isdigit() accepts "²", and a non-ASCII signature makes compare_digest raise.
if key == "t" and re.fullmatch(r"[0-9]{1,12}", value):
t = int(value)
elif key == "v1" and re.fullmatch(r"[0-9a-f]{64}", value):
signatures.append(value)
if t is None or not signatures:
return False
if int(time.time()) - t > tolerance:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, signature) for signature in signatures)
# Flask: request.get_data() is the raw body.
app = Flask(__name__)
@app.post("/backed/webhooks")
def backed_webhook():
header = request.headers.get("Backed-Signature", "")
if not verify_backed_signature(header, request.get_data(), os.environ["BACKED_WEBHOOK_SECRET"]):
return "", 400
event = request.get_json()
try:
# Yours: store the event durably (a queue or a table, de-duplicated on event["id"]),
# returning once it is stored. Do the slow work from there, after answering.
enqueue(event)
except Exception:
return "", 500 # not stored: a non-2xx answer makes Backed retry it
return "", 204 # stored: a 2xx is final, Backed will not send it againAnswer in time
- Answer a 2xx status only once the event is safely stored, such as on a queue or in a table, and within 10 seconds: store it, answer, then do the slow work from the store. A 2xx is final: Backed treats the delivery as delivered and never sends it again, so an event you acknowledge and then lose is lost.
- If you cannot store the event, answer
500(or any non-2xx status) so that Backed retries it. - Anything else, or no answer within 10 seconds, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours, 12 hours and 24 hours. After the last it is
Failed, and is sent again only if someone replays it. - An endpoint that fails 50 deliveries in a row is disabled, and the Administrators who manage integrations are emailed.
- Deliveries about one record arrive in the order they happened.
- A retry or a replay carries the same event id, so a delivery may reach you more than once: handle each id once.
What the body holds
The body names the event and the record it is about, with the record’s address in the API. It never carries form answers, bank details or contact details: read the record through the API, with the endpoint’s credential, for those. The payload’s shape is on the Event catalogue page. Send Test on the endpoint’s page sends a signed webhook.test event, so you can check your verification before a real event arrives.
