POST /v1/verify takes between one and twenty addresses and returns a verdict for each on the same connection. It is the call for a signup form or a small batch. Anything larger belongs in a job, which is the next page.
Each address goes through the whole pipeline: syntax, a lookup of the domain's mail servers, an SMTP conversation with the receiving server, and a test for catch-all domains. The connection closes before the DATA command, so no message is ever sent to the address you check.
Results come back in a results array alongside the name of the node that did the work. A verdict we already hold for a recent check can be served from cache, in which case the node is cache and checkedAt keeps the time of the original check. Set persist to false when you only want the answer back and do not want a row kept in your account history.
curl -X POST https://app.zapbounce.com/api/v1/verify \
-H "X-API-Key: $ZB_API_KEY" \
-H "Content-Type: application/json" \
-d '{"emails":["ada@example.com","ops@example.org"]}'{
"node": "verify-1",
"results": [
{
"id": "5821",
"email": "ada@example.com",
"status": "valid",
"reasonCode": "MAILBOX_EXISTS",
"provider": "other",
"flags": [],
"smtpCode": 250,
"checkedAt": "2026-10-26T10:02:41.000Z"
},
{
"id": "5822",
"email": "ops@example.org",
"status": "catch_all",
"reasonCode": "CATCH_ALL",
"provider": "other",
"flags": ["role"],
"smtpCode": 250,
"checkedAt": "2026-10-26T10:02:42.000Z"
}
]
}Fields
| Field | Type | What it carries |
|---|---|---|
| emails | array | Required. One to twenty addresses, each up to 320 characters. |
| persist | boolean | Optional, default true. When false the results are returned but not stored, and id comes back empty. |
| status | string | One of valid, invalid, catch_all, unknown. It answers one question: does this mailbox exist? |
| reasonCode | string | The narrower cause behind the status, from the list on the result codes page. |
| provider | string | The mailbox provider we detected from the domain's mail servers, such as google or microsoft. |
| flags | array | Signals about the address itself: disposable, role, free_provider. They sit beside the status and never replace it. |
| smtpCode | integer or null | The reply code the receiving server gave to RCPT TO, so you can audit our reading of it. Null when the conversation never got that far. |
| checkedAt | string | When the check ran, ISO 8601 in UTC. |
A checkout field, handled verdict by verdict
Say you run a store and check the email field when the shopper tabs out of it. Your server calls POST /v1/verify with {"emails":["ada@example.com"]} and branches on results[0].status. A valid verdict needs no action. An invalid one shows a short inline message asking the shopper to look again.
Suppose the shopper typed ada@gmial.com. A mistyped domain usually comes back invalid with NO_MAIL_SERVER or MAILBOX_NOT_FOUND. Ask the shopper to look at the address again and leave the rewrite to them, because the person might own the odd-looking domain.
Accept the order on catch_all, and when flags includes role. They're caveats for your marketing sends, and neither is a reason to refuse a sale. Accept it on unknown and on a timeout too, since the order confirmation will tell you soon enough whether the mailbox works. Then store status, smtpCode and checkedAt next to the customer record. Anyone reading it in six months can see how old the verdict is.
Mistakes that show up in the first week
The most expensive one is treating unknown as invalid. Gmail and Microsoft 365 often refuse probes, so that rule turns away people on the two largest mailbox providers. Write the unknown branch on day one and make it accept.
A close second is branching on reasonCode alone. The list of reason codes can grow without a version bump. Key your logic on status, and keep reasonCode for your logs and for the finer rules that can live with a default branch.
Watch your timeout as well. Ten seconds is right for a background job and far too long for a person staring at a form. A signup flow wants a shorter budget of its own, and a pass when it runs out.
Last, think about repeats. The duplicate rule covers the same address appearing twice inside one request. It says nothing about calling /v1/verify for the same address every morning. Cache the verdict with its checkedAt and pick your own refresh age. Unknown results are refunded, so the credits ledger shows what a day of calls cost.
Questions developers ask
Does checking an address send it an email?
No. We open the connection, get an answer to RCPT TO and hang up before DATA. There is no message to deliver.
Why did a real address come back unknown?
The receiving server would not answer clearly. Greylisting, throttling and blocks on probe traffic all produce that. We report it instead of guessing, and the credit comes back.
Why is the body an array when I check one address?
One shape for every size. Send an array of one for a single check, and the same code handles twenty.
Can I verify at signup without slowing the form?
Call it when the field loses focus rather than on submit, and treat a timeout as a pass. Blocking a signup on a verifier that is having a slow second costs more than it saves.