# Webhooks

Subscribe to changes in a user's tasks. Events are **thin**: an id and a type, never the object.

```json
{
  "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

```bash
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"
  }'
```

```json
{
  "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](/reference/webhooks).

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:

```js
// ⛔ 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:

```http
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.

```js
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:

```js
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.
- `2xx` is 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 a `webhook.disabled` event goes to your *other* subscriptions.
- If you have no other subscription, `GET /me` reports `webhooks.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`, and `POST .../redeliver` to 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:

```bash
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.
