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

ScenarioResult
First request with a key returns 2xxResponse cached for 24 hours
Same key + same body within 24 hoursThe original response is replayed verbatim — same status, same id, no new email
Same key + different body409 invalid_idempotent_request
Same key while the first request is still in flight409 concurrent_idempotent_requests
First request failed (4xx/5xx)The key is released — a retry re-runs the request
Idempotency store unreachable at claim time503 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

StatustypeWhen
400invalid_idempotency_keyHeader present but not 1–256 characters
409invalid_idempotent_requestKey reused with a different body
409concurrent_idempotent_requestsThe first request with this key is still in flight — wait for it to finish, then retry with the same key
503service_unavailableIdempotency store unreachable at claim time; nothing happened — retry with the same key

For the full error format, see Errors.