Idempotency

A network timeout on a credit-spending POST leaves you with a real problem: the request may have succeeded, and you cannot tell. Retrying blind buys the work twice. Send an Idempotency-Key and the retry returns the original response instead — one job, one charge.

Both credit-spending endpoints accept it:

  • POST /api/v1/verify
  • POST /api/v1/verify/batch

The header is optional. Omit it and behaviour is exactly what it always was: every call is a new request.

On this page

Sending a key

Generate a fresh value per logical operation — a UUID v4 is ideal — and reuse it for every retry of that operation.

POST /api/v1/verify
X-Api-Key: ev_your_key
Idempotency-Key: 00000000-0000-4000-8000-000000000001
Content-Type: application/json

{ "email": "alice@example.com", "timeout": 15 }

Keys may be up to 255 characters. A longer one is rejected with 400 invalid_request and param: "Idempotency-Key".

What a retry returns

When the same key arrives again with the same request, the stored response is replayed byte-for-byte — same status code, same body — with one extra header:

Idempotency-Replayed: true

So a client that timed out, retried, and got a 200 cannot tell whether it did the work or inherited it, except by reading that header. Either way it was charged once.

Keys are scoped

Scope Meaning
Account The same key value used by a different account never collides
Mode Test and live are separate key spaces. The same key value on an ev_test_ key and on a live key is two unrelated operations — a sandbox call never replays a live response, and a live call never replays a sandbox one
Endpoint A key minted on POST /api/v1/verify is not the same key on POST /api/v1/verify/batch
24 hours Records expire 24h after the first use. After that the key is treated as never seen and can be used again

What counts as "the same request"

A replay is only correct if the retry really is the same call, so each endpoint records a fingerprint of the request alongside the key. Present the same key with a different request and you get 422 idempotency_key_reuse — the original resource is untouched, and nothing is charged.

POST /api/v1/verify fingerprints the whole request: email, timeout, callback_url. Change any of them and it is a different request.

POST /api/v1/verify/batch fingerprints the upload's contents:

  • the file name,
  • a SHA-256 of the uploaded file's bytes,
  • the email column being validated,
  • the callback_url.

Because the fingerprint is over the bytes themselves, two different files can never be mistaken for one another — not even two files that share a name and are exactly the same length. Present the same key with a different file and you get 422 idempotency_key_reuse, not somebody else's job.

The digest is taken over the file's bytes alone, never over how they were framed on the wire. A retry that re-sends the identical file with a fresh multipart boundary, or under Transfer-Encoding: chunked with no Content-Length, produces the same fingerprint and replays normally. Only the file name, its contents, the email column and the callback_url decide whether two calls are the same request.

Note that a replayed batch retry still uploads its bytes before the key is recognised: the file name only becomes known while streaming the multipart body. You spend the bandwidth, never a second charge.

Concurrent duplicates

If a second request with the same key arrives while the first is still in flight, it gets 409 idempotency_in_progress:

{
  "status": 409,
  "code": "idempotency_in_progress",
  "error": "A request with this Idempotency-Key is still in progress. Retry shortly."
}

Wait a moment and retry — once the first request finishes, the same key replays its response.

This advice is always actionable. You are never locked out for the full 24 hours, and what happens next depends on one thing only: whether that first request got as far as charging you. See below.

What happens when a request dies mid-flight

This is the case the header exists for, so it is worth being precise about. Your request failed, or your client disconnected, or the process handling it was replaced during a deploy — and you cannot tell how far it got. We can, because the charge and the record of it are written together, in one database transaction. There is no in-between state where your credits are gone and the key does not know it.

If nothing was charged, the claim is released and your very next retry does the work, from scratch, at full price and no more. Nothing was bought, so nothing needs protecting.

If the charge had already committed, the key is not released and the work is never re-run. Your retry gets 202 carrying the resource you already paid for — the same verification object every other endpoint returns — plus Idempotency-Replayed: true:

202 Accepted
Idempotency-Replayed: true

{
  "object": "verification",
  "id": "…",
  "created_at": "2026-07-29T10:03:11Z",
  "livemode": true,
  "status": "pending",
  "email": "alice@example.com",
  "result": null,
  "recovery": null,
  "completed_at": null,
  "credits_remaining": null,
  "result_url": "/api/v1/verify/…"
}

It reports the resource as it is now, not as it was when your request died. Unlike an ordinary replay there is no earlier response to hand back — the request that owed you one never wrote it — so a retry an hour later returns status: "complete" with a populated result, not a stale pending you would have to poll past. The status code stays 202 regardless: it says your original request was accepted, while status says how far it has got.

Retry that key as many times as you like: it never costs a second credit, and each answer is the current truth about the one resource. The batch endpoint behaves the same way and returns the same job object GET /api/v1/verify/batch/{id} does, including csv_url and json_url if the job has already finished.

One field does not come back: callback_secret. The per-request webhook secret is revealed only on the original 202 that accepts an asynchronous verification — no later read of the resource carries it, including GET /api/v1/verify/{id} and this recovery. If your original response was lost, verify that job's callbacks with your account signing secret (Member → API → Webhooks), which signs every delivery and is the mechanism we recommend anyway; or resubmit with a new key if you specifically need a fresh per-request secret. Everything else about the resource is returned.

Failed requests release the key

A key is only pinned to a response once the work succeeded and the charge committed. If the request fails before anything is charged — 402 insufficient_credits, a rejected callback_url, a job that could not start — the claim is released, so you may top up and retry with the same key. A failure never locks a key for 24 hours.

The counterpart is a response that is itself the authoritative answer. A POST /api/v1/verify that exceeds its timeout returns 202 with a result_url, and the credit has already been spent — so that 202 is stored and replayed. Follow result_url rather than resubmitting.

A response your connection never received is not stored. If you disconnect while we are waiting for the check — the very case this header is for — the 202 we were about to write is discarded instead of being pinned to the key, because you never got it and there is nothing to reproduce. Your retry then takes the recovery path above and reports the resource as it is at that moment, rather than replaying a pending snapshot of something that has since completed. The charge is still made exactly once.

One job, one charge, in every case. That is the guarantee; the paragraphs above are only its mechanics.

Error summary

Status code When
400 invalid_request The key is longer than 255 characters
409 idempotency_in_progress The first request with this key has not finished
422 idempotency_key_reuse This key was already used for a different request

See Errors for the full envelope.