Sign in Create account
Concepts

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:

200Response
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "email",
      "id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f",
      "…": "…"
    },
    {
      "object": "email",
      "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
      "…": "…"
    }
  ]
}
ParameterDescription
limitItems per page, from 1 to 100. Default: 20.
afterAn item ID: returns the items older than it. Use the ID of the last item on the page to go to the next one.
beforeAn 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:

Pagination

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.

EndpointFilters
GET /emailssearch, status, domain_id, api_key_id, start_date, end_date
GET /emails/receivinginbox_id, unread, search
GET /inboxes/{id}/threadsfolder, label, unlabeled, assigned_to, unread, search
GET /inboxes/{id}/draftsthread_id
GET /domainssearch, status, region
GET /api-keyssearch, permission
GET /templatessearch, status
GET /layoutssearch, category
GET /contactssearch, segment_id, topic_id, tag, unsubscribed, email_status, external_id (or user_id), source
GET /segmentssearch, type (static, dynamic)
GET /forms, GET /tagssearch
GET /forms/submissions, GET /forms/{id}/submissionsform_id (first one only), search, status, start_date, end_date
GET /broadcastssearch, status
GET /broadcasts/{id}/recipientsstatus, search
GET /eventsname, contact
GET /automationssearch, status
GET /automations/{id}/runsstatus, contact
GET /email-verificationspreview
GET /logsstatus, 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.