Reference
Pagination
Lists that can grow without bound are paginated with a keyset cursor: ask for a page, get an opaque cursor back, ask for the next page from that cursor. There are no page numbers and no offsets.
How it works
GET /v1/messages?limit=50
{
"data": [ … 50 messages, newest first … ],
"hasMore": true,
"nextCursor": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ"
}
GET /v1/messages?limit=50&before=msg_AZLm3kQ8T2Sf9pXbNc7HrQ| Field | Notes |
|---|---|
| limit | Request parameter. Per-endpoint bounds and defaults; 1–100 default 25 for most, 1–200 default 50 for suppressions. |
| before | Request parameter. The nextCursor from the previous page. It is the id of the last row you received, so the next page resumes at the row immediately after it. An id of the wrong kind returns 400 invalid_request. |
| data | The rows, newest first. |
| hasMore | Whether another page exists. This is the value to loop on. |
| nextCursor | The cursor to pass as before next time. |
Keyset rather than OFFSET for two reasons. Offset gets slower the deeper you page, and on a table that grows by every send it also skips and repeats rows when new messages arrive mid-scan — quiet duplicates that nothing about the result would reveal. The cursor compares the (created_at, id) pair, so a hundred messages written in the same millisecond page correctly rather than all but one being skipped at the boundary.
Loop on hasMore, never on nextCursor
GET /v1/inbound/messages returns a non-null nextCursor on its last page. Any page with at least one row gets a cursor, whether or not there is anything after it. hasMore is correct on every endpoint; nextCursor is not.
A loop written as while (cursor != null) therefore terminates on /v1/messages and /v1/suppressions, where the cursor goes null, and spins for ever on /v1/inbound/messages, re-requesting the same final page. It is the kind of bug that passes every test written against the first two endpoints.
import { Posthaste } from '@posthaste/sdk'
const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_KEY })
// One page, exactly as the endpoint returns it.
const page = await posthaste.messages.list({ limit: 50, status: 'bounced' })
page.data // MessageSummary[]
page.hasMore // the only correct loop condition
page.nextCursor // pass as `before` on the next request
// Every page. autoPaginate loops on hasMore for you, so the trap below is
// closed by construction — it also stops on an empty page and on a cursor
// that fails to advance.
for await (const message of posthaste.messages.autoPaginate({ status: 'bounced' })) {
await suppress(message.to)
}
// Or collect, with a ceiling you choose rather than an unbounded drain.
const recent = await posthaste.messages.listAll({ status: 'bounced' }, 500)
// Suppressions work identically.
for await (const entry of posthaste.suppressions.autoPaginate({ reason: 'complaint' })) {
await flag(entry.address)
}Checking both — stop when hasMore is false or the cursor is missing — costs nothing and is correct against every endpoint, present and future. The TypeScript SDK does exactly that inside autoPaginate, so there is no loop to get wrong.
Which lists paginate
| Endpoint | Paginated | Notes |
|---|---|---|
| /v1/messages | Yes | limit 1–100, default 25. nextCursor is null on the last page. |
| /v1/suppressions | Yes | limit 1–200, default 50. nextCursor is null on the last page. |
| /v1/inbound/messages | Yes | limit 1–100, default 25. Cursor is non-null on the last page. |
| /v1/domains | No | Everything on the account. Domains are few. |
| /v1/webhooks | No | Everything on the account. |
| /v1/api-keys | No | Capped at the 100 most recent, including revoked ones, with no cursor. |
| /v1/inbound/addresses | No | Capped at the 100 most recent, with no cursor. |
| /v1/billing/invoices | No | Capped at the 100 most recent, with no cursor. |
| /v1/billing/history | No | The whole ledger for the account. |
The capped lists truncate silently. They carry no hasMore, so a hundred-and-first key, inbound address or invoice is simply absent with nothing in the response to say so. If you might exceed a hundred of any of them, do not treat the list as complete.
What a cursor guarantees
Rows are ordered newest first, and a cursor is a position in that order — so a page you fetch after new rows have arrived does not shift under you, because the new rows sort ahead of where you are reading. Walking a list to its end therefore gives you a consistent snapshot backwards in time, not a consistent snapshot of the whole list.
Cursors are just ids and do not expire. Storing one to resume a sync later is a legitimate use — pass the newest id you have already processed as before when you resume, or filter on from if you would rather bound by time.
NextIdempotency →Retry a send safely — and the header that looks like it works but does not.