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.

  • Yeah, I hit this last quarter. The cursor is technically a base64-encoded JSON struct with id and created_at, but the sorting only uses created_at ASC, id ASC. Problem is the query uses WHERE created_at >= cursor.created_at — that >= is the issue.

    If two records have the same created_at (common with bulk imports), the second page's >= includes the last record from page 1 again, but the API dedupes it... somehow incorrectly? I never traced exactly where the drop happens.

    Workaround that worked for me: add an explicit sort_by=id and sort_dir=asc. This forces ID-only cursor generation and the boundary logic becomes WHERE id > cursor.id — no more duplicates at boundaries.

    Worth noting: sort_by isn't documented for the work orders endpoint, but it's there. YMMV on other endpoints.

  • 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.

  • From a specification standpoint, cursor-based pagination (a.k.a. "keyset pagination") should guarantee stability if and only if the sort key(s) are unique. The current default of created_at — which has millisecond granularity — clearly violates this precondition when ingestion throughput exceeds 1K records/second or when bulk operations set identical timestamps.

    The sort_by=id workaround is effectively switching to a candidate key (assuming id is monotonic), which satisfies the uniqueness requirement. However, I would note that id monotonicity is an implementation detail, not a documented contract. Applications requiring strict ordering guarantees should probably not rely on this.

    It is worth asking whether the API should reject non-unique sort keys with a 400 and a requirement to specify a tie-breaker, i.e., enforce UNIQUE constraint equivalent at the API layer. This would fail fast rather than fail silent.

  • That's a fair critique — we should probably document the sort key behavior more explicitly at minimum. Right now the "opaque cursor" language lets us hand-wave the implementation, which... yeah, leads to exactly these surprises.

    The ULID migration gets us there practically (128-bit sortable unique IDs), but you're right that we don't enforce uniqueness constraints at the API layer. I'll bring that back to the team — at very least we could warn when a sort key has duplicates in the result set.