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** 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.
invoice_id = 'inv_4192' client.emails.send( {'from': sender, 'to': recipient, 'subject': subject, 'text': text}, idempotency_key=f'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.send_batch, templates.send, broadcasts.send | Yes | An idempotency key makes a repeat a replay. |
| emails.cancel, emails.reschedule, broadcasts.cancel, forms.publish, forms.pause, forms.resume, forms.approve_submission, account.accept_invitation, account.decline_invitation | Yes | A pure set of a named state. |
| threads.update, threads.trash, threads.restore | 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, domains.update, contacts.update, audiences.update, keys.update, emails.update, forms.update, branding.update, chats.rename, threads.update_note, domains.update_address, domains.update_address_forward, 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 | The grant is an upsert, and the order is stated in full. |
| templates.publish, imports.start | Yes | Publishing a head that 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 | Yes | A repeated check changes nothing but the time of the check. |
| contacts.save, contacts.set_audiences, contacts.remove_photo, contacts.block, contacts.unblock, contacts.delete_many, keys.revoke, account.remove_photo, branding.remove_image, domains.remove_logo, domains.remove_logo_certificate, domains.remove_address_photo, app_host.delete, files.revoke_link, files.revoke_all_links, 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, senders.research | Yes | A repeat finds the first call’s work already done and reports it rather than doing it twice. |
| contacts.set_photo, imports.upload_chunk, account.set_photo, branding.upload_image, domains.set_logo, domains.set_logo_certificate, domains.set_address_photo | 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, templates.design, forms.design | 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 | No | It spends model calls, so a retry after an unanswered request buys the same answer twice. |
| emails.compose, emails.rewrite, emails.suggest_subject | No | Each attempt spends another AI action and comes back with a different answer. |
| Every other call | No | Sent once, and a failure is reported rather than repeated. |
forms.update is retried even with expectedUpdatedAt, so a retry after a lost response can come back 409 version_conflict because the first attempt went through. Read the form before trying again.
client.raw.request retries a GET and sends anything else once, unless you pass repeatable=True.
The backoff
- Bounded by
max_retrieson the client, defaulting to two extra attempts. - 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, 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. timeoutbounds each attempt, so a call that uses both of its retries can take three timeouts plus the waits between them.- Cancelling an
AsyncOpenEmailcall is never retried. The cancellation propagates at once, from the request or from the wait before the next attempt.