Skip to the documentation
Ruby

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.

idempotency.rb
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.

CallRetriedWhy
Every GETYesNothing changes.
emails.send, emails.send_batch, templates.send, broadcasts.sendYesAn idempotency key makes a repeat a replay.
emails.cancel, emails.reschedule, broadcasts.cancel, forms.pause, forms.resume, threads.restoreYesA pure set of a named state.
threads.update, threads.trashYesA label set. Applying it twice is applying it once.
threads.snooze, threads.unsnoozeYesThe 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_activeYesA pure set of named fields.
members.grant_address, members.grant_domain, rules.reorder, threads.reorder_notesYesA grant is an upsert, and an order is stated in full.
templates.publish, forms.publish, imports.startYesPublishing what is already published, or starting an import that has already started, returns it unchanged.
templates.preview, templates.render, broadcasts.preview, rules.testYesThey render, count or evaluate, and write nothing.
domains.verify, app_host.verify, senders.researchYesA 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.moveYesEach 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_addressYesA 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_chunkYesBytes sent again replace what the first attempt stored.
drafts.create, labels.create, webhooks.create, templates.create, rules.create, roles.create, temp_mail.create, files.uploadNoA retry leaves two objects.
drafts.updateNoRead 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_messageNoA retry after a lost response reports failure for work that succeeded.
webhooks.rotate_secretNoA second rotation invalidates the secret the first attempt returned.
webhooks.testNoIt would send a second synthetic delivery.
webhooks.replay_deliveryNoIt would send the event to your receiver a second time.
emails.translate, emails.compose, emails.rewrite, emails.suggest_subjectNoEach spends model calls, so a retry after an unanswered request buys the same answer twice.
security.begin_step_up, security.verify_step_upNoA retry could send a second email or spend a second try on the code.
Every other call that is not a GETNoSent 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: 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 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-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 retry_after_seconds 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 calls inside the call, so the calling thread waits too, and the call returns or raises only once its last attempt is over.