Idempotency keys for email APIs: stop sending duplicates

How idempotency keys prevent duplicate emails when a request times out, how to choose good keys, and how to retry safely without annoying customers.

4 min read

Your server calls the email API. The connection stalls. After fifteen seconds your code gives up. Did the email go out?

You don't know. If you retry and it had gone, the customer gets two receipts. If you don't retry and it hadn't, they get none. Idempotency keys exist to take this decision away from you.

What "idempotent" means

An operation is idempotent when doing it twice has the same effect as doing it once. Setting a value to 5 is idempotent. Adding 5 to it isn't.

Sending an email is not naturally idempotent. Each request sends a new message. An idempotency key makes it behave as if it were. You attach a unique label to the request. If the service sees the same label again with the same content, it recognises the repeat and doesn't send a second copy. It answers with the outcome of the first attempt.

How it works on Mailions's API

Send the key in an Idempotency-Key header:

POST /api/v1/emails
Authorization: Bearer YOUR_API_KEY
Idempotency-Key: receipt/order-1042
Content-Type: application/json

The rules:

  • The key is 1 to 128 printable ASCII characters, with no spaces.
  • It is scoped to your API key, so two different keys can use the same label.
  • It is kept alongside the email log.
  • Repeat the request with the same key and the same body, and you get the original outcome. No second email is sent.
  • Reuse the key with a different body, and the API answers 409 with idempotency_conflict. That protects you from a bug that recycles keys.

Choosing a good key

A key should describe the email, not the attempt. Derive it from something that is stable across retries:

GoodWhy it works
receipt/order-1042One receipt per order
welcome/user-4821One welcome message per user
reset/req-9f3aTied to a specific reset request
invoice/2026-11/cust-77One invoice per customer per month

Bad keys:

  • A fresh random value on every attempt. Retries get different keys, so nothing is deduplicated. The whole point is lost.
  • Something too coarse, like welcome or the user's email. The second legitimate message, such as a second reset, is wrongly treated as a repeat.
  • A timestamp. It changes between attempts.

If two messages should both be sent, they need different keys. A user who requests two password resets triggers two different reset requests, so each has its own ID in the key.

A retry loop that is safe

async function sendWithRetry(message, key, attempts = 3) {
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch(API_URL, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.MAIL_API_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': key,
        },
        body: JSON.stringify(message),
        signal: AbortSignal.timeout(15000),
      });
      if (res.status === 429 || res.status >= 500) throw new Error(`retryable ${res.status}`);
      return await res.json();
    } catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 1000 * 2 ** i));
    }
  }
}

The key stays the same across all three tries. If the first request actually landed, the second and third are recognised and no extra message goes out.

Don't retry errors caused by your own input, such as a missing field. They'll fail every time.

How it relates to the queue

If the mail server is briefly unavailable, the API answers 202 with status: queued. The message is stored and sent automatically, for up to 24 hours. That counts as success from your side, so don't retry it. If you do retry with the same key, you'll just get the same queued result back. Without a key, you'd create a duplicate in the queue.

A 202 with status: unknown is the one case to handle by hand. It means the outcome of the final hand-off couldn't be confirmed. Contact support with the returned ID before resending. A replay with the same key never sends another copy.

What idempotency doesn't do

  • It doesn't make the email arrive. Accepted means the relay took the message, not that the inbox did.
  • It doesn't dedupe across different keys. Two different keys with the same content send two emails.
  • It isn't available over SMTP. SMTP has no standard for it, which is one reason an HTTP API suits retry-heavy code. We compare the two in the Node.js sending guide.

Where it matters most

  • Receipts and invoices, where a duplicate looks like a double charge
  • Login and verification codes, where two live codes confuse people (see OTP emails)
  • Anything triggered by a webhook, since webhook senders retry too
  • Jobs run from a queue that can deliver the same task twice

Getting started

Add the header to your send function today. It costs one line. The API guide documents the header, and you can contact us for a trial on your domain.

Frequently asked questions

What is an idempotency key?

A unique value sent with an API request, usually in an Idempotency-Key header. If the same request is repeated with the same key, the server returns the first result instead of acting again, so a retry never sends a second email.

What should I use as an idempotency key?

Something tied to the business event, such as the type of email plus the order ID, for example receipt/order-1042. Avoid a new random value on each attempt: a retry must reuse the same key to be recognised.

What happens if I reuse an idempotency key with different content?

A well-designed API refuses rather than guessing. The Mailions API answers 409 with idempotency_conflict, which points to a bug where keys are being recycled for different messages.