Skip to main content
A retried send can deliver the same email twice. A network timeout hides whether the first attempt reached Lettr, and queue consumers and serverless platforms invoke the same handler more than once. The Idempotency-Key header is the fix: reusing the same key, payload, and API key within 24 hours returns the original transmission result without sending another email.

The contract

Send an optional Idempotency-Key header with POST /api/emails. Reusing the same key, payload, and API key within 24 hours returns the original transmission result without sending another email. Replayed responses include Idempotency-Replayed: true and the same data.request_id. Requests without this header are processed independently. The rules that follow from it:
  • Key format. Keys are 1 to 255 characters using letters, numbers, ., _, and -.
  • One key per intended email. A new intended email requires a new key. Reusing a key with a different payload returns 409 idempotency_key_conflict rather than a second send.
  • Replays are free. A replayed response doesn’t send the email again and doesn’t count against your quota again.
  • Normal checks run first. Authentication, sending-domain and quota checks run before the key is looked up, so a retry after your quota is used up still gets a 429.
  • Only POST /emails. Scheduled sends (POST /emails/scheduled) ignore the header.
  • Retention is 24 hours. This is not an unlimited exactly-once guarantee. A retry after the window is a new request and sends a new email.
  • Metadata does not deduplicate. metadata.idempotency_key is stored and echoed in webhooks like any other metadata field. Only the header enables deduplication.

Choosing a key

Derive the key from the business event the email belongs to, not from the attempt. Generate or derive one key per intended email, then reuse it across every retry and handler invocation. Do not generate a new timestamp, random value, or attempt counter for each retry. A UUID generated once and persisted with the business record works as well as a derived key.
Include the email’s purpose and the entity it relates to, such as order-confirmation-{order_id} or password-reset-{reset_request_id}. Use a stored identifier rather than a raw token or other user-facing value, which can contain characters outside the allowed key format. A generic key like email-123 lets two different email types collide on the same value.

Sending with a key

The header goes on the request alongside the authorization header. A replayed response carries the Idempotency-Replayed header and the original data.request_id:
Store data.data.request_id on the order record. The request ID is the handle for delivery status in the API and dashboard, and every webhook event for the message carries it, so a handler can map events back to the order.

Retrying with the same key

A timeout (ETIMEDOUT) or connection reset (ECONNRESET) means the client received no response, but Lettr may already have accepted the email. Retry with the same key and the same payload. If the first attempt got through, the retry returns the original result as a replay; if it did not, the retry sends the email. There is no need to query for the earlier attempt before retrying. Keep the payload byte-for-byte identical across retries and across function invocations. For template sends, the resolved content must not change either, which means both substitution_data and the template itself stay unchanged between attempts. Pass only the fields the template needs rather than a whole mutable record like the order object, so an unrelated update between attempts cannot alter the payload. A changed payload under the same key is a conflict, not a replay.
A local sent flag on the business record still saves an API call when the outcome is already known, but it is an optimization. The header is what prevents the duplicate.

Error responses

Two 409 responses are specific to idempotent requests. The rest of the retry rules are unchanged and covered in Errors & Retries.

Using an SDK

Every official SDK takes the key as an argument, so you do not have to set the header yourself. The response carries a replay flag alongside the usual fields.
A replay is a success, not an error — the original transmission is returned verbatim. Log it differently if you want the visibility, but do not treat it as a failure.
Laravel is the exception: it does this for you. lettr-laravel derives a key for every queued mail automatically, from the queue job’s UUID plus the payload, so a job retry cannot deliver twice. Pass your own key with Mail::lettr()->idempotencyKey(...) to override it, or set LETTR_IDEMPOTENCY_ENABLED=false to turn the automatic behaviour off. Synchronous sends have no job UUID and so get no automatic key.