---
title: Idempotency
description: Use the Idempotency-Key header to retry sends safely without dispatching duplicate email.
---

> **For AI agents:** the complete documentation index is at [llms.txt](/docs/llms.txt). Append `.md` to any page URL for its markdown version.

Pass an `Idempotency-Key` header on a send and retries of that request become safe: the same key with the same body returns the original response instead of sending again. (Replace the recipient with an address you own.)

<Tabs>
  <Tab title="curl">

```bash
curl -X POST https://api.mailfully.com/v1/emails \
  -H "Authorization: Bearer mf_live_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234-attempt-1" \
  -d '{
    "from": "orders@mail.acme.com",
    "to": "you@yourcompany.com",
    "subject": "Your order shipped",
    "html": "<p>Order #1234 is on its way.</p>"
  }'
```

  </Tab>
  <Tab title="Node">

```typescript
const { data, error } = await mailfully.emails.send(
  {
    from: "orders@mail.acme.com",
    to: "you@yourcompany.com",
    subject: "Your order shipped",
    html: "<p>Order #1234 is on its way.</p>",
  },
  { idempotencyKey: "order-1234-attempt-1" }
);
```

  </Tab>
</Tabs>

Run that request twice and both calls return `202` with the same message id. One email goes out.

## Where it applies

Idempotency is opt-in and honored only by `POST /v1/emails` and `POST /v1/emails/batch`. No header, no deduplication. Other endpoints ignore the header entirely.

In the Node SDK, only `emails.send` and `emails.batch` accept the `{ idempotencyKey }` options argument.

Keys must be 1–256 characters; there is no charset restriction beyond that. A key outside those bounds fails with `400 invalid_idempotency_key` before anything else runs. Keys are scoped to your org, so they cannot collide with another tenant's.

## Replay semantics

| Scenario | Result |
|---|---|
| First request with a key returns 2xx | Response cached for 24 hours |
| Same key + same body within 24 hours | The original response is replayed verbatim — same status, same `id`, no new email |
| Same key + different body | `409 invalid_idempotent_request` |
| Same key while the first request is still in flight | `409 concurrent_idempotent_requests` |
| First request failed (4xx/5xx) | The key is released — a retry re-runs the request |
| Idempotency store unreachable at claim time | `503 service_unavailable`, zero side effects |

Body comparison uses a fingerprint: the JSON is re-serialized with recursively sorted keys and hashed with SHA-256. Property order does not matter; values do. Change any value and the same key returns `409 invalid_idempotent_request`.

Only 2xx responses are cached. A failed request releases the key, so retrying after a `429` or `503` with the same key re-runs the send rather than replaying the failure.

<Warning>
The idempotency layer fails closed. If the idempotency store is unavailable when a keyed request arrives, the API returns `503 service_unavailable` without persisting or enqueueing anything. Retry with the same key.
</Warning>

## Choosing keys

Derive the key from the business event that triggered the send, not from the attempt:

```
order-1234-attempt-1
```

`order-1234` identifies the event, so crash-and-retry loops around the same logical send all share one key. The `attempt-1` suffix gives you an explicit lever: bump it when you deliberately want to send again (a manual resend, a second reminder). A random UUID generated per call gives you no protection — every retry looks like a new request.

Cached responses expire after 24 hours. After that, the same key runs as a brand-new request, so keys older than a day cannot be used to fetch a previous result.

## Errors

| Status | `type` | When |
|---|---|---|
| 400 | `invalid_idempotency_key` | Header present but not 1–256 characters |
| 409 | `invalid_idempotent_request` | Key reused with a different body |
| 409 | `concurrent_idempotent_requests` | The first request with this key is still in flight — wait for it to finish, then retry with the same key |
| 503 | `service_unavailable` | Idempotency store unreachable at claim time; nothing happened — retry with the same key |

For the full error format, see [Errors](/concepts/errors).
