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.send_batch, 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 idempotency_key: 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.
invoice_id = "inv_2026_09_4192" sent = client.emails.send( from: "[email protected]", to: "[email protected]", subject: "Your September invoice", text: "Invoice attached.", idempotency_key: "invoice:#{invoice_id}") puts sent[:id], sent[:replayed]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.send_batch, 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.update_address, domains.update_address_forward, contacts.update, audiences.update, keys.update, forms.update, branding.update, threads.update_note, chats.rename, account.set_email_notification, account.set_push_muted, app_host.set, workspaces.set_active | Yes | A pure set of named fields. |
| members.grant_address, members.grant_domain, rules.reorder, threads.reorder_notes | 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, app_host.verify, senders.research | Yes | A repeated check changes nothing but the time it was made. |
| contacts.save, contacts.set_audiences, contacts.remove_photo, contacts.block, contacts.unblock, contacts.delete_many, keys.revoke, files.revoke_link, files.revoke_all_links, app_host.delete, account.remove_photo, branding.remove_image, domains.remove_logo, domains.remove_logo_certificate, domains.remove_address_photo, account.accept_invitation, account.decline_invitation, forms.approve_submission, subscriptions.move | Yes | Each states the end result, so a second call leaves what the first one left. |
| audiences.add_contact, audiences.add_contacts, audiences.remove_contacts, audiences.import_contacts, suppressions.add, domains.create_address | Yes | A repeat finds the first call’s work already done and reports it rather than doing it twice. |
| contacts.set_photo, account.set_photo, branding.upload_image, domains.set_logo, domains.set_logo_certificate, domains.set_address_photo, imports.upload_chunk | Yes | Bytes sent again replace what the first attempt stored. |
| drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, temp_mail.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.revoke_address, temp_mail.delete, temp_mail.delete_message | No | A retry after a lost response reports failure for work that succeeded. |
| webhooks.rotate_secret | No | A second rotation invalidates the secret the first attempt returned. |
| webhooks.test | No | It would send a second synthetic delivery. |
| webhooks.replay_delivery | No | It would send the event to your receiver a second time. |
| emails.translate, emails.compose, emails.rewrite, emails.suggest_subject | No | Each spends model calls, so a retry after an unanswered request buys the same answer twice. |
| security.begin_step_up, security.verify_step_up | 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
max_retries:on the client, defaulting to two extra attempts.max_retries: 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 raises straight away. Any other status raises 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 error is raised with
retry_after_secondson 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
sleepcalls inside the call, so the calling thread waits too, and the call returns or raises only once its last attempt is over.