Developers · 05
Webhooks
HTTPS POST to your URL when a matching change is recorded. HMAC-signed; verify before you act.
Register
Admin → Integrations → Webhooks. Public HTTPS URL. Subscribe to the events you need (Events).
Copy whsec_… once.
Each delivery is a POST:
| Header | Value |
|---|---|
X-Prototype-Event | e.g. spec.published |
X-Prototype-Delivery | uuid, safe to use as an idempotency key |
X-Prototype-Timestamp | unix seconds |
X-Prototype-Signature | sha256=<hex> of {timestamp}.{raw_body} |
Respond 2xx within 10 seconds. Anything else is retried on the daily pass.
URL must be HTTPS (HTTP localhost is allowed outside production). No credentials in the URL.
Verify
Use the raw body (do not JSON.parse then stringify). Compare in constant time:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, timestamp, rawBody, signatureHeader) {
const expected = "sha256=" + createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}Reject if the signature fails, or if the timestamp is more than five minutes off. Skip duplicate X-Prototype-Delivery ids.
Payload
{
"id": 1842,
"type": "spec.published",
"action": "published",
"target_kind": "specification",
"target_id": "0f3c…",
"actor_id": "user_…",
"occurred_at": "2026-08-22T05:12:00.000Z",
"diff": { "before": { "value": 3.8 }, "after": { "value": 4.2 } }
}type is the event you subscribed to. target_kind and diff differ by event; Events shows an example for each. There is no project slug on the payload. List /projects, then the nested collections, if you need to fan out.
Ping: POST /api/v1/webhooks/ping with {"webhook_id":"<uuid>"} (scope webhooks:manage) sends a synthetic change.created.