Reliable signals for work that takes time
Signed HTTPS requests when jobs, domains, publications or approvals change, delivered at least once.
Register an endpoint
Add an endpoint on the Developers page, or with the webhooks:write scope:
curl -s -X POST "$BASE/workspaces/$WS/webhooks" \
-H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $(openssl rand -hex 16)" \
-H "Content-Type: application/json" \
-d '{"url":"https://hooks.example.com/mini-casa","events":["job.updated","publication.updated"]}'The answer contains signing_secret (whsec_...), shown once. The URL must be HTTPS on port 443 and resolve only to public addresses; private, loopback and internal targets are refused, the check runs again before every delivery, and redirects are never followed. A workspace can have up to 10 live endpoints.
Prove you control it
New endpoints start as pending_verification. The Verify button (or POST /webhooks/{id}/verify) sends one signed webhook.challenge. Answer 2xx with {"challenge": "<data.challenge>"} and the endpoint becomes active.
Events
job.updated: a job or bulk run changes state.job.item.updated: one item of a bulk run changes state.domain.updated: a custom domain moves through its checks.publication.updated: a build starts, succeeds, fails or is superseded.approval.updated: an approval is decided, expires or is used.billing.entitlement.updated: the plan or entitlements change.
Bodies carry ids and a state only, never credentials, content or personal data. Fetch the current state with the API.
Verify every request
Each request has SmallWorld-Webhook-Id, SmallWorld-Webhook-Timestamp (unix seconds) and SmallWorld-Webhook-Signature (v1=<hex>,kid=<key id>). The signature is HMAC-SHA256 of <timestamp>.<raw body> with your secret. Use the raw body before any JSON parsing, compare in constant time and reject timestamps more than five minutes from your clock.
import crypto from "node:crypto";
export function verify(secret, timestamp, rawBody, header) {
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = /v1=([a-f0-9]+)/.exec(header)?.[1] ?? "";
const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300;
return fresh && given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}Delivery guarantees
- At least once. A request repeats until you answer
2xx. The event id stays the same on every retry and redelivery, so store it and ignore ids you have seen. - Retries back off from one minute to a day, for 72 hours in total. After that the delivery is a dead letter you can send again from the Developers page or with
redeliver_webhook. - An endpoint that dead-letters 10 events in a row is paused until you resume it.
- Order is not guaranteed: treat the state as a hint and read the resource.