---
title: "Retries and idempotency"
description: "What is retried, what is deliberately not, and why a retried send cannot duplicate."
url: "https://openemail.uk/docs/php/retries"
area: "PHP"
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`, `templates->send` and `broadcasts->send`), generated once per call as `oe-` and a random UUID, 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. A replay answers with `replayed` set to true and the stored message as it is now.

**idempotency.php**

```
$invoiceId = 'inv_2026_09_4192';

$sent = $client->emails->send([
    'from' => 'billing@acme.com',
    'to' => 'ada@example.com',
    'subject' => 'Your September invoice',
    'text' => 'Invoice attached.',
], idempotencyKey: 'invoice:' . $invoiceId);

echo $sent['id'], ' ', ($sent['replayed'] ?? false) ? 'replayed' : 'sent', PHP_EOL;
```

> Derive it from what made the send necessary. Never from a clock. Reusing a key with a different body is refused with a 422 `idempotency_key_reuse` rather than silently replayed. A key is 1 to 255 characters of letters, digits, `_`, `.`, `:` or `-`, and anything else is a 400 `invalid_idempotency_key`.

## Everything else

Every GET 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 GET | Yes | Nothing changes. |
| `emails->send`, `emails->sendBatch`, `templates->send`, `broadcasts->send` | Yes | An idempotency key makes a repeat a replay. |
| `emails->cancel`, `emails->reschedule`, `broadcasts->cancel`, `forms->pause`, `forms->resume`, `threads->restore` | 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. |
| `emails->update`, `labels->update`, `webhooks->update`, `settings->update`, `roles->update`, `members->update`, `domains->update`, `domains->updateAddress`, `domains->updateAddressForward`, `contacts->update`, `audiences->update`, `keys->update`, `forms->update`, `branding->update`, `threads->updateNote`, `chats->rename`, `account->setEmailNotification`, `account->setPushMuted`, `appHost->set`, `workspaces->setActive` | Yes | A pure set of named fields. |
| `members->grantAddress`, `members->grantDomain`, `rules->reorder`, `threads->reorderNotes` | Yes | A grant is an upsert, and an order is stated in full. |
| `templates->publish`, `forms->publish`, `imports->start` | Yes | Publishing what is already published, or starting an import that has already started, returns it unchanged. |
| `templates->preview`, `templates->render`, `broadcasts->preview`, `rules->test` | Yes | They render, count or evaluate, and write nothing. |
| `domains->verify`, `appHost->verify`, `senders->research` | Yes | A repeated check changes nothing but the time it was made. |
| `contacts->save`, `contacts->setAudiences`, `contacts->removePhoto`, `contacts->block`, `contacts->unblock`, `contacts->deleteMany`, `keys->revoke`, `files->revokeLink`, `files->revokeAllLinks`, `appHost->delete`, `account->removePhoto`, `branding->removeImage`, `domains->removeLogo`, `domains->removeLogoCertificate`, `domains->removeAddressPhoto`, `account->acceptInvitation`, `account->declineInvitation`, `forms->approveSubmission`, `subscriptions->move` | Yes | Each states the end result, so a second call leaves what the first one left. |
| `audiences->addContact`, `audiences->addContacts`, `audiences->removeContacts`, `audiences->importContacts`, `suppressions->add`, `domains->createAddress` | Yes | A repeat finds the first call’s work already done and reports it rather than doing it twice. |
| `contacts->setPhoto`, `account->setPhoto`, `branding->uploadImage`, `domains->setLogo`, `domains->setLogoCertificate`, `domains->setAddressPhoto`, `imports->uploadChunk` | Yes | Bytes sent again replace what the first attempt stored. |
| `drafts->create`, `labels->create`, `webhooks->create`, `templates->create`, `rules->create`, `roles->create`, `tempMail->create`, `files->upload` | 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. |
| `webhooks->replayDelivery` | No | It would send the event to your receiver a second time. |
| `emails->translate`, `emails->compose`, `emails->rewrite`, `emails->suggestSubject` | No | Each spends model calls, so a retry after an unanswered request buys the same answer twice. |
| `security->beginStepUp`, `security->verifyStepUp` | No | A retry could send a second email or spend a second try on the code. |
| Every other call that is not a GET | No | Sent once, and a failure is reported rather than repeated. |

## The backoff

- Bounded by `maxRetries:` on the client, defaulting to two extra attempts. `maxRetries: 0` turns retries off.
- 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: each wait is a random point between half that ceiling and the whole of it, 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 exception is thrown with `retryAfterSeconds` on it. Coming back sooner than it asked is not honouring it.
- A timeout is a network failure like any other, so a call that is safe to repeat is tried again after one, and `timeout:` applies to each attempt afresh.
- The waits are `sleep` and `usleep` calls inside the call, so the script waits too, and the call returns or throws only once its last attempt is over. In a web request a person is waiting on, keep `timeout:` and `maxRetries:` low.
