Skip to navigation

Pagination

Cursor-based, both directions, constant cost at any history size

List endpoints (GET /v1/messages, GET /v1/workspaces) paginate by cursor and always return rows newest-first.

The envelope

{
"object": "list",
"data": [ ... ],
"pagination": {
"limit": 2,
"total": 13,
"total_capped": false,
"has_next": true,
"has_prev": false,
"next_cursor": "v1_eyJiIjogWyIyMDI2LTEwLTAyVDA2OjM4OjU0...",
"prev_cursor": "v1_eyJiIjogWyIyMDI2LTEwLTA2VDEzOjExOjIx..."
}
}

Walking the list

  • First request — no cursor, optionally limit (1–100, default 20).
  • Older items: request again with ?cursor=<next_cursor>.
  • Newer items: ?cursor=<prev_cursor>. From the top of the list this returns rows created after your first request — a built-in “load fresh” poll; an empty data means you’re at the top.
  • Stop when next_cursor is null (equivalently, when has_next is false).
curl "https://api.rray.app/v1/messages?status=delivered&limit=50" \
-H "Authorization: Bearer $RRAY_API_KEY"
# then repeat with &cursor=<pagination.next_cursor> until null

Rules cursors enforce

  1. Keep the query identical between hops. The cursor is fingerprinted with your filters and limit; changing them mid-walk returns 400 validation.cursor_filters_mismatch. Start a fresh walk instead.
  2. Cursors are opaque. Don’t parse or construct them — malformed values return 400 validation.cursor_invalid.

About total

Counting is expensive on large histories, so total appears only on the first page (no cursor) and is capped at 10 000: "total": 10000, "total_capped": true means “10 000 or more”. While walking with cursors, total is null — use has_next and has_prev.

Filtering messages

GET /v1/messages composes these filters (all AND-ed), and each one is part of the cursor fingerprint:

ParameterBehaviour
idThe exact message id returned by POST /v1/messages
statusAggregate status; repeat the param for several (status=queued&status=delivered)
recipientMessages addressed to this exact address on any channel (to, cc or bcc)
created_atCreation time, RFC 3339 — see below

created_at takes one or two values:

  • Two values → an inclusive range, order irrelevant (created_at=2026-10-01T00:00:00Z&created_at=2026-10-31T23:59:59Z).
  • One full timestamp → exact match (created_at=2026-10-07T09:30:00Z).
  • One date, no time → that whole calendar day (created_at=2026-10-07).

Include an offset for local-time filtering (2026-10-07T00:00:00+07:00); a value without an offset is read as UTC. To show a user their own day, send the two local-day boundaries with their offset.