---
title: "Retry Email Validation Without Spending Quota Twice"
description: "Use Idempotency-Key for retry-safe email validation jobs: replay the exact body, distinguish two 409 errors, and avoid treating cached DNS as duplicate protection."
slug: "retry-email-validation-with-idempotency-key"
date: 2026-09-30
updated: 2026-09-30
last_tested: 2026-09-30
summary: "Background validation retries need a stable operation key and an unchanged request body; completed replays consume no extra quota, while fresh checks require fresh keys."
cluster: "Signup flows"
intent: how-to
sources:
  - title: "nobounce.dev agent reference — retry accounting"
    url: "https://nobounce.dev/llms.txt"
  - title: "nobounce.dev OpenAPI specification"
    url: "https://nobounce.dev/openapi.json"
  - title: "nobounce.dev verdict taxonomy and limits"
    url: "https://nobounce.dev/v1/config"
  - title: "Node.js crypto — randomUUID"
    url: "https://nodejs.org/api/crypto.html#cryptorandomuuidoptions"
  - title: "RFC 9457 — Problem Details for HTTP APIs"
    url: "https://www.rfc-editor.org/rfc/rfc9457.html"
---

A background worker sends an email-validation batch, then loses the connection before it receives the response. Did the server finish? Retrying without an operation identifier cannot answer that question. It creates another request, potentially spending the same checks again.

For nobounce, send an `Idempotency-Key` on the first attempt and reuse it with the same endpoint and exact body on retries. A completed replay consumes no extra quota. This is a recovery mechanism for imports and background jobs, not an invitation to keep a signup form waiting through repeated attempts.

## Create the operation before the network call

Both `POST /v1/check` and `POST /v1/check/batch` accept `Idempotency-Key`. The value must contain 1–128 printable ASCII characters. A random UUID is a convenient choice; do not derive it from an address or include personal data in it.

Build an immutable operation record containing:

- the endpoint;
- the serialized JSON body;
- the generated idempotency key;
- your job status and attempt count.

Keep that record in your application's protected job system before sending. Generating the UUID inside each HTTP attempt defeats the mechanism: every attempt then looks new. Likewise, serializing a mutable list again on retry risks changing the operation you meant to replay.

Here is the request preparation in server-side Node.js:

```js
import { randomUUID } from "node:crypto";

export function prepareBatch(emails) {
  return {
    url: "https://nobounce.dev/v1/check/batch",
    key: randomUUID(),
    body: JSON.stringify({ emails }),
  };
}

export async function sendAttempt(operation) {
  const response = await fetch(operation.url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.NOBOUNCE_KEY}`,
      "Content-Type": "application/json",
      "Idempotency-Key": operation.key,
    },
    body: operation.body,
    signal: AbortSignal.timeout(30000),
  });
  return {
    status: response.status,
    retryAfter: response.headers.get("Retry-After"),
    body: await response.json(),
  };
}
```

The 30-second timeout is a background-job choice, not an API requirement. Save `prepareBatch`'s result once, then pass that saved operation to each attempt. A thrown transport or JSON-decoding error means you did not obtain a usable response; it does not prove the server did no work.

Live validation requires a paid key. Hobby is $1/mo for 1,000 checks/mo, pro $19/mo for 100,000, and scale $99/mo for 1,500,000. Obtain a key by redeeming an operator-minted access code or paying self-serve through Stripe cards or usevig stablecoins. Keep it server-side in the `Authorization` header, never a URL or query parameter.

## Reproduce a completed replay with curl

Set `CHECK_ID` once to a randomly generated UUID and keep it unchanged between commands:

```bash
CHECK_ID=$(node -p 'require("node:crypto").randomUUID()')
BODY='{"email":"user@gmail.com"}'

curl -i https://nobounce.dev/v1/check \
  -H "Authorization: Bearer $NOBOUNCE_KEY" \
  -H "Idempotency-Key: $CHECK_ID" \
  -H 'Content-Type: application/json' \
  --data-binary "$BODY"
```

Run the same curl command again without reassigning either variable. Once the first operation completed, the second call is a replay, not another metered check. Check usage with:

```bash
curl https://nobounce.dev/v1/me \
  -H "Authorization: Bearer $NOBOUNCE_KEY"
```

Do this in a quiet account if you want to compare counts; concurrent jobs can change the total independently. The keyless `/demo/check` endpoint is fixture-only and cannot validate arbitrary addresses. It is not a substitute for testing authenticated retry accounting.

## Two 409 responses require different actions

Read the RFC 9457 problem body's `error` and `fix`, not just the HTTP status.

| Response | Meaning | Worker action |
|---|---|---|
| `200` | A verdict or completed replay arrived | Process the result and finish the operation |
| `409 request_in_progress` | The original request is active | Honour `Retry-After`; defer the same operation |
| `409 idempotency_key_reused` | The key conflicts with the body or endpoint | Stop and inspect your operation record |
| `503` | Work failed | Retry the same body and key with bounded backoff |
| `401` | Authentication failed | Repair credentials, not the address |
| `429` | Quota is exhausted | Inspect usage and pause the job |

The active-request lease is 120 seconds; active batches renew it. An interrupted request keeps its original quota reservation until retry. Do not spin on `request_in_progress`, and do not evade it by generating another key. Schedule a later attempt using the server's guidance.

A conflict is a client bug to investigate. Changing from `/v1/check` to `/v1/check/batch`, reordering batch addresses, or editing the JSON body must not reuse the old key. A deliberately changed request is a new operation.

## Replay is not revalidation

A completed `unknown` verdict consumes quota. Replaying that operation retrieves its completed result; it is not a request to try DNS again. If a later cleanup pass needs a fresh check after `dns_error`, create a new operation with a new key and budget another check.

The same distinction applies to `cached`. A warm domain cache avoids DNS work, but cache hits still count against monthly quota. Idempotency protects an operation from duplicate accounting; domain caching avoids repeated resolution. They solve different problems.

For batches, the cap remains 1,000 addresses. Freeze each chunk separately and assign one key per chunk. Keep the result's `email_sha256` association when joining back to your input; the chunking and correlation workflow is in [How to Batch-Validate a List of Email Addresses](https://nobounce.dev/blog/batch-validate-email-list/).

## Keep retries off the interactive path

On a signup submit, a short deadline should lead to an unchecked annotation and permission to continue, not a 120-second lease wait. [How to Handle an Email Validation Timeout at Signup](https://nobounce.dev/blog/handle-email-validation-timeout/) covers that policy. Recover background work separately.

When a verdict does arrive, idempotency changes nothing about its interpretation. Offer any non-null suggestion, including `risky` with `likely_typo`, before considering rejection. Accept `unknown` with `checked.mx: false` as an honest degraded result. The correction UI is described in [Handle Email Validation Verdicts in a React Signup Form](https://nobounce.dev/blog/email-validation-verdicts-in-react-form/).

Do not log request bodies or suggestions: both contain addresses. nobounce persists no plaintext address; anything persisted is SHA-256, with domains in clear. Your retry queue still needs its own access controls and retention policy.

For the complete signup integration and verdict mapping, give your coding agent [/integrate.md](https://nobounce.dev/integrate.md).
