Pagination
How list endpoints page through results, what the response looks like, how to walk every page safely, and the endpoints that behave differently.
How it works#
Paginated list endpoints use page numbers. You ask for a page and a page size; the response returns that slice plus the totals you need to know when to stop.
pageintegerdefault1- Which page to return, starting at 1.
per_pageintegerdefault20- Items per page, from 1 to 100.
Both are query parameters and combine with each endpoint's own filters:
curl "https://api.poststack.dev/emails?page=2&per_page=50&status=bounced" \
-H "Authorization: Bearer $POSTSTACK_API_KEY"The response puts the items in data and the paging state in meta:
{
"data": [
{ "id": 48213, "publicId": "em_k3v9x2m8q1w7r4t6y0p5n2bz", "status": "bounced", "…": "…" },
{ "id": 48190, "publicId": "em_c8n1q4w0z7x2v5b9m3k6j1hd", "status": "bounced", "…": "…" }
],
"meta": {
"page": 2,
"perPage": 50,
"total": 137,
"totalPages": 3
}
}dataobject[]- This page's items, in the endpoint's order (see the table below).
meta.pageinteger- The page you got.
meta.perPageinteger- The page size used.
meta.totalinteger- Items matching your filters across all pages.
meta.totalPagesintegerceil(total / perPage)—0when nothing matches.
Walking every page#
Request page 1, then keep going until page reaches meta.totalPages. Use per_page=100 to make the fewest requests. None of the SDKs has an auto-paging iterator, so the loop is yours:
import { PostStack } from '@poststack.dev/sdk';
const poststack = new PostStack(process.env.POSTSTACK_API_KEY!);
const seen = new Set<string>();
for (let page = 1; ; page++) {
const { data, meta } = await poststack.emails.list({ page, per_page: 100, status: 'bounced' });
for (const email of data) {
if (seen.has(email.publicId)) continue; // a new row can shift an item onto the next page
seen.add(email.publicId);
// ...handle email
}
if (page >= meta.totalPages) break;
}page=1
while :; do
body=$(curl -sS "https://api.poststack.dev/emails?page=$page&per_page=100" \
-H "Authorization: Bearer $POSTSTACK_API_KEY")
echo "$body" | jq -c '.data[]'
[ "$page" -ge "$(echo "$body" | jq '.meta.totalPages')" ] && break
page=$((page + 1))
doneimport os
from poststack import PostStack
client = PostStack(api_key=os.environ["POSTSTACK_API_KEY"])
page = 1
while True:
res = client.emails.list({"page": page, "per_page": 100})
for email in res["data"]:
... # handle email
if page >= res["meta"]["totalPages"]:
break
page += 1for page := 1; ; page++ {
res, err := client.Emails.List(ctx, &poststack.ListEmailsParams{
Page: poststack.IntPtr(page),
PerPage: poststack.IntPtr(100),
})
if err != nil {
return err
}
for _, email := range res.Data {
_ = email // handle email
}
if page >= res.Meta.TotalPages {
break
}
}Pages are computed at request time with an offset, not held open for you. If an item is created or deleted while you are paging, everything after it shifts by one: on a newest-first list, a new email arriving between page 1 and page 2 pushes the last item of page 1 onto page 2, so you see it twice. De-duplicate on the ID as above. For GET /emails you can also fix the window with date_to set to the time you started, so new sends can't enter the result.
Limits and errors#
- Out-of-range values are rejected, not clamped.
per_page=500,per_page=0,page=0or a non-number answer400, naming the parameter. - A page past the end is not an error. It returns
200with an emptydataand the realmeta.total. cursordoes nothing. Some endpoints accept acursorparameter without complaint, but it is ignored — you get page 1 (or whateverpagesays). Don't build on it.- List reads aren't rate-limited per endpoint, but they are subject to the per-IP edge limit of 30 requests a second.
HTTP/1.1 400 Bad Request
{
"error": "per_page: Too big: expected number to be <=100"
}Paginated endpoints#
These return { data, meta } as described above, unless the table says otherwise.
| Endpoint | Parameters | Order | Notes |
|---|---|---|---|
GET /emails | page, per_page (20, max 100) | Newest first | Filters: status, domain_id, to, from, subject, tag, date_from, date_to, provider, country, device. Bodies and attachments are left out of list items. |
GET /contacts | page, per_page (20, max 100) | Newest first; oldest first when segment_id is set | Filters: search, segment_id, engagement_segment, unsubscribed. |
GET /segments/:id/members | page, per_page (20, max 100) | Oldest first | 404 if the segment doesn't exist. |
GET /broadcasts | page, per_page (20, max 100) | Newest first | Filters: search, status. |
GET /templates | page, per_page (20, max 100) | Most recently updated first | Filters: search, published. Adds counts: { published, draft } beside meta. |
GET /suppressions | page, per_page (20, max 100) | Not guaranteed | No sort order, so pages can overlap or skip entries. For a complete list use GET /suppressions/export (CSV). |
GET /inbound | page, per_page (20, max 100) | Newest first | Filter: domain (a domain name). An unknown domain returns an empty page, not an error. |
GET /subscription-topics/:id/subscribers | page, per_page (50, max 100) | Most recently changed first | subscribed=true|false picks subscribed or unsubscribed contacts; subscribed by default. |
GET /mailboxes | page, per_page (20, max 100) | Newest first | Filter: domain_id. Deleted mailboxes are left out. |
GET /mailboxes/aliases | page, per_page (20, max 100) | Newest first | — |
GET /signup-forms | page, per_page (20, max 100) | Newest first | — |
GET /webhooks/:id/deliveries | page, per_page (20, max 100) | Newest first | Returns { deliveries, pagination } instead of { data, meta }; pagination has the same four fields. |
GET /notification-channels/:id/deliveries | page, per_page (20, max 100) | Newest first | Returns { deliveries, meta }. |
GET /domains/:id/dmarc/reports | page, perPage (20, max 100) | Newest first | The size parameter is camelCase here and per_page is ignored. Values over 100 are clamped rather than rejected. Returns { reports, pagination }. |
Lists that aren't paginated#
These return every item in one response, under a key named after the resource. They take no page or per_page; any you send are ignored.
| Endpoint | Response | Order |
|---|---|---|
GET /domains | { domains: [...] } | Newest first |
GET /api-keys | { keys: [...] } | Oldest first |
GET /segments | { segments: [...] } | Oldest first |
GET /segments/:id/broadcasts | { broadcasts: [...] } | Newest first |
GET /webhooks | { webhooks: [...] } | Newest first |
GET /notification-channels | { channels: [...] } | Newest first |
GET /subscription-topics | { topics: [...] } | Newest first |
GET /subscription-topics/contacts/:contactId/subscriptions | { subscriptions: [...] } | Not guaranteed |
GET /workflows | { data: [...] } with no meta | Oldest first |
GET /contact-properties | { properties: [...] } | By name, A–Z |
GET /templates/presets | { data: [...] } | Fixed |
GET /emails/:id/events | { events: [...] } | Oldest first |
GET /inbound/:id/attachments | { attachments: [...] } | Not guaranteed |
GET /broadcasts/:id/variants | { variants: [...] } | Not guaranteed |
GET /mailboxes/:id/shares | { shares: [...] } | Not guaranteed |
GET /mailboxes/:id/filters | { rules: [...] } | Rule position |
Lists capped by a limit parameter
Three broadcast reports take limit instead of pages. There is no way to get past the maximum.
| Endpoint | limit | Response |
|---|---|---|
GET /broadcasts/performance | Default 20, max 100 | { metric, since, broadcasts: [...] } |
GET /broadcasts/:id/non-openers | Default 500, max 5,000 | { broadcast, count, contacts: [...] } |
GET /broadcasts/:id/non-clickers | Default 500, max 5,000 | { broadcast, count, contacts: [...] } |
Troubleshooting#
I get the same items on every page
Check that you are sending page, not only cursor — cursor is ignored, so every request returns page 1. On DMARC reports, the size parameter is perPage.
My export is missing a few suppressions
GET /suppressions has no defined order, so offset pages can overlap. Download GET /suppressions/export instead, or de-duplicate and re-check the total against meta.total.
An item appeared on two pages
Something was created while you were paging and shifted the offsets. De-duplicate on the ID, or pin the window with a date filter where the endpoint has one.