API pagination and filters
Lists use cursor pagination with limit, after and before, and accept filters in the query string.
#How it works
Endpoints that list resources (GET /emails, GET /domains, GET /logs…) return items from newest to oldest, one page at a time:
{
"object": "list",
"has_more": true,
"data": [
{
"object": "email",
"id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f",
"…": "…"
},
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"…": "…"
}
]
}
| Parameter | Description |
|---|---|
limit | Items per page, from 1 to 100. Default: 20. |
after | An item ID: returns the items older than it. Use the ID of the last item on the page to go to the next one. |
before | An item ID: returns the items newer than it. Use the ID of the first item to go back a page. |
has_more tells you whether there are more items in the direction you asked for. You cannot use after and before together (422). Because the cursor is an ID, items created while you paginate do not make the list skip or repeat items. The cursor must be the UUID of an item, and a limit outside 1 to 100 also answers 422.
#Walk through all the pages
To read a whole list, request pages of 100 and follow after until has_more is false:
cURL
# First page
curl "https://api.postal.so/emails?limit=100" \
-H "Authorization: Bearer $POSTAL_API_KEY"
# Next page: the ID of the last item received
curl "https://api.postal.so/emails?limit=100&after=019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b" \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
async function listAll(path) {
const items = [];
let after;
do {
const url = new URL(path, 'https://api.postal.so');
url.searchParams.set('limit', '100');
if (after) url.searchParams.set('after', after);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.POSTAL_API_KEY}` },
});
const page = await response.json();
items.push(...page.data);
after = page.has_more ? page.data.at(-1).id : undefined;
} while (after);
return items;
}
const bounces = await listAll('/emails?status=bounced');
Python
import os
import requests
def list_all(path, **filters):
items, after = [], None
while True:
params = {"limit": 100, **filters}
if after:
params["after"] = after
page = requests.get(
f"https://api.postal.so{path}",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params=params,
timeout=10,
).json()
items.extend(page["data"])
if not page["has_more"]:
return items
after = page["data"][-1]["id"]
bounces = list_all("/emails", status="bounced")
PHP
function listAll(string $path, array $filters = []): array
{
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$items = [];
$after = null;
do {
$page = json_decode($client->get($path, [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [...$filters, 'limit' => 100, ...($after ? ['after' => $after] : [])],
])->getBody(), true);
array_push($items, ...$page['data']);
$after = $page['has_more'] ? end($page['data'])['id'] : null;
} while ($after !== null);
return $items;
}
$bounces = listAll('emails', ['status' => 'bounced']);
For large volumes, prefer the CSV exports: they use the same filters and do not use up your request limit.
#Filters
Several lists accept filters in the query string, combined with pagination. Filters with multiple values accept a comma-separated list, such as status=bounced,complained.
| Endpoint | Filters |
|---|---|
GET /emails | search, status, domain_id, api_key_id, start_date, end_date |
GET /emails/receiving | inbox_id, unread, search |
GET /inboxes/{id}/threads | folder, label, unlabeled, assigned_to, unread, search |
GET /inboxes/{id}/drafts | thread_id |
GET /domains | search, status, region |
GET /api-keys | search, permission |
GET /templates | search, status |
GET /layouts | search, category |
GET /contacts | search, segment_id, topic_id, tag, unsubscribed, email_status, external_id (or user_id), source |
GET /segments | search, type (static, dynamic) |
GET /forms, GET /tags | search |
GET /forms/submissions, GET /forms/{id}/submissions | form_id (first one only), search, status, start_date, end_date |
GET /broadcasts | search, status |
GET /broadcasts/{id}/recipients | status, search |
GET /events | name, contact |
GET /automations | search, status |
GET /automations/{id}/runs | status, contact |
GET /email-verifications | preview |
GET /logs | status, method, source, user_agent, api_key_id, search, start_date, end_date |
#Date filters
Lists with start_date and end_date (emails, logs, form submissions and the exports that use those filters) keep what was created between the two dates, both inclusive. Each one accepts a date (2026-10-01) or an ISO 8601 date-time (2026-10-01T09:30:00-03:00); with no time zone, UTC applies. An end_date with only the date covers the whole day (up to 23:59:59 UTC), so start_date=2026-10-01&end_date=2026-10-01 returns all of the 1st. If start_date comes after end_date, or a date is invalid, the response is 422 validation_error.
The metrics and form analytics endpoints have their own period and a time zone (timezone or tz): in metrics, a date without a time means 00:00 in that time zone (to include the last day, send 2026-10-22T23:59:59), and the end never goes past now.