thin events, verified signatures, and honest delivery guarantees
Webhooks
Subscribe to changes in a user's tasks. Events are thin: an id and a type, never the object.
{
"id": "evt_aCyx2ukBPQnIfeQ7",
"type": "todo.updated",
"occurredAt": "2026-09-15T18:02:11.000Z",
"data": { "object": "todo", "id": "8E4F2A1B", "sub": "8Kd2mQ..." }
}Thin for two reasons, and either alone would be enough.
The server cannot read the row — it is ciphertext — so a fat event could only ever carry an envelope no subscriber can open. And a webhook endpoint is a URL: sending content there makes the content's blast radius your log retention.
Subscribe
curl -s https://api.inittasks.com/v1/webhooks \
-X POST \
-H "Authorization: Bearer $INITTASKS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://standup.example/hooks/inittasks",
"events": ["todo.created", "todo.updated", "todo.deleted"],
"description": "standup bot"
}'{
"id": "wh_9Kd2mQx4",
"url": "https://standup.example/hooks/inittasks",
"events": ["todo.created", "todo.updated", "todo.deleted"],
"active": true,
"secret": "whsec_R2x9...",
"createdAt": "2026-09-15T18:00:00.000Z"
}The secret is shown once. Store it now; the server keeps no copy it can show you again. Lost it? Rotate.
All of /webhooks needs webhooks:write, the reads included — listing subscriptions enumerates every channel out of the account.
Events
todo.*, container.*, inbox.*, tag.*, filter.*, attachment.* — each with .created, .updated, .deleted — plus webhook.ping and webhook.disabled.
There is no todo.completed
status is encrypted. The server genuinely cannot tell a completion from a rename — both are todo.updated. You do the comparison:
// ⛔ This event does not exist. Do not wait for it.
// if (event.type === 'todo.completed') { ... }
// ✅ Compare against what you last saw.
const seen = new Map(); // id -> status
async function onEvent(event) {
if (event.type !== 'todo.updated') return;
const res = await fetch(`https://api.inittasks.com/v1/todos/${event.data.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 404) { seen.delete(event.data.id); return; }
const todo = await res.json();
const before = seen.get(todo.id);
seen.set(todo.id, todo.status);
if (before !== 'completed' && todo.status === 'completed') {
await celebrate(todo);
}
}Keep the last-seen status keyed by id. That is the whole trick, and it is the same shape you would need anyway for a server that could send completions, because events can arrive out of order.
Verify the signature
Every delivery carries:
X-InitTasks-Event-Id: evt_aCyx2ukBPQnIfeQ7
X-InitTasks-Event: todo.updated
X-InitTasks-Signature: t=1789459331,v1=8f3c...,v1=1b90...The signed string is "<t>.<raw body>", HMAC-SHA256, hex. Verify against the raw bytes — parsing and re-serialising the JSON changes them.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
export function verify(rawBody, header, secret) {
const t = Number(/t=(\d+)/.exec(header)?.[1]);
if (!Number.isFinite(t)) return false;
// ⛔ Reject an old timestamp. Without this, a captured delivery replays
// forever — the signature stays valid because the body never changes.
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const expectedBuf = Buffer.from(expected, 'utf8');
// There may be TWO v1 values during a secret rotation. Accept either.
return [...header.matchAll(/v1=([0-9a-f]+)/g)].some((m) => {
const got = Buffer.from(m[1], 'utf8');
return got.length === expectedBuf.length && timingSafeEqual(got, expectedBuf);
});
}With Express, take the raw body before the JSON parser:
app.post('/hooks/inittasks',
express.raw({ type: 'application/json' }),
(req, res) => {
if (!verify(req.body, req.get('X-InitTasks-Signature') ?? '', SECRET)) {
return res.sendStatus(404);
}
res.sendStatus(200); // ACK fast, work afterwards
queue.push(JSON.parse(req.body.toString('utf8')));
});Rotating a secret
POST /webhooks/{id}/rotate-secret returns a new secret and keeps the old one valid for 24 hours. During the overlap, deliveries are signed with both and the header carries two v1= values — so you can deploy the new secret without dropping the events that land mid-deploy. That is why the verifier loops over every v1= instead of reading the first.
Delivery
- HTTPS only. The hostname must resolve to a public address, checked at send time; no redirects are followed; 10-second timeout.
2xxis delivered. Anything else is retried at +1 min, +5 min, +30 min, +2 h, +8 h.- After three days of continuous failure the subscription is set
active: false, and awebhook.disabledevent goes to your other subscriptions. - If you have no other subscription,
GET /mereportswebhooks.disabled— that is the only channel left, so check it. - The last 100 deliveries per subscription are kept for 30 days:
GET /webhooks/{id}/deliveries, andPOST .../redeliverto try one again.
Guarantees, stated honestly
Inbound is best-effort and at-most-once. The change feed this API listens to makes one attempt per event and never retries, and its queue is not crash-safe. A dropped event is not detectable from either side.
So: webhooks are a latency optimisation, not a source of truth. Reconcile on a timer:
curl -s "https://api.inittasks.com/v1/todos?updated_since=2026-09-15T17:00:00Z" \
-H "Authorization: Bearer $INITTASKS_TOKEN"Keep a high-water mark, poll every few minutes, and treat webhooks as the thing that usually makes the poll find nothing. Any integration that assumes every event arrives will eventually be quietly wrong.