Webhooks
A webhook is a signed message sent to your server when a job ends.
Events
The events are job.succeeded, job.failed and job.canceled.
| Event | When it is sent |
|---|---|
job.succeeded | A job finished and its pictures are ready. |
job.failed | A job ended without a result. |
job.canceled | A job was canceled before it finished. |
webhook.test | Sent only when you press Send test event on the API page. You cannot subscribe to it. |
Endpoints
An account can have up to 5 endpoints.
Each endpoint has its own signing secret. It starts with whsec_.
Headers
| Header | What it holds |
|---|---|
webhook-id | The event id. It is the same on every attempt, so you can drop a repeat. |
webhook-timestamp | The time of the message in whole seconds since 1970. |
webhook-signature | The signature of the message, in the form v1,<base64>. A receiver can accept several, separated by spaces, as the Standard Webhooks scheme allows. |
Check the signature
Check the signature of every message before you trust it.
- Read the three headers and the raw body, exactly as they arrived.
- Reject the message if the timestamp is more than 300 seconds from your clock.
- Join the id, the timestamp and the body with full stops.
- Make an HMAC with SHA-256 over that text. The key is the secret without whsec_, decoded from base64.
- Encode the result as base64 and compare it with each v1, signature in constant time.
A secret cannot be rotated in place. To change it, remove the endpoint and add it again. Each message carries one signature.
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = "YOUR_ENDPOINT_SECRET"; // the whsec_ value of your endpoint
const TOLERANCE_SECONDS = 300;
export function isGenuine(headers: Record<string, string | undefined>, body: string): boolean {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (id === undefined || timestamp === undefined || signatures === undefined) return false;
const sentAt = Number(timestamp);
if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;
const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
return signatures.split(" ").some((part) => {
const [version, value] = part.split(",");
if (version !== "v1" || value === undefined) return false;
const given = Buffer.from(value, "base64");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
// The webhook-id is the same on every attempt. Call this after isGenuine returns true: on a repeat, answer 2xx and do not handle the event again.
// This map lives in one process; keep the ids in a database or cache when you run more than one server.
const seen = new Map<string, number>();
export function isRepeat(id: string): boolean {
const now = Date.now() / 1000;
for (const [seenId, at] of seen) if (now - at > TOLERANCE_SECONDS) seen.delete(seenId);
if (seen.has(id)) return true;
seen.set(id, now);
return false;
}Retries
A failed delivery is tried again. There are up to 8 attempts over about 28 hours. An answer of 410 is final: the endpoint is turned off and nothing more is sent to it.