Idempotency, Retries and Backoff for Email Sends: Never Double-Send
Retry failed transactional email sends safely: which HTTP responses to retry, backoff with jitter, idempotency keys, and a send loop that never sends twice.
Networks fail, workers restart, webhooks arrive twice. If your send code retries naively, a customer gets three copies of the same receipt; if it never retries, a transient timeout silently loses a password reset. The fix is not complicated, but it must be deliberate: decide what to retry, how to wait, and how to guarantee that one logical email is sent at most once. This guide gives you that design.
Quick answer
Give every logical email a unique key in your own database (for example order-10482-confirmation), record it before you call the API, and never send a key that is already marked sent. Retry only network errors, timeouts, 429 and 5xx responses; never retry 400, 401, 403 or 422. Wait with exponential backoff plus random jitter, cap at 5 attempts, and move the job to a dead-letter queue after that so a human sees it.
Where duplicates come from
- The request reached the API but the response did not reach you (timeout after send). A retry sends again.
- A webhook from your payment provider delivered twice; each delivery triggers a send.
- A worker crashed after calling the API but before marking the job done, and the queue redelivered the job.
- A cron job ran on two servers, or twice because of a deploy.
- A user double-clicked a button and your endpoint enqueued two jobs.
Idempotency in your own tables
Since the safe place to remember what was sent is your database, model it explicitly. Create an email_sends table with a unique idempotency_key column, plus status (pending, sent, failed), attempts, last_error, provider_message_id and timestamps. Before sending, insert the row with status pending; a unique-constraint violation means it already exists and you stop. After a successful response, update to sent with the message id. If the process dies between the API call and the update, the row stays pending with attempts = 1, and your reconciliation job can decide whether to resend (usually yes for a pending row older than a few minutes with no message id, because a lost response is rarer than a lost call). Build the key from the entity and the event: user-42-verify-2026-08-02, invoice-482-issued.
What to retry
- Retry: connection errors, DNS failures, timeouts, HTTP 429 (rate limited) and 5xx.
- Do not retry: 400 (your body is wrong), 401 (bad or revoked key), 403 (account or limit issue), 422 (message blocked by policy). Retrying these just repeats the failure; fix the cause.
- A 202 Accepted from OquMail means the message is queued for delivery. Do not retry it, even if a later delivery attempt to the recipient defers; the platform handles SMTP retries and shows each attempt in the delivery log.
Backoff with jitter
Wait 1 second, then 2, 4, 8, 16, each multiplied by a random factor between 0.5 and 1.5, and stop after 5 attempts. The jitter matters: when a whole fleet of workers hits a 429 at the same moment, fixed delays make them all retry together and get rate limited again. Cap the total time (about a minute) for user-facing mail like OTPs, since a code that arrives after its expiry is worse than a clear error; allow longer for receipts and digests.
The send loop
async function sendOnce(key, payload) {
const created = await db.insertIgnore("email_sends", { idempotency_key: key, status: "pending" });
if (!created) return; // already sent or in flight
for (let attempt = 1; attempt <= 5; attempt++) {
try {
const res = await fetch("https://api.oqumail.com/api/v1/emails", {
method: "POST",
headers: { "Authorization": "Bearer " + process.env.OQUMAIL_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify(payload), // { from, to, subject, html, text }
signal: AbortSignal.timeout(15000)
});
if (res.status === 202) {
const data = await res.json();
await db.update("email_sends", key, { status: "sent", provider_message_id: data.id, attempts: attempt });
return;
}
if (res.status === 429 || res.status >= 500) throw new Error("retryable " + res.status);
await db.update("email_sends", key, { status: "failed", last_error: String(res.status), attempts: attempt });
return; // 4xx: do not retry
} catch (err) {
await db.update("email_sends", key, { attempts: attempt, last_error: err.message });
if (attempt === 5) throw err;
const base = 1000 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, base * (0.5 + Math.random())));
}
}
}Operational details
- Run the loop inside a queue job, not a web request. Most queue libraries (BullMQ, Sidekiq, Celery, Laravel queues) implement the backoff for you; use their settings and keep the idempotency table.
- Make the enqueue idempotent too: use the same key as the job id so a double-click cannot create two jobs.
- Alert on the dead-letter queue. A send that failed five times is a support case waiting to happen.
- Reconcile pending rows older than 10 minutes with the OquMail delivery log before resending, to be sure the first attempt did not go through.
- Log the attempt number and the response code with the key so you can trace a duplicate if one ever slips through.
Common questions
Should I retry a 422?
No. A 422 means the message was blocked by outbound rules, for example a recipient limit or a disallowed pattern. Resending the same message gives the same answer; change the message or the recipient.
What if the same key needs to be sent again legitimately?
Then it is a different logical email and needs a different key, for example order-10482-confirmation-resend-1. Support tooling should create the new key explicitly rather than clearing the old row.
How long should the request timeout be?
Ten to fifteen seconds. Long enough for a slow network, short enough that a stuck connection does not block a worker.
Free business email on your own domain
OquMail gives you up to 15 mailboxes on your domain — free — with guided SPF/DKIM/DMARC, webmail, IMAP/SMTP for any mail app, and a send API. Most teams are live in under fifteen minutes. Start at oqumail.com.
Get started free