Verify a single address
POST /api/v1/verify submits one email address for validation.
On this page
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email |
string | yes | Address to validate |
timeout |
integer | no | Seconds to wait for a result (3–60; default 30) |
callback_url |
string | no | HTTPS URL to receive the result as a webhook |
Optional headers:
| Header | Description |
|---|---|
Idempotency-Key |
Makes a retry safe — the second call replays the first response instead of charging again. See Idempotency |
This endpoint requires the verify scope. See
Authentication.
Synchronous mode (no callback_url)
The request blocks until validation completes or the timeout elapses.
POST /api/v1/verify
X-Api-Key: ev_your_key
Content-Type: application/json
{ "email": "alice@example.com", "timeout": 15 }
Completes within timeout — 200 OK:
{
"object": "verification",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2025-06-10T14:02:09.180Z",
"livemode": true,
"status": "complete",
"email": "alice@example.com",
"result": {
"outcome": "deliverable",
"sub_status": "",
"risk": "none",
"free_email": false,
"account": "alice",
"domain": "example.com",
"details": {
"role": false,
"disposable": false,
"catch_all": false,
"mx_found": true,
"mx_record": "aspmx.l.google.com",
"smtp_provider": "Google",
"smtp_code": 250
}
},
"recovery": null,
"completed_at": "2025-06-10T14:02:11.432Z",
"credits_remaining": 4819
}
Exceeds timeout — 202 Accepted:
{
"object": "verification",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2025-06-10T14:02:09.180Z",
"livemode": true,
"status": "pending",
"email": "alice@example.com",
"result": null,
"recovery": null,
"completed_at": null,
"credits_remaining": null,
"result_url": "/api/v1/verify/3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
The credit is already spent at this point, so this 202 is the authoritative answer — follow
result_url rather than resubmitting the address.
One object, two axes
Every response above is the same object. There is no separate "pending shape" and no separate
"list row shape": the synchronous 200, both kinds of 202, GET /api/v1/verify/{id} in either
state, every row of GET /api/v1/verify, and the verify.completed webhook payload all deserialise
into one type.
| Field | Axis | Values |
|---|---|---|
status |
Lifecycle — how far the check has got | pending, complete |
result.outcome |
Verdict — what we concluded | deliverable, invalid, catch-all, unknown |
livemode |
Mode — which namespace it belongs to | true (live key), false (ev_test_ sandbox key) |
livemode never changes for a given key, which is what makes it useful after the fact: a response
you stored or logged says for itself whether it came from the sandbox.
On the rare row whose stored probe detail exists but cannot be read — a fault on our side —
result.details and result.free_email are omitted rather than reported as falses. The
verdict in result.outcome comes from a separate, intact field and is still trustworthy; treat a
missing details as "not available", never as an all-clear. A row that simply never had probe
detail is a different thing and still returns details with its default values.
result and recovery are null while status is pending, because a check that has not
finished has reached no conclusion. Switch on status to know whether to wait; switch on
result.outcome to know what to do with the address. One field never answers the other's question.
Recovered addresses
When the address you sent turns out to be dead, we sometimes find a live alternative — and when we
do, we probe it before telling you about it. That is what recovery carries:
{
"object": "verification",
"id": "1c9d2f80-4f3a-4c19-9a51-52a1a4e0c001",
"created_at": "2025-06-10T14:07:44.010Z",
"livemode": true,
"status": "complete",
"email": "alice@gmial.com",
"result": {
"outcome": "invalid",
"sub_status": "no_dns_entries",
"risk": "high",
"free_email": false,
"account": "alice",
"domain": "gmial.com",
"details": { "...": "..." }
},
"recovery": {
"email": "alice@gmail.com",
"outcome": "deliverable"
},
"completed_at": "2025-06-10T14:07:48.220Z",
"credits_remaining": 4818
}
recovery.emailis an addition, never a substitution:emailstill holds exactly what you submitted.recovery.outcomeisdeliverablewhen a probe confirmed a mailbox there, orcatch-allwhen the fix landed on a domain that accepts everything. It uses the same words asresult.outcome, so there is nothing new to learn — and it is the same value as theRecoveredOutcomecolumn in a batch CSV for the same row.- Nothing ambiguous is ever surfaced. If several different addresses came back deliverable, or
several different domains came back catch-all,
recoveryisnullrather than a guess between them. - A recovery is billable work. A definitive outcome or a recovery is what you pay for — so
an
unknownresult that carries arecoveryis charged, and anunknownwith no recovery is refunded. If you see a charge against a non-definitive result,recoveryis the reason, and it is in the same response.
Polling for results
When you receive a 202 with a result_url, poll GET /api/v1/verify/{id} until the
result is ready.
GET /api/v1/verify/3fa85f64-5717-4562-b3fc-2c963f66afa6
X-Api-Key: ev_your_key
Still processing — 200 OK:
{
"object": "verification",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2025-06-10T14:02:09.180Z",
"livemode": true,
"status": "pending",
"email": "alice@example.com",
"result": null,
"recovery": null,
"completed_at": null,
"credits_remaining": null,
"result_url": "/api/v1/verify/3fa85f64-5717-4562-b3fc-2c963f66afa6"
}
Completed — 200 OK: the same object with status: "complete" and a populated result — see
Synchronous mode for the full body.
Asynchronous mode (with callback_url)
The request returns immediately with 202 Accepted. When validation completes, the full
result is POSTed to your URL as a signed webhook.
POST /api/v1/verify
X-Api-Key: ev_your_key
Content-Type: application/json
{
"email": "alice@example.com",
"callback_url": "https://yourapp.example.com/hooks/email"
}
202 Accepted:
{
"object": "verification",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2025-06-10T14:02:09.180Z",
"livemode": true,
"status": "pending",
"email": "alice@example.com",
"result": null,
"recovery": null,
"completed_at": null,
"credits_remaining": null,
"result_url": "/api/v1/verify/3fa85f64-5717-4562-b3fc-2c963f66afa6",
"callback_secret": "cs_9f2a4c6e..."
}
callback_secret is a per-job override of your account signing secret, and this 202 is the
only place it appears — it is not on GET /api/v1/verify/{id}, not on list rows and not in the
webhook payload, because a secret you can re-fetch at will is not a secret. You do not have to keep
it: verifying with the account secret (whsec_…, from the dashboard) works for every delivery and
is the documented default. See Webhooks.
Listing your results
Lost an id? GET /api/v1/verify lists the checks submitted with this key's account, newest
first — the only way to recover a result whose id you no longer hold, since every other read is
by id.
GET /api/v1/verify?limit=25
X-Api-Key: ev_your_key
200 OK:
{
"object": "list",
"data": [
{
"object": "verification",
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"created_at": "2025-06-10T14:02:09.180Z",
"livemode": true,
"status": "complete",
"email": "alice@example.com",
"result": {
"outcome": "deliverable",
"sub_status": "",
"risk": "none",
"free_email": false,
"account": "alice",
"domain": "example.com",
"details": { "...": "..." }
},
"recovery": null,
"completed_at": "2025-06-10T14:02:11.432Z",
"credits_remaining": null
},
{
"object": "verification",
"id": "8c1d0f22-9a44-4e31-b0aa-6f2e5c7d1234",
"created_at": "2025-06-10T14:02:10.006Z",
"livemode": true,
"status": "pending",
"email": "bob@example.com",
"result": null,
"recovery": null,
"completed_at": null,
"credits_remaining": null,
"result_url": "/api/v1/verify/8c1d0f22-9a44-4e31-b0aa-6f2e5c7d1234"
}
],
"has_more": true,
"next_cursor": "MTc0OTU2NzIwMDAwMDAwMDAwfDNmYTg1..."
}
| Parameter | Description |
|---|---|
limit |
Rows per page. Clamped to 1–100; default 25 |
starting_after |
A next_cursor from a previous response. Walks towards older results |
ending_before |
A next_cursor from a previous response. Walks back towards newer results. Ignored when starting_after is also supplied |
status |
Return only this lifecycle state: pending, complete. Matched case-insensitively |
outcome |
Return only this verdict: deliverable, invalid, catch-all, unknown. Matched case-insensitively |
The list is scoped to your key's mode as well as your account: a sandbox (ev_test_) key
sees only sandbox verifications and a live key only live ones. See
Authentication.
Notes that will save you time:
next_cursoris present only whenhas_moreistrue. Follow it untilhas_moreisfalse; there is nototal— a per-page count would be stale on arrival, and walking the cursor tells you the real answer.- A malformed cursor is a
400, naming the parameter it arrived in — see Cursors that no longer decode. Omitting the parameter is still page one. - An unrecognised
statusoroutcomeis a400, not a silently unfiltered list — a typo'd filter that quietly returned everything would read as a wrong answer.paramnames the one that was wrong. - The two filters have separate vocabularies.
?status=deliverableis a400, and so is?outcome=pending— neither parser was widened to absorb the other's words, because that is exactly the ambiguity the two-axis shape removes. Combine them instead:?status=complete&outcome=invalid. - In-flight checks ARE listed, with
status: "pending"andresult: null. A check submitted with acallback_urlis findable here before it finishes — you do not have to have kept theidand you do not have to wait for it to complete. credits_remainingisnullon a list row. A balance is a per-request figure; read it fromGET /api/v1/credits.- Only work submitted through the API is returned. Validations run from the web dashboard belong to the dashboard, not to this key.
This endpoint requires the read scope.
Cursors that no longer decode
A cursor you send back can only have come from a next_cursor we issued. If it arrives damaged —
truncated by a URL-length limit, mangled by a proxy, or clipped in a copy/paste — the API tells you
so instead of quietly starting again:
{
"status": 400,
"code": "invalid_request",
"error": "Malformed cursor in `starting_after`. Send back a `next_cursor` exactly as it was returned, or omit the parameter to start from the first page.",
"param": "starting_after"
}
param names the parameter the unusable value arrived in — starting_after or ending_before —
and an unusable value is refused even when the other one would have taken precedence.
This is deliberate, and it is the one place a "helpful" fallback would be dangerous. Silently
treating a damaged cursor as "no cursor" returns page one, with has_more: true and a fresh
next_cursor — so the loop everyone writes,
while (has_more) { page = fetch(next_cursor); process(page); }
never terminates. It reprocesses page one forever, burning your rate-limit budget, with a 200 and
a perfectly-shaped body at every step and nothing anywhere reporting a problem. A 400 ends the
walk in one step and tells you why.
Two things this does not apply to:
- Omitting the parameter, or sending it empty, is still the first page. That is a request, not a damaged cursor.
- A cursor that decodes but points past the last row is a legitimate position and still returns
an empty page with
has_more: false. Reaching the end of a list is not an error.
The same rule applies to GET /api/v1/verify/batch. The portal's own feeds keep the old fail-soft
behaviour, because they thread a cursor out of a browser URL a person can edit.
Retention
Single-check results are removed 7 days after submission. After that a check is
indistinguishable from one that never existed: it disappears from the list and
GET /api/v1/verify/{id} answers 404 not_found rather than a separate "expired" error. There is
no expires_at field on a single-check row — the window is a flat seven days from the moment the
check was submitted, not from when its result arrived.
If you need results for longer, persist them on receipt (the webhook payload carries the full result object) or download them while they are still listed.
Status codes
| Code | Meaning |
|---|---|
200 |
Validation completed synchronously |
202 |
Accepted — async callback mode or sync timeout exceeded |
400 |
Invalid email format (invalid_email), bad callback_url (invalid_callback_url), or an over-long Idempotency-Key (invalid_request) |
401 |
Missing or invalid API key |
402 |
Insufficient credits |
403 |
The key is not scoped for this endpoint (insufficient_scope) |
404 |
On GET /api/v1/verify/{id} — not found, expired, belongs to another account, or belongs to the other mode (a sandbox key cannot fetch a live verification, or vice versa) |
409 |
An identical Idempotency-Key request is still in flight (idempotency_in_progress) |
413 |
Request body exceeds the maximum allowed size |
422 |
The Idempotency-Key was reused for a different request (idempotency_key_reuse) |
429 |
Rate limit exceeded |
500 |
Unexpected server error (internal_error) |
Every one of these carries the same envelope with a machine-readable code. See
Errors.