---
title: "Retries and idempotency"
description: "What is retried, what is deliberately not, and why a retried send cannot duplicate."
url: "https://openemail.uk/docs/sdk/retries"
area: "SDK"
category: "Getting started"
---

# Retries and idempotency

What is retried, what is deliberately not, and why a retried send cannot duplicate.

## Sends

The client attaches an `Idempotency-Key` to every send (`emails.send`, `emails.sendBatch` and `templates.send`), generated once per **call** and reused by that call's retries. The API claims that key before it dispatches anything, so a retry replays the original message rather than sending a second one, while two deliberate `send()` calls still send twice. Those are different intentions and they stay different.

Pass your own `idempotencyKey` to stretch that guarantee across processes, so a job that crashed and ran again replays its sends instead of repeating them.

**idempotency.ts**

```
await openemail.emails.send(message, { idempotencyKey: `invoice:${invoice.id}` })
```

> Derive it from what made the send necessary. Never from a clock. Reusing a key with a different body is refused with `idempotency_key_reuse` rather than silently replayed.

## Everything else

Every read is retried. A write is retried only where a second identical request cannot mean anything different from the first, and a send qualifies because its idempotency key turns a repeat into a replay.

| Call | Retried | Why |
| --- | --- | --- |
| Every read | Yes | Nothing changes. |
| `emails.send`, `emails.sendBatch`, `templates.send` | Yes | An idempotency key makes a repeat a replay. |
| `emails.cancel`, `emails.reschedule` | Yes | A pure set of a named state. |
| `threads.update`, `threads.trash` | Yes | A label set. Applying it twice is applying it once. |
| `threads.snooze`, `threads.unsnooze` | Yes | The wake-up instant is in the body, not derived from arrival time. |
| `labels.update`, `webhooks.update`, `settings.update`, `roles.update`, `members.update` | Yes | A pure set of named fields. |
| `members.grantAddress`, `rules.reorder` | Yes | The grant is an upsert, and the order is stated in full. |
| `templates.publish` | Yes | Publishing a head that is already published returns it unchanged. |
| `templates.preview`, `rules.test` | Yes | They render or evaluate, and write nothing. |
| `drafts.create`, `labels.create`, `webhooks.create`, `templates.create`, `rules.create`, `roles.create`, `tempMail.create` | No | A retry leaves two objects. |
| `drafts.update` | No | Read the id off the result of every write rather than reusing the one you sent. |
| `drafts.delete`, `labels.delete`, `webhooks.delete`, `templates.delete`, `rules.delete`, `roles.delete`, `members.remove`, `members.revokeAddress`, `tempMail.delete`, `tempMail.deleteMessage` | No | A retry after a lost response reports failure for work that succeeded. |
| `webhooks.rotateSecret` | No | A second rotation invalidates the secret the first attempt returned. |
| `webhooks.test` | No | It would send a second synthetic delivery. |
| `emails.translate` | No | It spends model calls, so a retry after an unanswered request buys the same answer twice. |
| Every other write | No | Sent once, and a failure is reported rather than repeated. |

## The backoff

- Bounded by `maxRetries` on the client, defaulting to two extra attempts.
- Only after a network failure or a `408`, `500`, `502`, `503` or `504`. A `429` is retried only when it carries a `Retry-After`, and this API does not send one, so a rate limit throws straight away. Any other status throws at once.
- Exponential from half a second up to eight, with jitter, so a fleet does not resynchronise on the recovery.
- Paced by `Retry-After` in either of its forms, delay-seconds and HTTP-date. When the server names a wait, the client waits exactly that long instead of backing off.
- A server asking for longer than a minute is treated as telling the client to stop rather than to sleep, so the error is raised with `retryAfterSeconds` on it. Coming back sooner than it asked is not honouring it.
- A caller's `AbortSignal` is never retried. Aborting throws an `OpenEmailNetworkError` at once, from the request or from the wait before the next attempt.
