---
title: "Design the retry before you write the callout"
url: "https://trailassembly.com/callout-retries-and-idempotency/"
topic: "Data and Integration"
published: "2026-09-10T10:06:13+00:00"
updated: "2026-10-06T13:52:11+00:00"
---

# Design the retry before you write the callout

**The answer, briefly:** your callout will run twice. Not because of a bug — because a timeout tells you nothing about whether the other system did the work, and somebody will replay the message anyway. Design for that on day one: put a key in the request, make the receiver treat a repeat as a no-op, and never decide a retry is safe by guessing. This is a fifteen-minute design choice that saves a reconciliation project.

## A timeout is not a failure. That is the whole problem

When a callout times out, you know one thing: you did not get an answer. You do not know whether the request arrived, whether it was processed, or whether the response was lost on the way back.

Those three cases need opposite responses. If the request never arrived, retrying is correct. If it was processed and the answer was lost, retrying creates a second payment, a second order, a second ticket. From inside Salesforce they look identical.

Which means safety cannot come from the caller. It has to come from the design of the request itself.

## Give every request an identity

Send a key with the request that identifies the work, not the attempt. A quote Id, an order number, a UUID you generate and store on the record before the callout goes out.

The receiver then has a rule it can actually apply: if you have already processed this key, return the original result and do nothing else. Two identical requests produce one outcome and two identical responses. That property is worth more than any retry framework.

Store the key on the Salesforce record, not only in the log. When somebody asks in six months whether order 4471 was sent twice, the answer should be a field on the record, not an afternoon in event monitoring.

## Inbound is the easy half, and Salesforce hands you the tool

For data coming the other way, upsert on an [external ID](https://trailassembly.com/upsert-with-external-ids/) does the job for free. Mark the field that holds the other system’s key as an [External ID](https://trailassembly.com/upsert-with-external-ids/), then have the integration upsert against it rather than insert.

A replayed message now updates the row it created the first time. The REST API supports this directly through the [sObject Rows by External ID resource](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_sobject_upsert.htm), and [sObject Collections will upsert up to 200 records](https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_composite_sobjects_collections_upsert.htm) against an external ID in one request.

This is the single highest-value habit in Salesforce integration work. An integration that inserts is one operator mistake away from duplicates. The same integration upserting on a key is not.

## The limits decide how much retrying you can do in-process

Retry loops inside a synchronous transaction run into hard numbers. From the [Apex governor limits](https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_gov_limits.htm):

- **100 callouts per transaction**, counted across everything that runs in the save.

- **10 seconds** default timeout per callout, **120 seconds** cumulative for the transaction.

- **Concurrent long-running requests:** any synchronous transaction over five seconds counts against a pool of one per 100 licences, minimum 10, maximum 50, for the whole org.

Read the third one with a retry loop in mind. Three attempts against an endpoint that takes four seconds each is a twelve-second synchronous transaction holding one of a small number of org-wide slots. Enough of those at once and unrelated work starts failing — your retry policy became everyone’s outage.

So: retry in-process at most once, only for a fast endpoint, and only when a user is genuinely waiting. Everything else retries out of process.

## Retry out of process, with a record you can see

The pattern that holds up: write the intent down, act on it asynchronously, record the outcome.

1. **Persist the request.** A custom object row, or fields on the record, holding the key, the payload or enough to rebuild it, an attempt count and a status.

2. **Send from a [Queueable](https://trailassembly.com/apex-choosing-async/).** One attempt per execution. On a retryable failure, chain the next attempt with a delay. On success or a permanent failure, stop and write the outcome.

3. **Back off.** Immediate retries hit an endpoint that is still down. Increasing delays give it room, and stop you from turning a partner’s incident into a denial of service.

4. **Give up deliberately.** A dead-letter status with the last error, and something a human will actually see. Silent infinite retry is how integrations rot.

Middleware can own all of this, and often should past two or three integrations. What middleware cannot fix is a receiver with no idempotency key, because the duplicate is created downstream of every guarantee the middleware makes.

## Two failure modes people design straight past

**The manual replay.** The one that creates duplicates is almost never the automated retry — it is a person at 9pm re-running yesterday’s file, past the middleware, straight into the API. Idempotency in Salesforce, not only in the platform between the two systems, is what survives that.

**Callout ordering.** Apex will not let you make a callout after DML in the same transaction, and that restriction is doing you a favour: it stops a slow endpoint holding database locks while users wait. If you are fighting it, the design wants splitting, not working around. We covered the mechanics in [You have uncommitted work pending](https://trailassembly.com/uncommitted-work-pending/).

## What I would put in the design, in one line each

- The idempotency key, named, and where it is stored on the Salesforce side.

- Which failures are retryable and which are permanent. A 500 is worth another attempt, a 400 never is.

- Maximum attempts, the backoff, and what happens after the last one.

- Who finds out, and how, when a request lands in the dead-letter state.

Four lines. An integration that cannot fill them in is not finished, whatever the happy path looks like in the demo.

## Frequently asked

**Does the platform retry callouts for me?** No. A callout from Apex is one attempt. Anything more is yours to build. Platform features that do retry, like outbound messages, retry on their own schedule and do not consult your code.

**Is a 500 always safe to retry?** It is the most likely to be safe, and it is still a guess unless the request carries a key. With a key you do not need to reason about it at all, which is the point.

**Where should the attempt counter live?** On the persisted request, not in a static variable. Statics die with the transaction, and the transaction is what failed.

**Do I need this for a read?** No. Reads are naturally repeatable. All of this applies to anything that changes state on the other side.

---

Source: https://trailassembly.com/callout-retries-and-idempotency/
Trail Assembly — independent Salesforce write-ups.
