Skip to main content
A duplicate can be the same email reaching a recipient twice, or the same webhook event being processed twice in your own system. Almost every duplicate comes from a retry: your code, your queue, or a webhook delivery ran the same step a second time and nothing recognized it as a repeat. This guide covers how to tell the cases apart and how to stop each one.

Which Kind of Duplicate Is It?

Look both messages up in the dashboard or through the API and compare their request IDs. Two different request IDs mean two accepted send requests. One request ID means Lettr accepted the request once.

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 an Idempotency-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.
What to expect:
  • 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_id plus an Idempotency-Replayed: true header. No second email goes out.
  • Same key, different payload: 409 with error_code idempotency_key_conflict. Lettr refuses it rather than sending a second, different email.
  • Same key while the first request is still being processed: 409 with error_code idempotency_in_progress and a Retry-After: 1 header.
A replay is a success. Treat it as one in your code.

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.
The full retry helper, with backoff for 429 and 5xx responses, is in Idempotency and Errors & Retries.

Choose a Key That Survives Retries

The most common reason an Idempotency-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 returns 409 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
Pass only the fields the template needs and build the request body once, before the retry loop.

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_key field inside metadata stores it on the message and echoes it in webhooks. Only the header enables deduplication.
  • SMTP has no header. The Idempotency-Key header 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 with POST /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 201 and an Idempotency-Replayed: true header, not a second draft.
  • Same key, different payload: 409 idempotency_key_conflict.
  • Same key while the first request is still running: 409 idempotency_in_progress with Retry-After: 1.
  • Retention: the key is remembered for 24 hours.
  • Scope: campaign keys are scoped to your team.
Use one key per intended campaign, for example 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 a 2xx 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.
The retry schedule is 7 attempts over about 35 hours: 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 an event_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.
Read 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:
Keep the stored IDs for at least as long as the retry window (about 35 hours). A 48 hour expiry covers it with margin.
Return 2xx for events you recognize as duplicates. Returning an error makes Lettr retry the whole delivery again.

Make the Effect Idempotent as Well

Deduplicating on event_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 to and cc, 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.
For a duplicate none of these explain, collect the request ID, the recipient address and both message headers (including 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.

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