Why your email API needs idempotency keys
Network failures, timeouts, and retries can cause duplicate emails. Idempotency keys let you retry API requests safely without sending the same email twice.
You send an API request to deliver an order confirmation. The request times out. Did the email go through, or not? If you retry and the original request succeeded, the customer gets two identical emails. If you do not retry and it failed, the customer gets nothing.
This is not an edge case. Any API call over HTTP can encounter network timeouts, connection resets, and ambiguous failures. The question is how you handle them.
The retry problem
The Nuntly SDK automatically retries failed requests up to 2 times with exponential backoff. It retries on connection errors, timeouts, and server errors (5xx). You can configure this behavior per client or per request:
1// Global configuration2const nuntly = new Nuntly({ maxRetries: 3 });34// Per-request override5await nuntly.emails.send(emailParams, { maxRetries: 5 });6
These automatic retries are safe for most operations, but email sending is different. A request might reach the server and trigger delivery before the response is lost on the way back. When the SDK retries, the server processes it as a new request and the recipient gets the same email twice.
You could check whether the email was already sent before retrying, but that introduces a race condition. Between your check and your retry, another process might send the same email.
How idempotency keys work
An idempotency key is a unique string you attach to a request. The server uses it to recognize duplicate submissions:
- You generate a key (typically a UUID) and include it in the
Idempotency-Keyheader. - The server processes the request and stores the result alongside the key.
- If you send the same key again, the server returns the stored result without reprocessing.
The key acts as a contract: "process this request exactly once, no matter how many times I send it."
1import { randomUUID } from 'node:crypto';23const idempotencyKey = randomUUID();45const { data, error } = await nuntly.emails.send(6 {7 from: 'billing@yourapp.com',8 to: order.customerEmail,9 subject: `Receipt for order ${order.id}`,10 html: renderReceipt(order),11 },12 {13 headers: {14 'Idempotency-Key': idempotencyKey,15 },16 },17);18
Now you can safely retry this request within the 24-hour key retention window. If the first attempt succeeded, retries return the original response with an Idempotent-Replayed: true header. If the first attempt genuinely failed with a server error, the key is cleared and the retry is processed as a new request.
Choosing a key strategy
The key format depends on your use case. There are three approaches.
Random keys (UUID, ULID) work when you just need retry safety for a single operation. Generate the key before sending and the SDK's automatic retries will reuse it:
1import { randomUUID } from 'node:crypto';23const idempotencyKey = randomUUID();45const { data, error } = await nuntly.emails.send(6 {7 from: 'billing@yourapp.com',8 to: order.customerEmail,9 subject: `Receipt for order ${order.id}`,10 html: renderReceipt(order),11 },12 {13 headers: { 'Idempotency-Key': idempotencyKey },14 },15);16
Deterministic keys work when you need to prevent duplicates across independent processes. Derive the key from a business identifier:
1// Two different servers processing the same order2// will generate the same key and avoid duplicate sends3const idempotencyKey = `order-receipt-${order.id}`;4
This is useful when multiple workers might pick up the same job from a queue, or when a cron job might fire twice due to clock skew.
Content-based keys work when you do not have a stable business identifier but want to prevent sending the same content twice. Hash the email content (or a subset of it) to derive the key:
1import { createHash } from 'node:crypto';23const idempotencyKey = createHash('sha256')4 .update(`${recipient}-${subject}-${body}`)5 .digest('hex');6
This is useful when the same email content can be generated from different code paths without a shared identifier. Choose which fields to include in the hash based on what makes an email "unique" in your context.
This approach only works if the hashed fields are stable between attempts. If your email body includes dynamic values that change on each generation (timestamps, formatted dates, random tokens), the hash will differ and the key will not match. In that case, use a deterministic key based on a business identifier instead.
What happens on errors
The server behavior depends on the original response:
Server errors (5xx): The stored result is discarded. Your next request with the same key is processed fresh. This is correct because the server failed to complete the operation.
Client errors (4xx): The error response is cached. Retrying with the same key returns the same error. You need to fix the issue (bad email format, missing field, unverified domain) and use a new key.
Successful responses (2xx): The response is cached for 24 hours. Any retry within that window returns the stored response.
The SDK automatically retries on server errors and timeouts, so you do not need to implement retry logic yourself. Combined with an idempotency key, the SDK's retries are safe by default.
Idempotency for bulk sends
For bulk email endpoints, the idempotency key covers the entire batch:
1const { data, error } = await nuntly.emails.bulk.send(2 {3 fallback: {4 from: 'updates@yourapp.com',5 subject: 'Weekly digest',6 },7 emails: [8 { to: 'user-a@example.com', html: digestA },9 { to: 'user-b@example.com', html: digestB },10 ],11 },12 {13 headers: {14 'Idempotency-Key': `weekly-digest-${weekNumber}`,15 },16 },17);18
If the first request succeeded and you retry, the server returns the complete original response (including per-email statuses) without sending any emails again.
Payload fingerprinting
The server checks that retried requests match the original. If you send the same idempotency key with a different request body, you get a 409 Conflict error:
1{2 "error": {3 "status": 409,4 "code": "idempotency_key_payload_mismatch",5 "title": "Idempotency key reused with different payload"6 }7}8
This prevents a subtle bug: accidentally reusing a key meant for a different email. The server computes a fingerprint of the request and compares it on retries. If they diverge, something is wrong and the request is rejected rather than silently ignored.
Concurrent requests
If two requests with the same key arrive simultaneously, the first to reach the server starts processing. The second gets a 409 response indicating the key is already in progress:
1{2 "error": {3 "status": 409,4 "code": "idempotency_key_in_progress",5 "title": "Idempotency key is already being processed"6 }7}8
Wait a moment and retry. The processing window is very short, and if the original request stalls, the server automatically clears the lock after a timeout.
Practical patterns
Here are common scenarios where idempotency keys prevent duplicate emails. Each pattern uses a naturally unique identifier from the execution context as the key.
Queue workers
Most message queues guarantee at-least-once delivery, meaning your worker can receive the same message more than once. This typically happens when the acknowledgment is lost after processing. By using the message ID as the idempotency key, duplicate deliveries from the queue do not result in duplicate emails.
1async function handleQueueMessage(message: QueueMessage) {2 await nuntly.emails.send(3 {4 from: 'notifications@yourapp.com',5 to: message.body.recipient,6 subject: message.body.subject,7 html: message.body.html,8 },9 {10 headers: {11 'Idempotency-Key': message.id,12 },13 },14 );15}16
Scheduled jobs
Cron jobs and scheduled tasks can fire multiple times due to deployment restarts, rolling updates, or clock drift across servers. When two instances of the same job run concurrently, both attempt to send the same email. By combining the job name with the schedule slot, each execution window maps to a single idempotency key.
1async function sendDailyReport(date: string) {2 const idempotencyKey = `daily-report-${date}`;34 await nuntly.emails.send(5 {6 from: 'reports@yourapp.com',7 to: 'team@yourapp.com',8 subject: `Daily report for ${date}`,9 html: await renderDailyReport(date),10 },11 {12 headers: { 'Idempotency-Key': idempotencyKey },13 },14 );15}16
Webhook-triggered emails
External services like payment providers often retry webhook deliveries when they do not receive a timely response. If your handler sends an email on each webhook call, retried webhooks cause duplicate sends. Using the webhook event ID as the idempotency key ensures the email is sent only once per event, regardless of how many times the webhook is delivered.
1app.post('/webhooks/payments', async (req, res) => {2 const event = req.body;34 if (event.type === 'payment.succeeded') {5 await nuntly.emails.send(6 {7 from: 'billing@yourapp.com',8 to: event.data.customerEmail,9 subject: 'Payment received',10 html: renderPaymentConfirmation(event.data),11 },12 {13 headers: {14 'Idempotency-Key': `payment-receipt-${event.id}`,15 },16 },17 );18 }1920 res.status(200).send('OK');21});22
Summary
Idempotency keys are a small addition to your API calls that eliminate an entire class of bugs. They require no changes to your database schema, no distributed locks, and no complex deduplication logic. Generate a key, attach it to the header, and the server guarantees exactly-once processing.
For details on key format, error codes, and API behavior, see the idempotency documentation.
Ship emails, not infrastructure
Free plan available. No credit card required.
Start sending free