Skip to content
docs/webhooksGuides

Webhooks

Be called back when a job finishes, and verify that the call came from us.

A job can call you instead of being polled. Give a submit a URL and the terminal state is POSTed to it, for failures and cancellations as well as successes, which is the point of having one: a receiver told only about successes would still have to poll to learn about everything else. Deliveries are signed, retried and at-least-once, and all three of those change how the handler has to be written.

Asking for a callback

cURL
curl -X POST https://api.routehook.ai/v1/queue/fal/veo3 \
  -H "Authorization: Bearer $ROUTEHOOK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "drone shot over a harbour at dawn",
    "duration": 5,
    "webhook_url": "https://your.app/hooks/routehook"
  }'

The payload

One POST, application/json, sent when the job reaches a terminal state. status is always COMPLETED (a delivery only happens on a terminal state), so outcome is the field that carries the news. payload is the upstream's result body, exactly as the result route would have returned it, and is null unless the outcome is succeeded; error is filled in when it is not. gateway_request_id is the req_… usage-log row, so a receiver can reconcile the charge without a second call.

JSONPOST https://your.app/hooks/routehook
{
  "request_id": "job_ab12cd34",
  "gateway_request_id": "req_7c41d9be",
  "status": "COMPLETED",
  "outcome": "succeeded",
  "payload": {
    "video": { "url": "https://cdn.routehook.ai/vid/ab12cd34.mp4" }
  },
  "error": null
}

The three headers

HEADERVALUE
X-Routehook-Webhook-IdIdentifies this delivery. Stable across every retry of it. This is what you de-duplicate on.
X-Routehook-Webhook-TimestampUnix seconds at the moment this attempt was sent. A retry carries the time it was retried.
X-Routehook-Webhook-Signaturev1=<hex>, the HMAC-SHA256 of <id>.<timestamp>.<body> keyed on your account's webhook secret. The v1= prefix exists so the digest can be changed later without inventing a fourth header.

What is signed, and why not the body alone

A signature over the body alone is valid for ever and is byte-identical every time that body is sent. Anyone who captures a single delivery (a proxy log, a mis-configured receiver, an intermediary) can replay it whenever they like, and your HMAC still verifies, because nothing inside what was signed says when it was sent or which delivery it was. Binding the id and the timestamp into the signed string closes both halves of that: you reject a delivery whose timestamp is outside your tolerance, and you ignore an id you have already processed. Because both values sit inside the signature, neither can be rewritten in flight to get past those checks.

Verifying a delivery

JAVASCRIPT
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.ROUTEHOOK_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 300;

const app = express();

// express.raw() on this route only. The signature is over the bytes we
// sent, and the global JSON parser would have thrown them away.
app.post("/hooks/routehook", express.raw({ type: "*/*" }), (req, res) => {
  const id = req.get("X-Routehook-Webhook-Id");
  const timestamp = req.get("X-Routehook-Webhook-Timestamp");
  const signature = req.get("X-Routehook-Webhook-Signature");

  if (!id || !timestamp || !signature || !signature.startsWith("v1=")) {
    return res.status(400).end();
  }

  // Reject anything too old to be live. A replayed capture fails here.
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) {
    return res.status(400).end();
  }

  const body = req.body.toString("utf8");
  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${id}.${timestamp}.${body}`)
    .digest("hex");

  // Constant time, and length-checked first: timingSafeEqual throws on
  // buffers of different lengths instead of returning false.
  const mine = Buffer.from(expected, "utf8");
  const theirs = Buffer.from(signature.slice(3), "utf8");
  if (mine.length !== theirs.length || !crypto.timingSafeEqual(mine, theirs)) {
    return res.status(401).end();
  }

  const event = JSON.parse(body);

  // At-least-once: seen it before, do nothing. Both functions are yours.
  if (!alreadySeen(id)) {
    recordAndEnqueue(id, event);
  }

  // Answer now; do the work off the request.
  res.status(204).end();
});

Choosing a tolerance

The timestamp is unix seconds at the moment that attempt was sent, and every retry is signed afresh with the time it went out, so your tolerance has to cover the difference between our clock and yours, not the length of the backoff. Five minutes is the usual choice and is what the samples above use. Too tight and a host whose clock has drifted a minute rejects live deliveries; too loose and a captured delivery stays replayable for as long as you allow.

Retries and backoff

The first attempt is made as soon as the job reaches a terminal state. Anything other than a 2xx, or no answer at all, is a failed attempt and is retried with exponential backoff up to a cap the operator sets, five retries by default, and zero is permitted, which makes delivery a single shot. Every attempt is recorded against the job, so a delivery that never landed is visible rather than silent. Answer quickly: a handler that renders a thumbnail before replying is indistinguishable from a broken one, and it will be retried while it is still working.

A receiver worth deploying

  • Reads the raw body, verifies, then parses. Never the other way round.
  • Compares digests in constant time. TimingSafeEqual, or hmac.compare_digest.
  • Rejects a timestamp outside its tolerance, and says 400 rather than 200.
  • De-duplicates on the delivery id before doing any work.
  • Answers within a second or two and does the work elsewhere.
  • Treats a missing callback as possible: poll GET /v1/jobs for anything that has gone quiet, rather than assuming a job that never called back never finished.

The secret is not the API key

Signatures are keyed on the account's webhook secret, which is a separate credential from the keys that spend money. A receiver needs to verify deliveries; it has no reason to hold something that can bill you, and a leak on the receiving end should not be a leak of your gateway access. Keep it in the environment beside the API key and rotate it on the same suspicion you would rotate that one.