Skip to main content

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.

Webhook events
EventWhen it is sent
job.succeededA job finished and its pictures are ready.
job.failedA job ended without a result.
job.canceledA job was canceled before it finished.
webhook.testSent 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

Headers of a webhook message
HeaderWhat it holds
webhook-idThe event id. It is the same on every attempt, so you can drop a repeat.
webhook-timestampThe time of the message in whole seconds since 1970.
webhook-signatureThe 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.

  1. Read the three headers and the raw body, exactly as they arrived.
  2. Reject the message if the timestamp is more than 300 seconds from your clock.
  3. Join the id, the timestamp and the body with full stops.
  4. Make an HMAC with SHA-256 over that text. The key is the secret without whsec_, decoded from base64.
  5. 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.