WebhooksIntegration guide

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:

  1. 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.
  2. Verify the x-webhook-signature-v1 signature (next step).
  3. Respond with any 2xx within 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 the v1 digest 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:

  1. Read the raw request body as bytes.
  2. Read x-webhook-signature-v1 and split it on , into t=<ts> and v1=<hex>.
  3. Compute HMAC-SHA256(secret, ts + "." + rawBody) and hex-encode it.
  4. Compare your hex digest to the header’s v1 value using a constant-time comparison.
  5. If they match, check that now - ts is within your tolerance (for example 300 seconds) and reject otherwise.
  6. Respond 2xx within 10 seconds; dedupe on the envelope id.

Checklist

  • Verify x-webhook-signature-v1; ignore x-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 2xx within 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.