Reference
Pagination
Every list is paginated with a keyset cursor: ask for a page, get a cursor back, ask for the next page from that cursor. There are no page numbers and no offsets. Nothing on this API returns a list whole, so a response is never evidence that you have seen everything.
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. Bounds and defaults are per-endpoint and are given in the table below — they are not uniform, and the default is never the maximum except on /v1/domains. |
| 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. /v1/billing/history uses after instead — see below. |
| data | The rows, newest first — except on /v1/billing/history, which is oldest first. |
| hasMore | Whether another page exists. Sent by three endpoints, not by all of them. Where it is present it is authoritative; where it is absent, reading it gives you undefined rather than an error. |
| nextCursor | The cursor to pass as before next time. Sent by every list. null means there is no next page on every endpoint except /v1/inbound/messages — see below. |
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.
Check both signals, and neither on its own
Two endpoints break the obvious loop, in opposite directions. Whichever single field you pick as your stopping rule, one of them defeats it — so the correct loop reads both, and the two paragraphs below are why.
while (cursor != null) never terminates on GET /v1/inbound/messages. Any page with at least one row gets a cursor there, whether or not there is anything after it — so the loop asks for the page after the last one, receives an empty page carrying the same cursor, and spins. It terminates fine everywhere else, which is what makes it the kind of bug that passes every test written against the other endpoints.
if (!page.hasMore) return stops after ONE page on six of the nine lists. /v1/domains, /v1/webhooks, /v1/api-keys, /v1/inbound/addresses, /v1/billing/invoices and /v1/billing/history send no hasMore at all. Reading it yields undefined, which is falsy, which is indistinguishable from that was everything. Nothing errors and nothing is logged; you simply get the first page and believe it is the whole list. Compare with === false, not for truthiness.
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 // sent by this endpoint; authoritative where it is sent
page.nextCursor // pass as `before` on the next request
// Every page. autoPaginate consults both signals for you, so both traps below
// are 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)
// Every list has the same three methods, including the ones that send no
// hasMore. This is all sixty invoices, not the first fifty.
const invoices = await posthaste.billing.listAllInvoices()
for await (const key of posthaste.apiKeys.autoPaginate()) {
await audit(key)
}
// Billing history pages by `after` and reads oldest first. The helper knows.
for await (const event of posthaste.billing.autoPaginateHistory()) {
await record(event.seq, event.type)
}Checking both — stop when hasMore is explicitly false, or when the cursor is null — costs nothing and is correct against every endpoint, present and future. Each check is the only correct stop on some endpoint, and each is wrong on its own somewhere else. The TypeScript SDK does exactly that inside autoPaginate, so there is no loop to get wrong.
Every list paginates
There is no longer any endpoint that returns a list whole. What differs between them is the page size, whether hasMore is sent, and — on one endpoint — the name and meaning of the cursor.
| Endpoint | limit | Cursor | hasMore | Notes |
|---|---|---|---|---|
| /v1/messages | 1–100, default 25 | before | Yes | nextCursor is null on the last page. |
| /v1/suppressions | 1–200, default 50 | before | Yes | nextCursor is null on the last page. |
| /v1/inbound/messages | 1–100, default 25 | before | Yes | Cursor is non-null on the last page. Stop on hasMore here. |
| /v1/domains | 1–100, default 100 | before | No | The only endpoint whose default is its maximum, so the first page is every domain on all but the largest accounts. Past a hundred, follow the cursor. |
| /v1/webhooks | 1–100, default 50 | before | No | Stop when the cursor is null. |
| /v1/api-keys | 1–100, default 50 | before | No | Revoked keys are listed too and count towards the limit. |
| /v1/inbound/addresses | 1–100, default 50 | before | No | Stop when the cursor is null. |
| /v1/billing/invoices | 1–100, default 50 | before | No | Stop when the cursor is null. |
| /v1/billing/history | 1–200, default 100 | after | No | Oldest first, and the cursor is a sequence number. See below. |
The six lists without hasMore truncate silently if you ignore the cursor. There is nothing in the response that says a page is partial — no flag, no count, no header. An account with sixty invoices that reads only data gets fifty of them and no indication that ten are missing, and the same is true of the fifty-first API key, webhook or inbound address. Read nextCursor on every one of them, even when you expect the list to be short.
/v1/billing/history is the exception
It is the one list on the API that reads forward, and it does not use before at all. Both differences follow from what it is: the billing history is a hash chain, a chain is verified from its start, and a record that renders in a different order than it verifies in is a record people stop trusting. So the events come back oldest first, and you resume with after.
| Every other list | /v1/billing/history | |
|---|---|---|
| Order | Newest first | Oldest first |
| Cursor parameter | before | after |
| What the cursor is | A prefixed row id, e.g. inv_… | A sequence number — the seq of an event |
| Meaning | Rows sorting after the named row | Events whose seq is strictly greater |
| limit | Caps at 100, or 200 for suppressions | 1–200, default 100 |
GET /v1/billing/history?limit=100
{
"chain": { "valid": true, "brokenAt": null, "reason": null },
"nextCursor": "100",
"data": [ … 100 events, OLDEST first, seq 1 … 100 … ]
}
# "100" is a sequence number, not an id. Read it forward:
GET /v1/billing/history?limit=100&after=100nextCursor is that sequence number rendered as a string, so passing it straight back as after is correct — and it is null on the last page, which is the stopping condition since there is no hasMore here either. Passing an id rather than a number is refused with 400 invalid_request.
chain travels with every page and is the verification of the whole account history, not of the page you are holding — so it means the same thing on page four as on page one, and paging through the record does not weaken the guarantee it makes.
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.
/v1/billing/history reads the other way, and gains something for it: because it walks forward from a sequence number, new events land ahead of your cursor rather than behind it. Resuming from the last seq you processed picks up everything since, which is what makes it the one list you can poll incrementally without re-reading what you already have.
Cursors are just ids — or, on the billing history, sequence numbers — 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.