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.email is an addition, never a substitution: email still holds exactly what you submitted.
  • recovery.outcome is deliverable when a probe confirmed a mailbox there, or catch-all when the fix landed on a domain that accepts everything. It uses the same words as result.outcome, so there is nothing new to learn — and it is the same value as the RecoveredOutcome column 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, recovery is null rather than a guess between them.
  • A recovery is billable work. A definitive outcome or a recovery is what you pay for — so an unknown result that carries a recovery is charged, and an unknown with no recovery is refunded. If you see a charge against a non-definitive result, recovery is 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_cursor is present only when has_more is true. Follow it until has_more is false; there is no total — 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 status or outcome is a 400, not a silently unfiltered list — a typo'd filter that quietly returned everything would read as a wrong answer. param names the one that was wrong.
  • The two filters have separate vocabularies. ?status=deliverable is a 400, 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" and result: null. A check submitted with a callback_url is findable here before it finishes — you do not have to have kept the id and you do not have to wait for it to complete.
  • credits_remaining is null on a list row. A balance is a per-request figure; read it from GET /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.