API pagination skipping records at page boundaries

Observed behavior:

  • Fetching /v1/work_orders with limit=100
  • Using cursor from next_cursor in response
  • Page 1 returns records A–J (IDs: 1001–1100)
  • Page 2 returns records B–K (IDs: 1101–1200), but record A from page 1 never appears on page 2
  • After ~10 pages, total count is 40 short of expected

Reproduction:

GET /v1/work_orders?limit=100&cursor=eyJpZCI6MTEwMH0=
# response
{
  "data": [...],
  "next_cursor": "eyJpZCI6MTIwMH0=",
  "has_more": true
}

Timestamp ordering in DB appears stable. No concurrent deletes during fetch. Same behavior observed with customers endpoint.

Documentation states cursor is "opaque" — need to know if implementation is timestamp-based vs ID-based. Gap suggests records with identical sort values are being dropped at boundary.

Parents
  • Oof — yeah this one tripped me up too when I first looked at it. Devon's workaround will get you unblocked, but here's the actual issue:

    The default cursor is timestamp-based with millisecond precision. When you have records created in the same millisecond (which happens more than you'd think with webhooks, bulk imports, etc.), the cursor pagination uses created_at > cursor_ts OR (created_at = cursor_ts AND id > cursor_id). There's a bug in how we're encoding that second clause in the cursor itself — under certain conditions the id component gets dropped, so you get created_at >= cursor_ts only, which skips the id filter entirely.

    Result: records with identical timestamps can get lost at page boundaries when the cursor degenerates to timestamp-only.

    Devon's sort_by=id works because it generates a simpler cursor that's just id > cursor_id — no compound logic to break. Heads up though: this won't respect creation order if you've got out-of-sequence IDs from data imports.

    Proper fix is in PR now, targeting next week's deploy. We'll be switching to ULID-based cursors that encode both time and randomness monotonically. See this workaround article for the ID-based approach if you need something today.

    If you want to verify you're hitting this specific bug: check if your missing records all share created_at values with records that appeared at the start of a page. That's the smoking gun.

  • Confirmed. Missing records have created_at identical to page boundary records. Using sort_by=id now, verified complete set retrieved.

    Will monitor for ULID cursor deploy.

Reply Children
No Data