Webhooks
Subscribe to changes, verify the signature, and handle delivery correctly.
Webhooks push a signed JSON payload to your endpoint when something changes, so you do not have to poll.
Events
| Event | Fires when |
|---|---|
task.created | A task is created |
task.updated | A task changes |
task.deleted | A task is deleted |
phase.status_changed | A phase's status changes, including gate completion |
project.created | A project is created |
project.updated | A project changes |
comment.created | A comment is posted |
An endpoint subscribes to one or more. Anything not in this list is rejected when you register.
Registering an endpoint
curl -X POST https://cadence.alen.world/api/v1/webhooks \
-H "Authorization: Bearer $CADENCE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/cadence",
"events": ["task.created", "task.updated", "phase.status_changed"]
}'The response includes a generated secret:
{
"data": {
"id": "1c9f…",
"url": "https://example.com/hooks/cadence",
"events": ["task.created", "task.updated", "phase.status_changed"],
"isActive": true,
"secret": "9a2b…",
"createdAt": "2026-08-19T09:02:11.482Z"
}
}Endpoint management in the app UI is not built yet — register and manage endpoints through the API above.
The payload
Every delivery is a POST with this body:
{
"event": "task.updated",
"data": { "id": "7c1e…", "title": "Book line trial", "status": "in-progress" },
"timestamp": "2026-08-19T09:02:11.482Z"
}And these headers:
| Header | Contains |
|---|---|
Content-Type | application/json |
X-Cadence-Event | The event type, so you can route without parsing the body |
X-Cadence-Signature | HMAC-SHA256 of the raw body, keyed with your endpoint secret, hex encoded |
Verifying the signature
Compute the HMAC over the raw request body, exactly as received. Parsing and re-serialising the JSON first will change the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, signature, secret) {
const expected = createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature ?? '', 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}import { createHmac, timingSafeEqual } from 'node:crypto';
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get('x-cadence-signature') ?? '';
const expected = createHmac('sha256', process.env.CADENCE_WEBHOOK_SECRET!)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
// Acknowledge fast, then do the work out of band.
return new Response(null, { status: 204 });
}Always compare with a constant-time function. A plain === on the hex strings leaks timing information.
Delivery behaviour
- Deliveries are fired after the change has been committed, in parallel to every subscribed endpoint.
- Each request times out after 10 seconds. Acknowledge quickly and do real work asynchronously — a slow endpoint is a failed endpoint.
- Any
2xxcounts as success; anything else, or a timeout, is recorded as a failure. - Every attempt is logged with its status code and the first part of your response body, which is where to look when something is not arriving.
Building a good endpoint
- Verify first. Reject anything whose signature does not match, before you read the payload.
- Be idempotent. Handle the same event arriving twice without doubling its effect.
- Return fast. Acknowledge, then queue.
- Do not trust the body's shape blindly. Payloads carry the changed record; new fields can appear over time.
Pausing and removing
Set isActive to false with PATCH /webhooks/:id to stop deliveries while keeping the endpoint and its secret — useful during maintenance. Delete it when it is genuinely finished with.