Pagination

GET /v1/batches/{id}/results

Results paginate with a cursor. You ask for a page, you get rows and a next_cursor, and you keep passing it back until the cursor comes back null. There are no page numbers.

That choice has a reason. Offset pagination over a set that is still being written gives you duplicated and skipped rows, and on a running batch the set is very much still being written. A cursor points at a position in the data rather than a count from the start, so it survives rows landing behind you.

For a finished batch, the CSV download is simpler than any loop. Use the cursor when you want rows streaming into your database while the batch is still running.

Shell
curl "https://api.zapbounce.com/v1/batches/bat_8f2c1d/results?limit=1000&cursor=eyJvIjo0MDAwfQ" \
  -H "Authorization: Bearer zb_live_..."
JSON
{
  "data": [
    { "email": "ada@example.com", "result": "valid", "billed": true },
    { "email": "grace@example.org", "result": "unknown", "billed": false }
  ],
  "has_more": true,
  "next_cursor": "eyJvIjo1MDAwfQ"
}

Fields

FieldTypeWhat it carries
limitintegerRows per page, 1 to 1,000. Default 100.
cursorstringOpaque. Pass back what we gave you and do not try to decode it; the encoding will change.
has_morebooleanLoop on this rather than on an empty page.
filterstring (provisional)Restrict to one result, such as filter=unknown. Parameter name not final.

A field marked provisional could still be renamed before v1 is declared stable. Every other field is fixed.

Streaming rows into your database while the batch runs

Say a batch of 250,000 addresses is running and you want rows in your own database as they land. Ask for pages with limit=1000. At that size the full set takes about 250 requests, against 2,500 at the default of 100.

Write each page with an upsert keyed on the email address, so reading a page twice does no harm. After each page commits, save next_cursor. If your process dies, it resumes from the saved cursor and doesn't start over from the first row.

One behavior isn't documented yet: what has_more returns when you've caught up with a batch that is still writing rows. Until that's settled, pair the loop with the batch status call. Treat the download as finished only when the status reads complete and has_more is false. If you catch up early, wait a little before asking again.

Four ways a results loop goes wrong

The first is decoding the cursor. It looks like base64 because it is, and the contents will change without notice. Pass it back untouched.

Next comes depending on filter. That parameter is still provisional, so don't ship code that needs it. Read every row and keep the ones you want on your side, for example where result equals unknown. It costs a few more requests and it can't break under you.

A quieter problem is keeping cursors for later. They die with the batch when your retention window closes, so a job that resumes after a week may find nothing to resume.

The last one is skipping the final count. When the loop ends, compare the number of rows you stored with submitted and unique on the status call. A gap that repeats don't explain means a page went missing somewhere. It's far easier to find that today than after the batch has been deleted.

Questions developers ask

Why not page numbers?

Because rows are still being written while you read. Offsets shift under you, and you end up with duplicates and gaps that look like a data bug.

What is the fastest way to get everything?

For a finished batch, download the CSV. The cursor loop is for consuming results as they land.

Can I go backwards?

No. Keep the cursors you have used if you need to re-read a page, or start the loop again.

Try it against a sandbox key

Scripted verdicts, no SMTP connections, no credits. Live keys come with 100 free checks a month and no card.