The verification API
One POST for a single address. Another POST and a webhook for a list. Everything else is billing and pagination.
curl -s -X POST "https://api.zapbounce.com/v1/verify" \
-H "Authorization: Bearer $ZAPBOUNCE_KEY" \
-H "Content-Type: application/json" \
-d '{"email":"ada@example.com"}'
{
"email": "ada@example.com",
"result": "catch_all",
"reason": "accept_all_domain",
"domain": "example.com",
"mx_found": true,
"mx_host": "mx1.example.com",
"smtp_code": "250",
"role": false,
"disposable": false,
"free_provider": false,
"did_you_mean": null,
"billed": true,
"checked_at": "2026-09-18T10:02:41Z"
}Verification is POST only, and the address travels in a JSON body. We don't offer a GET with the address in the URL, because a query string ends up in access logs, proxy logs and browser history, and those are places your contacts' addresses shouldn't sit.
What comes back
Branch on result. The reason underneath it is a fixed list that can grow, so treat one you don't recognize as extra detail.
| Field | What it holds |
|---|---|
| The address as you sent it, normalized. | |
| result | One of valid, invalid, catch_all, unknown. It answers one question: does this mailbox exist? |
| reason | The narrower cause, from a closed list: mailbox_not_found, accept_all_domain, greylisted and so on. |
| domain, mx_found, mx_host | The domain we checked, whether it has a mail server, and the host we spoke to. |
| smtp_code | The literal reply to RCPT TO, so you can audit our reading. null if the handshake never got that far. |
| role, disposable, free_provider | Booleans about the address itself. They are flags, never results, and they are set even when the result is unknown. |
| did_you_mean | A typo suggestion or null. We never apply it for you. |
| billed | true for valid, invalid and catch_all. false for unknown and for a duplicate inside a batch. |
| checked_at | When we ran the check, ISO 8601 in UTC. |
A list, and a webhook when it finishes
curl -s -X POST "https://api.zapbounce.com/v1/batches" \
-H "Authorization: Bearer $ZAPBOUNCE_KEY" \
-H "Content-Type: application/json" \
-d '{"emails":["ada@example.com","grace@example.org"],
"webhook_url":"https://yourapp.com/hooks/zapbounce"}'
{ "batch_id": "bat_8f2c1d", "status": "queued", "submitted": 2, "unique": 2 }The limits you'll hit first
| Surface | Default | What to do about it |
|---|---|---|
| Single checks | 100 requests a second per key | Read X-RateLimit-Remaining and slow down before it reaches zero. A 429 carries Retry-After in seconds. |
| Batch submissions | 10 a minute, up to 100,000 addresses each | Send one large batch instead of many small ones. Past 100,000 you get a 413, so upload a file. |
| File uploads | 5 a minute | CSV or TXT. Gzip anything over about 50 MB. |
| Results pages | 1 to 1,000 rows, default 100 | Loop on has_more and pass next_cursor back. There are no page numbers. |
| Idempotency window | 24 hours | Reuse one Idempotency-Key per unit of work, so a lost reply can't create a second batch. |
Two errors need different handling from the rest. A 429 means wait for Retry-After and carry on, with jitter if you run more than one worker. A 402 means the balance is too low, and retrying won't help until someone tops up. Neither costs a credit. No error does, because an error means the address never reached the verification queue.
At a signup form, budget three seconds for the common case and set your client timeout at ten. A greylisting server can hold the connection that long, and when the timeout fires your code should let the signup through.
Test every branch before you spend a credit
A key that starts zb_test_ runs the same API in sandbox mode. Nothing connects to a mail server, nothing is billed, and the response has the same shape, so your parsing code gets a real workout in CI.
Reserved addresses at sandbox.zapbounce.com force each outcome. unknown@ returns the result most pipelines never handle, because in production it only appears when a mail host refuses to answer. slow@ replies after about nine seconds, which tells you whether your timeout works. ratelimit@ returns a 429 so you can watch your backoff do its job.
Read next
Authentication
Bearer keys, rotation, and why the key belongs on your server rather than in the browser.
Result codes
Every verdict and reason, and what each one means for what you do next.
Rate limits
Per-plan limits, and what a 429 tells you to do.
Webhooks
The callback payload and how to verify it really came from us.
SDKs
Python, Node, PHP, Ruby, Go, C# and more, with real code per language.
At a signup form
Which verdicts should block a signup, and which should never.
Before you build against this
Questions
Am I charged for an unknown result?
No. Unknown means we could not finish the work, and billing for it would make the honest verdict the expensive one.
Is a catch-all result billed?
Yes. One credit buys one definitive result, and valid, invalid and catch_all all count. A catch-all took a full handshake plus a second probe, and it tells you something true about the domain. Unknown results, duplicates inside a batch, errors and sandbox calls cost nothing.
Is there a sandbox?
Yes. Sandbox addresses return each verdict deterministically, so you can test the unknown and catch-all branches without hunting for a real example.
What happens on a timeout?
Fail open in your code. A verification service should never be the reason a signup fails.
Can I re-check an address?
Yes, and it costs a credit, because the answer genuinely can change between checks.
Get a key
100 free checks a month, no card.