Job status and results

GET /v1/jobs/{id}

GET /v1/jobs/{id} answers two questions: how far along is this job, and what did it find. While it runs you read the status and the counts so far. Once it finishes you have the full breakdown, with unknown reported on its own line and never folded into anything else.

For the rows behind the counts, call GET /v1/jobs/{id}/emails. It returns one object per address and takes a status filter, so you can pull only the unknowns or only the invalid addresses. For everything at once, GET /v1/jobs/{id}/export streams a CSV with email, status, reason_code, mx, smtp_code and checked_at columns.

A job you no longer need can be cancelled while it runs, archived to hide it from the default list, or deleted for good. We do not push a notification when a job finishes, so poll the status at a sensible interval. Every ten seconds is plenty.

Shell
# Status and totals
curl https://app.zapbounce.com/api/v1/jobs/0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10 \
  -H "X-API-Key: $ZB_API_KEY"

# Only the unknowns, 100 a page
curl "https://app.zapbounce.com/api/v1/jobs/0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10/emails?status=unknown&pageSize=100" \
  -H "X-API-Key: $ZB_API_KEY"

# Everything as CSV
curl https://app.zapbounce.com/api/v1/jobs/0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10/export \
  -H "X-API-Key: $ZB_API_KEY" -o results.csv
JSON
{
  "id": "0b6f2d3e-6a52-4c58-9d0e-3f6a1c9a7e10",
  "source": "api",
  "submittedEmails": 12000,
  "totalEmails": 11480,
  "validEmails": 7380,
  "invalidEmails": 1430,
  "catchAllEmails": 2410,
  "otherEmails": 260,
  "spamEmails": 90,
  "status": "completed",
  "error": null,
  "startedAt": "2026-10-26T10:04:03.000Z",
  "completedAt": "2026-10-26T10:19:12.000Z",
  "createdAt": "2026-10-26T10:04:00.000Z"
}

Fields

FieldTypeWhat it carries
statusstringpending, running, completed, partial, failed or cancelled.
validEmails, invalidEmails, catchAllEmails, otherEmailsintegerOne count per result. otherEmails is the unknown count. Together they add up to totalEmails when the job completes.
submittedEmails, totalEmailsintegerWhat you sent, and what remained after cleaning. The difference is duplicates and rows that were not addresses.
errorstring or nullWhy a job failed, when it did.
status (query)stringOn /emails: pending, needs_retry, valid, invalid, catch_all or unknown.
q (query)stringOn /emails: search by address or reason code, up to 200 characters.

Reference

RouteWhat it doesScope
GET /jobs/{id}Status and totals.jobs.view
GET /jobs/{id}/emailsPer-address results, filterable and paginated.jobs.view
GET /jobs/{id}/exportAll results as a CSV download.jobs.view
POST /jobs/{id}/cancelStop a job that is still running.jobs.manage
POST /jobs/{id}/archiveHide the job from the default list.jobs.manage
DELETE /jobs/{id}Delete the job. Returns 204 with no body.jobs.manage

Reading a 20,000-address summary before you send

Say a finished job reports totalEmails: 20000 with this summary: 12,500 valid, 2,300 invalid, 4,600 catch-all and 600 unknown in otherEmails. Those four add up to 20,000. The 600 unknowns are refunded, so the job spent 19,400 credits.

Start with invalid. At 2,300 of 20,000 it's 11.5%, so mailing this list unchecked would have put you well past the point where mailbox providers start throttling. Next, look at catch-all. At 23% of the list, it sets the size of the separate segment you'll want to send and measure alone.

The share of mailboxes you can prove is the number to carry into your send plan: 12,500 of 20,000, or 62.5%. Catch-all domains gave a clear answer about the domain, and still no SMTP check can prove the mailbox behind them exists.

A polling loop that knows when to stop

Results are polled, so your loop needs four exits and most first drafts have one. Stop on completed, but also stop on partial, cancelled and failed. A failed job has been refunded, so read error, fix what it names and resubmit. Add a deadline of your own as the last exit, and have it page a person when it fires.

Mind the budget while you poll. The default allowance is 120 requests per 60 seconds per key, so a few dozen jobs polled every ten seconds will start drawing 429s. Poll a shared list of open jobs from one place and you won't have every worker running a loop of its own.

Watch the verdict counts between polls. A count that climbs slowly near the end is normal, because the slowest mail hosts finish last. Read the counts as final only after the status leaves running, and use /emails if you need rows sooner.

Fetch results when you need them. Store the job id in your database and call /export or /emails when you're ready to use the rows.

Questions developers ask

Is there a webhook when a job finishes?

Not for customers today. Poll GET /v1/jobs/{id} until status is completed, partial, failed or cancelled.

Why do the counts change while a job runs?

Because rows are still being written. The totals only settle once the status leaves running.

What is the fastest way to get all the rows?

The CSV export. Use the /emails route when you want to filter, or to read results while the job is still running.

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.