# 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](/webhooks/delivery), 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](/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](/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](/errors/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](/trash).

## 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](/errors/not_found).** There is no error for a cursor whose row has gone. The only cursor error is [validation_failed](/errors/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

- [Pagination and filtering](/pagination)
- [Delivery and reconciliation](/webhooks/delivery)
