Idempotency
Use the Idempotency-Key header to retry sends safely without dispatching duplicate email.
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.)
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>"
}'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.
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.
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.