init.Tasks openapi.json inittasks.com

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.

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.

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

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.