invalid_cursor
HTTP 400 retryable: false REST and MCP
The cursor you sent is not one this endpoint can resume from, either because it does not decode or because it was minted against a different set of filters.
Why it happens
A mail cursor is not an opaque offset. It carries a version, a binding that identifies the query it was created for, a sort point and an expiry, and all four are checked on the way back in. Changing the filter between pages therefore invalidates the cursor rather than silently paginating a different result set. The event cursor is a separate, simpler thing: a decimal sequence number that must be a positive integer inside the signed 64-bit range.
Every condition below was read in the implementation at the file and line given. They are the branches that raise this code, not a summary of what the name suggests.
- A thread or message cursor fails to decode, has expired, or its binding does not match the filter on the current request.
packages/domain/src/mail-pages.ts:34 - An event cursor is not 1 to 19 decimal digits, or its value exceeds 9223372036854775807.
packages/domain/src/services.ts:3133
How to fix it
- Restart the listing without a cursor. The first page is always reachable, and the cursor you were holding cannot be repaired.
- Keep the query parameters identical across a pagination run. A cursor is bound to the filter it was issued under, so changing the search term or the inbox mid-run invalidates it.
- Pass the cursor back exactly as received. Do not URL-decode it twice, trim it, or store it somewhere that truncates.
- For event polling, persist the cursor as a string. Some JSON parsers round a 19-digit sequence number through a double and corrupt the low digits.
Why it works this way
Binding the cursor to the filter is the reason this error exists at all. The alternative is to accept the cursor and paginate the new filter from an offset that means nothing in it, which returns a plausible page that silently skips or repeats records.
Messages you will see
The message field is written for a human reading a log and is not a contract
value. Branch on code instead. These are the exact strings this code is sent
with today, listed so that a search for one of them lands here.
Where it appears
These are the documented surfaces that raise it. The REST base is https://api.emailforagents.ai/v1, and the remote MCP endpoint is https://mcp.emailforagents.ai/mcp.
GET /projects/{project_id}/inboxes/{inbox_id}/threadsGET /projects/{project_id}/inboxes/{inbox_id}/messagesGET /projects/{project_id}/eventsMCP tool: list_threadsMCP tool: list_messages
The response
Read request_id off the body or the x-request-id header before
doing anything else. It is the only handle on one specific attempt, and for a 503 it is
the only handle at all, because the underlying detail stays server-side.
HTTP/1.1 400
content-type: application/json
x-request-id: req_...
{
"error": {
"code": "invalid_cursor",
"message": "Cursor is invalid, expired, or belongs to another filter.",
"request_id": "req_...",
"retryable": false
}
}
Verified against packages/domain/src/mail-pages.ts:34. The status comes from ERROR_STATUS in packages/contracts/src/errors.ts, which is the one
place a code is mapped to a status.