Changes feed
Incremental task sync that carries deletions — the one thing a list endpoint can never tell you.
GET /api/v1/changes/tasks/?since=2026-08-25T00:00:00ZRequires tasks:read.
Why this is not a filtered task list
A list endpoint returns what exists. It has no way to mention what stopped
existing, because a deleted task is not in any list by definition. So a client
syncing with ?updated_after= accumulates ghosts: rows it once saw, that will
never appear again, and that it has no way to distinguish from rows that simply
did not change.
That is why this lives under /changes/ rather than /tasks/. It is not a task
list — it is a change log, and deletions are the reason it exists.
The response
{
"success": true,
"data": {
"changes": [
{
"action": "updated",
"key": "krrim-0042",
"changed_at": "2026-08-25T09:14:03.117000Z",
"task": { "key": "krrim-0042", "title": "Investigate checkout timeout" }
},
{
"action": "deleted",
"key": "krrim-0038",
"changed_at": "2026-08-25T09:22:41.004000Z"
}
]
},
"pagination": { "limit": 200, "next_cursor": "1841", "has_more": true }
}A deleted change carries no task — there is nothing left to serialize.
Apply it by removing your copy.
Walking it
Start from a timestamp
?since= on your first call. Anything older than your last successful sync is
safe; overlapping is harmless because changes are idempotent to apply.
Follow the cursor
Keep passing next_cursor until has_more is false. Do not re-derive
the next page from a timestamp — see the ordering note below.
Record the high-water mark
Store the last changed_at you successfully applied, and use it as ?since= on
the next run. Store it only after the whole walk succeeds; a partial walk that
advances the mark loses whatever it did not reach.
Encode the + in your timestamp. A literal + in a query string decodes
to a space, so ?since=2026-08-25T00:00:00+00:00 fails to parse — and a
timestamp that fails to parse means the filter is skipped and you receive
everything, believing you asked for a window. Send Z, or %2B00:00.
Ordering
Like all cursor pagination in Krrim, the feed orders by primary key rather than
by changed_at, because a stable cursor needs a column that never changes.
The practical consequence: changed_at values are not monotonic across the
walk. Take the maximum you saw, not the last one you saw.
Choosing between this and webhooks
| Changes feed | Webhooks | |
|---|---|---|
| Direction | You pull | We push |
| Latency | Your polling interval | Seconds |
| Missed while you were down | Still there | Retried for ~9 hours, then abandoned |
| Rate limit cost | Counts against your budget | Free |
| Deletions | Yes | task.deleted |
Most robust integrations use both: webhooks for latency, and a periodic pass over the changes feed as the backstop that catches anything a delivery outage outlived.