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.
$invoiceId = 'inv_2026_09_4192'; $sent = $client->emails->send([ 'from' => '[email protected]', 'to' => '[email protected]', '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: 0turns retries off. - Only after a network failure or a
408,500,502,503or504. A429is retried only when it carries aRetry-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-Afterin 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
retryAfterSecondson 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
sleepandusleepcalls 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, keeptimeout:andmaxRetries:low.