Which Kind of Duplicate Is It?
Duplicate API Sends
Why a Retry Sends Twice
A timeout or a dropped connection leaves the client without an answer, although Lettr may already have accepted the email. A retry with no way to say “this is the same email” is a brand new request, and the recipient gets a second copy. A queue worker that crashes after the API call but before it records success has the same effect, as does a serverless platform that invokes a function twice for one event. In each case the retry is correct and the email only lacks a stable identity.Fix It with an Idempotency-Key
Send anIdempotency-Key header on POST /api/emails. Lettr remembers the key together with your payload and your API key for 24 hours. A repeat of the same request within that window returns the original result and does not send another email.
- First request: the email is queued and the response carries
data.request_id. - Replay with the same key and payload: the response carries the same
data.request_idplus anIdempotency-Replayed: trueheader. No second email goes out. - Same key, different payload:
409witherror_codeidempotency_key_conflict. Lettr refuses it rather than sending a second, different email. - Same key while the first request is still being processed:
409witherror_codeidempotency_in_progressand aRetry-After: 1header.
Handle idempotency_in_progress
idempotency_in_progress means the retry arrived while the original request was still running, which is not an error. Wait the number of seconds in Retry-After (1 second) and send the identical request again. The response is then either the original result as a replay or, if the first attempt failed, a fresh attempt.
429 and 5xx responses, is in Idempotency and Errors & Retries.
Choose a Key That Survives Retries
The most common reason anIdempotency-Key does not help is that the key changes between attempts.
Keys are 1 to 255 characters from letters, numbers,
., _ and -. Anything else is rejected with a 422.
Keep the Payload Identical
A reused key with any change in the payload returns409 idempotency_key_conflict. In practice that happens when:
- the payload is rebuilt from a record that changed between attempts, for example an order object whose total was updated
- the template was edited between the first attempt and the retry, so the resolved content differs
- a timestamp or a random value is part of
substitution_data
Know the Limits
- 24 hours. A retry after the window is a new request and sends a new email. Idempotency is not an unlimited exactly-once guarantee.
- Scoped to your API key. The same key sent with a different API key is treated as a different key.
- Only
POST /emails. Scheduled sends (POST /emails/scheduled) ignore the header. Lettr already protects its own dispatch of a scheduled email, so a worker that dies mid-send does not deliver it twice, but a duplicate request that creates a second scheduled email is still your responsibility. - Quota and rate limits still apply. A retry after you used up your quota gets a
429, not a replay. - Metadata does not deduplicate. Putting an
idempotency_keyfield insidemetadatastores it on the message and echoes it in webhooks. Only the header enables deduplication. - SMTP has no header. The
Idempotency-Keyheader is part of the HTTP API, and Lettr’s SMTP service has no equivalent. Deduplicate SMTP sends in your own application before submitting the message.
With the Laravel package, queued mail gets an idempotency key derived automatically from the queue job, so a retried job cannot deliver twice. Synchronous sends get no automatic key. See Idempotency.
Duplicate Campaigns
Creating a campaign withPOST /api/campaigns also accepts an Idempotency-Key header, with the same key format as email sends. A client that retries after a timeout would otherwise create two identical drafts, and sending both would deliver the same campaign to your audience twice.
- Same key, same payload: the original campaign comes back with
201and anIdempotency-Replayed: trueheader, not a second draft. - Same key, different payload:
409idempotency_key_conflict. - Same key while the first request is still running:
409idempotency_in_progresswithRetry-After: 1. - Retention: the key is remembered for 24 hours.
- Scope: campaign keys are scoped to your team.
newsletter-2026-10, and reuse it on every retry. The request details are in the Create a campaign reference.
Duplicate Webhook Events
A duplicate in your own system is often not a duplicate send at all. Lettr retries a webhook delivery whenever your endpoint does not answer with a2xx within 30 seconds, so your handler can legitimately receive the same event more than once.
Why the Same Event Arrives Twice
- The server processed the event and returned
200, but the response never reached Lettr. - The handler did slow work before responding and crossed the 30 second limit.
After the 7th failed attempt the delivery is marked permanently failed. A
410 Gone response disables the webhook instead of retrying. Full details are in Retries.
Deduplicate on event_id
Each event in the batch carries anevent_id. A webhook request body is a JSON array, and each item wraps the event in an msys object. The event sits under message_event for message events such as delivery, bounce and delay, under track_event for opens and clicks, and under unsubscribe_event, gen_event or relay_message for the rest.
event_id from the object inside msys, store it, and skip any event you have already seen. A unique constraint in your database makes the check safe even when two deliveries arrive at the same moment:
Make the Effect Idempotent as Well
Deduplicating onevent_id stops repeat deliveries. It does not protect you when the same real-world change is reported twice, for example a recipient clicking a link twice. Make the handler’s effect safe to repeat: set a status to a value rather than incrementing a counter, or key an update on the transmission_id and recipient (rcpt_to). The transmission_id matches the data.request_id returned when you sent the email, so you can map any event back to the send that caused it.
Webhooks also arrive in approximate order only. Compare the event timestamp before you overwrite a newer status with an older one.
Check the Recipient Side
If two copies share one request ID, Lettr accepted a single request. The extra copy comes from somewhere downstream:- Forwarding rules. The recipient’s mailbox forwards to a second address that is also read in the same mail client.
- Mailing lists and aliases. One alias that expands to several mailboxes delivered to the same person.
- The same address twice. The address appears in both
toandcc, or a list of recipients you built contains it twice with different capitalization. Deduplicate your recipient list before sending. - Client sync issues. Some mail clients show a message twice after a failed sync.
Message-ID and the Received chain) before you contact support.
Prevention Checklist
1
Send an Idempotency-Key on every transactional send
Derive it from the business event, such as
password-reset-{reset_request_id}, and store or recompute it identically on every retry.2
Build the payload once
Create the request body before the retry loop so no field changes between attempts.
3
Retry only what is safe to retry
Retry timeouts, connection resets,
429, 5xx and idempotency_in_progress. Do not retry 4xx validation errors or idempotency_key_conflict.4
Add a key to campaign creation
Send
Idempotency-Key on POST /api/campaigns so a retried request returns the same draft.5
Respond to webhooks fast and deduplicate on event_id
Return
200 first, do the work afterwards, and record each event_id with a unique constraint.Related Topics
Idempotency
The full Idempotency-Key contract, SDK examples and a retry helper
Errors & Retries
Which errors are safe to retry and how to back off
Webhook Retries
The retry schedule, success criteria and the 410 response
Webhook Delivery Failures
Diagnose endpoints that time out or return errors