Integrating webhooks
This guide takes you from nothing to a verified, replay-safe webhook receiver. For the reference on the envelope, headers, and delivery ledger, see the Webhooks overview.
Register an endpoint
Create a subscription with your merchant (fdk_) credential. The route requires
the manage:own_webhooks scope. The tenant is taken from the credential — never
from the request body.
curl -X POST https://api.ruo.pro/v1/merchant/webhooks \
-H "authorization: Bearer $FDK_TOKEN" \
-H "content-type: application/json" \
-d '{
"url": "https://hooks.example.com/flightdeck",
"events": ["commerce.order.placed.v1"],
"description": "order notifications"
}'url must be a public HTTPS endpoint — the platform rejects private, loopback,
link-local, and metadata addresses at registration time (and re-checks at every
dispatch). events must be a non-empty array drawn from the
deliverable-event allowlist; an unknown
name is a 400.
The response returns the subscription and, exactly once, its signing secret:
{
"subscription": {
"id": "…",
"url": "https://hooks.example.com/flightdeck",
"description": "order notifications",
"events": ["commerce.order.placed.v1"],
"active": true
},
"secret": "whsec_…"
}The whsec_… secret is shown only in this response and is never readable
again. Store it somewhere your receiver can reach it. If you lose it, delete
the subscription and create a new one.
Receive a delivery
Deliveries arrive as an HTTP POST with a JSON body and the
signature headers. Your endpoint must:
- Read the raw request body as bytes before any JSON parsing. The signature is over the exact bytes on the wire, so a parse-and-re-serialize round trip will change whitespace or key order and break verification.
- Verify the
x-webhook-signature-v1signature (next step). - Respond with any
2xxwithin 10 seconds. Later than that and the attempt times out and counts as a failure. Acknowledge first, do slow work after.
In Express, capture the raw body for the webhook route only:
import express from "express";
const app = express();
app.post(
"/flightdeck",
express.raw({ type: "application/json" }), // req.body is a Buffer
(req, res) => {
const rawBody = req.body.toString("utf8");
if (!verifyWebhook(rawBody, req.headers, process.env.FLIGHTDECK_WEBHOOK_SECRET!)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(rawBody);
// Acknowledge immediately; process out of band.
res.status(204).end();
void handleEvent(event);
},
);Verify the signature
The v1 scheme is HMAC-SHA256 over `${timestamp}.${rawBody}` with your
subscription secret, hex-encoded, carried as t=<ts>,v1=<hex> in
x-webhook-signature-v1. The timestamp is the same value sent in
x-webhook-timestamp (Unix seconds) — it is bound into the signature, so once
the signature verifies you can trust the timestamp to reject stale deliveries.
This function mirrors the platform’s signer exactly:
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60; // reject deliveries older than 5 minutes
/**
* @param rawBody the EXACT request-body bytes as a string — do not
* re-serialize parsed JSON; the signature is over the wire bytes.
* @param headers the incoming request headers.
* @param secret your subscription secret (whsec_…).
*/
export function verifyWebhook(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
secret: string,
): boolean {
const header = headers["x-webhook-signature-v1"];
if (typeof header !== "string") return false;
// Parse the `t=<ts>,v1=<hex>` header value.
const fields = Object.fromEntries(
header.split(",").map((kv) => {
const i = kv.indexOf("=");
return [kv.slice(0, i), kv.slice(i + 1)];
}),
);
const ts = Number(fields.t);
const received = fields.v1;
if (!Number.isInteger(ts) || typeof received !== "string") return false;
// Recompute HMAC-SHA256(secret, `${ts}.${rawBody}`) and compare in
// constant time — never with === (that leaks the match length by timing).
const expected = createHmac("sha256", secret)
.update(`${ts}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(received, "hex");
if (a.length !== b.length || !timingSafeEqual(a, b)) return false;
// Only NOW is the timestamp trustworthy (it was signed): reject stale
// deliveries so a captured request cannot be replayed later.
const ageSeconds = Math.abs(Date.now() / 1000 - ts);
return ageSeconds <= TOLERANCE_SECONDS;
}Three details are load-bearing:
- Sign
`${ts}.${rawBody}`, not the body alone. The${ts}.prefix is what binds the timestamp and makes the signature replay-safe. It is also what keeps thev1digest distinct from the deprecated bare-body digest. - Compare in constant time. Use
timingSafeEqual(or your language’s equivalent), never===or string equality. A byte-by-byte comparison that short-circuits leaks how much of the signature you guessed correctly. - Reject stale timestamps. Verify the signature first, then check the age. Reject anything outside a few minutes (5 minutes is a reasonable default), so a delivery captured off the wire cannot be replayed hours later. Because a retry is signed with a fresh timestamp, legitimate retries always fall inside the window.
Do not verify x-webhook-signature (the unversioned header). It is a
deprecated bare HMAC of the body with no bound timestamp, so it offers no
replay protection — a captured delivery verifies against it forever. It is sent
only for back-compat during the migration window and will be removed. New
integrations must verify x-webhook-signature-v1 only.
Handle retries idempotently
The platform is at-least-once: if your endpoint
processed a delivery but the 2xx acknowledgement never reached us, the delivery
is retried and you see the same event again. Retries re-send the identical
envelope bytes, including the same event id; only the per-attempt timestamp
header and its v1 signature change.
Dedupe on the envelope id (equivalently, x-webhook-event-id). Record it the
first time you fully process an event and treat a repeat as a no-op:
async function handleEvent(event: { id: string; type: string; data: unknown }) {
const firstTime = await markProcessed(event.id); // e.g. an INSERT … ON CONFLICT DO NOTHING
if (!firstTime) return; // already handled — a retry; do nothing
switch (event.type) {
case "commerce.order.placed.v1":
await onOrderPlaced(event.data);
break;
// platform.test.v1 arrives from a test fire; handle or ignore as you like.
}
}Because ordering is not guaranteed, key your processing off the payload’s own fields rather than arrival order.
Test end-to-end
Fire a real, signed test delivery at your endpoint before any live order flows:
curl -X POST https://api.ruo.pro/v1/merchant/webhooks/$SUBSCRIPTION_ID/test \
-H "authorization: Bearer $FDK_TOKEN"This sends the same HMAC scheme and headers as a production delivery, with event
type platform.test.v1 and test: true in data, and records it in your
delivery ledger. The response reports the honest outcome — your endpoint’s HTTP
status and measured latency, or the connection error:
{
"delivery_id": "…",
"event_type": "platform.test.v1",
"outcome": "delivered",
"status_code": 204,
"latency_ms": 42,
"error": null
}A failed test is recorded dead_lettered immediately — tests never enter the
retry ladder — and never counts toward auto-pause, so you can iterate against a
broken endpoint without pausing your real traffic.
Verifying in another language
The scheme is language-agnostic. In any stack:
- Read the raw request body as bytes.
- Read
x-webhook-signature-v1and split it on,intot=<ts>andv1=<hex>. - Compute
HMAC-SHA256(secret, ts + "." + rawBody)and hex-encode it. - Compare your hex digest to the header’s
v1value using a constant-time comparison. - If they match, check that
now - tsis within your tolerance (for example 300 seconds) and reject otherwise. - Respond
2xxwithin 10 seconds; dedupe on the envelopeid.
Checklist
- Verify
x-webhook-signature-v1; ignorex-webhook-signature. - Sign
`${ts}.${rawBody}`over the raw bytes, not parsed-then-re-serialized JSON. - Compare digests in constant time.
- Reject deliveries whose signed timestamp is outside your tolerance window.
- Respond
2xxwithin 10 seconds; process slow work afterward. - Dedupe on the envelope
id— the platform is at-least-once. - Store the
whsec_…secret from the registration response; it is shown only once.