Idempotency Keys: Prevent Duplicate Emails on Retry
Retrying after a timeout is the most common cause of duplicate emails. Rovela's idempotency key deduplicates within a 24-hour window — this article covers the rules, how to choose keys, and pairing them with backoff retry.

Retrying a send after a network timeout is the most common reason a user gets two verification codes or two order confirmations. The request reached the server and the message was queued; only the response was lost. The client saw a failure and sent again.
An idempotency key exists for exactly this case. Send the same key twice and the server sends one email, answering the second request with the result of the first.
Usage
Add an Idempotency-Key header to the send request:
curl https://api.rovela.dev/emails \
-H "Authorization: Bearer $ROVELA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-10086-confirmed" \
-d '{
"from": "Orders <orders@yourdomain.com>",
"to": "buyer@example.com",
"subject": "Order #10086 confirmed",
"html": "<p>We have received your order.</p>"
}'
The first request sends normally and returns {"object":"email","id":"..."}. Repeat it with the same key within 24 hours and Rovela sends nothing; it returns the first response unchanged, with the same email ID. The batch endpoint POST /emails/batch accepts the same header and applies it to the whole batch.
Rovela's rules
- Scoped to the organization. Two organizations using the same key never see each other's cached result.
- Valid for 24 hours. After that, the same key is treated as a new request.
- Only successful responses are cached. A validation error or an exhausted quota is not remembered, so after fixing the request you can retry with the same key and it will send.
- Up to 256 characters, with surrounding whitespace trimmed. An empty value is the same as no header.
Choosing the key
Tie the key to what the email means, not to the HTTP request that carries it.
Good keys:
order-10086-confirmed: order number plus event.pwd-reset-<token id>: one key per reset flow.welcome-<user_id>: each user should get one welcome email.
Bad keys:
- A random UUID generated per request. A retry gets a new key, so nothing is deduplicated.
- The user ID alone. The same user's second order is swallowed as a duplicate.
Rule of thumb: if two requests are the same thing in business terms, they get the same key.
Pair it with backoff
The key answers "will a retry duplicate?", not "when should I retry?". Timeouts and 5xx are worth retrying; a 4xx means the request itself is wrong and retrying will not help:
async function sendWithRetry(payload, key, attempts = 4) {
for (let i = 0; i < attempts; i++) {
try {
const res = await fetch('https://api.rovela.dev/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.ROVELA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10_000),
})
if (res.ok) return res.json()
if (res.status < 500 && res.status !== 429) {
throw new Error(`not retryable: ${res.status} ${await res.text()}`)
}
} catch (err) {
if (err.message?.startsWith('not retryable')) throw err
}
await new Promise((r) => setTimeout(r, 2 ** i * 500))
}
throw new Error('out of retries')
}
One boundary
Rovela records the key only once the email has been accepted and queued. If two requests with the same key arrive at the same instant, both may be processed. The key protects the serial "fail, then retry" case. Do not use it as a distributed lock, and do not fire concurrent requests with one key.
Summary
Put an idempotency key on every transactional email that carries business meaning, derive it from a business ID, and keep it unchanged across retries. However flaky the network, the user gets one email.
See the Send Email API reference for the full request format.