init.Tasks openapi.json inittasks.com

backfill once, sweep on a timer, treat webhooks as a speed-up

Keeping a copy in sync

If you hold your own copy of a user's tasks, build it in this order. The shape is not optional: inbound delivery is best-effort and at-most-once, so a design that trusts events will drift and will not tell you.

1. Backfill

Walk every list route you care about, following next_cursor until it comes back null.

bash
cursor=""
while :; do
  page=$(curl -s "https://api.inittasks.com/v1/todos?limit=200${cursor:+&cursor=$cursor}" \
    -H "Authorization: Bearer $INITTASKS_TOKEN")
  printf '%s\n' "$page" | jq -c '.data[]'
  cursor=$(printf '%s' "$page" | jq -r '.next_cursor // empty')
  [ -n "$cursor" ] || break
done

Record the server's Date header from the first page of the backfill, not the last, and not your own clock. That is your starting high-water mark. Starting from the end of a long walk loses everything that changed while you were walking.

2. Sweep on a timer

bash
curl -s "https://api.inittasks.com/v1/todos?updated_since=2026-09-16T17:00:00Z" \
  -H "Authorization: Bearer $INITTASKS_TOKEN"

Three rules for the mark:

Overlap it. Subtract a minute or two from the mark you send. A row written in the same second your last sweep read can otherwise fall between two windows.

Take it from the server. Use the Date header on the sweep response as the next mark. Your clock and the server's are not the same clock, and the gap is exactly the size of the hole.

Advance it only after the page is processed. Advancing first loses the window if your handler dies halfway.

updated_since filters on the server-side update time, which is not encrypted, which is why the server can filter on it at all. Nothing about content can be filtered server-side. See encryption.

3. Add webhooks for latency, not for truth

Subscribe, verify every delivery, and let events tell you to re-read a row sooner than the sweep would. See webhooks.

Dedupe by event id. A retry can deliver an event you already handled, so key on X-InitTasks-Event-Id and skip one you have seen. Keep the seen set for at least the retry ladder, which runs to eight hours.

Make the handler idempotent and the sweep re-delivering the same work stops being a problem too.

4. Deletion needs the sweep

There is no deleted-since feed and no tombstone. A permanently deleted row simply stops appearing, and its URL answers not_found.

So a full walk, less often than the sweep, is how you find out what went away. Compare ids and drop what the walk did not return. See trash and deletion.

What a walk guarantees

These were measured against the pager rather than reasoned about.

next_cursor is present and null on the last page of every paged list. One list is not paged and omits the key entirely: GET /v1/webhooks.

An empty page means you reached the end. A cursor names a position, not a row: the next page is the rows that sort strictly after it. So the position stays well defined when the row at it is deleted, trashed or re-keyed mid-walk, and the walk carries on from where it was.

Every row that stays put across the walk is delivered exactly once.

?updated_since= filters, it never orders. It is applied before the sort, so a filtered page still comes back in the list's own order. There is no way to ask for rows in update order.

A cursor is never not_found. There is no error for a cursor whose row has gone. The only cursor error is validation_failed with the pointer at /cursor, for a string this API did not issue.

What no cursor can give you

A row that moves across the cursor is returned twice, or missed. A row that moves from before the position to after it arrives a second time; one that moves the other way is never seen; a row created before the position is not seen either. The list is not a snapshot, and no cursor design fixes this.

So dedupe by id and make the work idempotent per row. That is the whole answer, and it is cheaper than any scheme that tries to make one pass complete.

A walk that survives all of it

  1. Overlap the window. Subtract a minute or two from the updated_since you send, every time.
  2. Dedupe by id. A row arriving twice must be an update, not a second copy.
  3. Make the handler idempotent per row id. Re-applying the same row must be a no-op.
  4. Re-walk from the start on a rejected cursor. A validation_failed on /cursor means the string did not come from this API. Do not retry it.
  5. Re-walk fully, on a slower timer. A full walk with no updated_since is the only thing that finds a row a sweep skipped, and the only thing that finds a deletion.

Cursors are opaque and positional. Never construct one, never parse one, and never store one between runs.

Next