Receive run webhooks
Build a receiver that verifies the signature, deduplicates deliveries, and responds fast enough to avoid retries.
Add an HTTPS webhook URL to a routine and Work on Repeat posts a signed event
every time a run finishes. Two events exist: run.succeeded and run.failed.
The signing secret is returned once, in the response that created or updated the routine, and only its encrypted form is stored. If you lose it, set the webhook URL again to rotate the secret.
The delivery contract
Every attempt carries four headers:
X-Work-On-Repeat-Event run.succeeded | run.failed
X-Work-On-Repeat-Request-Id stable across retries — use it to deduplicate
X-Work-On-Repeat-Timestamp unix seconds
X-Work-On-Repeat-Signature v1=<hex hmac-sha256>The signature is computed over `${timestamp}.${rawBody}` using the
routine's signing secret. Verify it against the exact bytes received, before
parsing — re-serialising the JSON changes the digest.
See Webhook reference for the full payload schema.
Write the receiver
Verify before you trust
import { createHmac, timingSafeEqual } from "node:crypto";
const REPLAY_WINDOW_SECONDS = 5 * 60;
export function verify(
secret: string,
timestamp: string,
rawBody: string,
signature: string,
now = new Date(),
) {
const sent = Number(timestamp);
if (!Number.isInteger(sent)) return false;
// Reject stale timestamps before spending time on the digest.
const current = Math.floor(now.getTime() / 1_000);
if (Math.abs(current - sent) > REPLAY_WINDOW_SECONDS) return false;
const digest = createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`, "utf8")
.digest("hex");
const expected = Buffer.from(`v1=${digest}`);
const received = Buffer.from(signature);
return (
expected.byteLength === received.byteLength &&
timingSafeEqual(expected, received)
);
}Deduplicate on the request id
X-Work-On-Repeat-Request-Id is stable across retry attempts of the same
delivery. Store it and ignore repeats — retries are expected behaviour, not a
bug, and a receiver that processes a delivery twice will double-post its own
side effects.
Answer quickly
Deliveries time out after 10 seconds. Any 2xx counts as delivered. Do the real work after you have acknowledged.
Understand the retry policy
| Response | Behaviour |
|---|---|
2xx | Marked delivered. |
429 or 5xx | Retried with backoff. |
| Network error or timeout | Retried with backoff. |
Any other 4xx | Permanent. Not retried. |
| Redirect | Not followed. Point the URL at the final destination. |
Retries are bounded at four attempts. Backoff starts at 30 seconds and doubles per attempt — capped at 30 minutes — with jitter added so simultaneous failures do not retry in lockstep. Deliveries are persisted before they are sent, and retried by the same one-minute operations tick that runs schedules.
Testing locally
Point the routine's webhook URL at a tunnel to your machine and run the routine manually. The run detail shows each webhook delivery with its attempt count and response code, so you can confirm the receiver's behaviour without guessing.
Production refuses unsafe targets
Webhook URLs are resolved and validated like any other outbound request, and
production rejects loopback, link-local, and private addresses. Use a public
tunnel hostname rather than localhost when testing against a deployed
environment.