Skip to main content

Replay a request without double side effects

Idempotency lets you replay a POST request without fearing a double side effect. A client that loses the response to a timeout can retry with the same idempotency key, and the API returns the stored response instead of re-executing the handler. CaptainDNS follows the Stripe convention, with a 24-hour validity window.

Why use idempotency

Networks are unreliable. A client may:

  • Send a request that succeeds on the server but whose response gets lost in flight.
  • Crash in the middle of an automatic retry.
  • See a timeout at the load balancer while the API actually processed the request.

Without idempotency, you must choose between:

  • Not retrying, and risk losing legitimate operations.
  • Retrying blindly, and risk executing the same operation multiple times.

Idempotency gives you a third option: retry safely.

How to use it

Add an Idempotency-Key header to your POST request:

POST /public/v1/deliverability/score HTTP/1.1
Host: api.captaindns.com
Authorization: Bearer cdns_live_a3f2XK7mN9QrVtZ4yP1sH6eL8cF2dB5aR3gW7kJxM
Content-Type: application/json
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

{"email":"contact@captaindns.com"}

The key must be an opaque string between 10 and 255 characters. UUIDv4 values are ideal (high entropy, collisions statistically impossible), but any unique client-side identifier works.

What happens server-side

On the first request, the idempotency middleware:

  1. Computes the sha256 of the request body.
  2. Writes a (api_key_id, idempotency_key, request_hash) record after the handler succeeds.
  3. Captures the status and body of the response before returning it to the client.

On the second request with the same key:

  1. The middleware finds an existing record.
  2. If the new body hash matches the stored one: replay. The stored response is returned as-is, with X-Idempotent-Replay: true.
  3. If the hash differs: conflict. The API returns 409 IDEMPOTENCY_CONFLICT with an explicit message.

A replay consumes zero credits and does not execute the underlying handler. It is logged as a replay in api_requests for observability, but it does not touch the rate limit bucket (beyond the record lookup), nor the monthly quota.

Validity window

Idempotency records live for 24 hours. After that delay, a purge job removes them and a reused key replays the handler normally.

This window is a trade-off: too short, you lose the value for delayed retries; too long, you store an ever-growing volume of old responses. 24 hours covers most retry patterns (immediate retry after a network error, retry a few minutes after job recovery, retry scheduled overnight after a monitoring alert).

Methods covered

Idempotency only applies to mutating or costly methods. In practice, all CaptainDNS public endpoints are POST, so all are eligible. GET requests never go through the idempotency middleware: they are naturally idempotent at the HTTP level.

The Idempotency-Key header is optional. If you do not send it, the request is processed normally, without replay guarantees.

Conflicts

A 409 IDEMPOTENCY_CONFLICT is triggered when:

  • You reuse an idempotency key with a request body that differs, even by one character.
  • This can happen if your client regenerates the request between two attempts (timestamps, embedded uuids, JSON key ordering).

To avoid conflicts:

  • Compute the idempotency key before serializing the body, not after.
  • Do not mix different calls under the same key.
  • If your framework reorders JSON keys, fix the order manually.

In case of a legitimate conflict (you really want to replay a different operation), generate a new key.

Usage examples

Retry after a network timeout

idempotencyKey = uuid()
for attempt in 1..3:
    try:
        response = post(url, body, headers={"Idempotency-Key": idempotencyKey})
        return response
    except TimeoutError:
        sleep(2 * attempt)
raise

Every retry uses the same key. If one of the calls already succeeded on the server, the next ones return the stored response.

Deduplicate inside a job queue

job = fetch_next_job()
idempotencyKey = hash(job.id)
response = post(url, job.payload, headers={"Idempotency-Key": idempotencyKey})
mark_job_done(job.id, response)

If the queue redelivers a job after a crash, the second execution of the same job id reuses the same key and fetches the stored response, instead of double-billing the customer.

Batch with server-side dedupe

for domain in domains:
    idempotencyKey = "batch-" + run_id + "-" + domain
    post(url, {"domain": domain}, headers={"Idempotency-Key": idempotencyKey})

If the batch is relaunched after a failure, every already-processed domain jumps straight to replay, and only the untreated ones consume credits.

Limitations

  • 24 h storage cap: beyond that, replay is no longer possible. Very delayed jobs must log responses on the client side.
  • Scoped to the API key: the same idempotency key used by two different API keys does not collide. That is by design: every key has its own namespace.
  • No inter-server coordination: if you use multiple distinct backends, they share the same API but must coordinate idempotency keys between themselves.
  • Strict body identity: an extra whitespace breaks the hash and triggers a conflict. Serialize deterministically.

Next steps

Continue with the error guide to understand how to react to 409 IDEMPOTENCY_CONFLICT, or consult the OpenAPI reference to see the header documented on every public endpoint.