Data & Integration

Engineering

Salesforce API integrations that survive contact with production

Every integration works on the day it is built. The engineering is in what happens on the days after, when a record is locked, a token expires overnight and somebody replays a queue.

Two engineers in hard hats inspecting a valve on a large insulated pipelineData & Integration

An integration is easy to demonstrate on the day it is built. The demo is honest, as far as it goes: the token is fresh, no record is locked, the network is behaving, and the test file has twelve rows in it.

The days after are a different environment. A certificate expires overnight and the feed stops without anybody noticing until the sales meeting. A parent account is locked because a rollup is recalculating, so a third of the child records come back with a lock error and the job reports success anyway. A connection drops two thirds of the way through a large load and nobody can say which two thirds were written. Somebody replays a queue to test a change, points it at the wrong endpoint, and finance finds duplicates on Monday.

None of that is exotic. It is the normal operating weather of an integration, and the gap between one that runs unattended for years and one that needs watching comes down to a handful of decisions taken at code and contract level, most of them before the first callout is written.

Two adjacent decisions sit outside this piece. Which pattern an interface should use is worth settling first, in pick the integration pattern before you pick the middleware. Who owns each field and who funds the pipe for its life sits above both, in integration at programme level: ownership before technology. What follows is the layer underneath those: the decisions an engineer makes with a keyboard.

The API family is a volume decision

The platform offers several families and they are not interchangeable, though most can be made to produce the same result at small scale. That is the trap. A row-at-a-time synchronous approach works perfectly against a test file, so it ships, and the wrong choice only becomes visible under volume, on a Monday morning or at month end, when the cost per call stops being negligible.

REST API is the right default for interactive, low-volume, one-record-at-a-time work: a lookup during a user journey, a small write, a query behind a screen. SOAP API remains appropriate where an existing client already depends on it, and migrating a working SOAP client for its own sake is rarely worth the risk.

Bulk API 2.0 is for volume. It is asynchronous, records are processed independently, and results come back as separate successful and failed sets you can collect. That independence is the point, and it is also the thing teams forget: a bulk job that finishes is not a bulk job that worked. Somebody has to read the failed results.

Composite resources sit between the two. They collapse several related operations into a single round trip and let related writes be treated as one unit, which is the right answer whenever an integration is making three or four calls that logically belong together.

Platform Events and Change Data Capture invert the direction: instead of the external system asking repeatedly whether anything has changed, the platform tells it. Polling on a timer is the most common avoidable consumption in an estate, and the most common reason a shared allowance is spent on nothing happening. Streaming API is how a client subscribes to those channels, and Connect REST API covers the collaboration and site-shaped surfaces the general-purpose object APIs handle awkwardly.

The question that picks between them is not which is most modern. It is: what is the peak volume, does anybody have to wait for the answer, and what happens to the rest of the set when one record fails.

Authentication is an operational concern, not a setup step

Most documentation treats OAuth as a configuration exercise: pick a flow, register a connected app, exchange a token, done. That framing is why authentication is the most common cause of an integration failing overnight in an otherwise healthy estate. The flow you pick determines how the integration fails, and the failure mode matters far more than the setup.

JWT bearer is the usual choice for a server-to-server integration with no human present. It is elegant, it needs no stored refresh token, and it fails in two specific ways. The signing certificate expires, and nothing in the delivery process reminds anybody of the date, because the person who uploaded it has moved on. And the integration user gets deactivated, has a permission set removed, or is caught by a login IP restriction added for an unrelated reason, at which point every call fails identically and the error does not say why. JWT bearer also assumes reasonable clock agreement between your host and the platform, which is fine until a container image ships with drift.

Client credentials is the cleaner option for a machine integration, and it moves the failure surface onto the client secret and the user the connected app runs as. Secret rotation becomes a scheduled task rather than an emergency, which is an improvement provided somebody schedules it.

Web server flow is for integrations acting on behalf of a person. It brings a refresh token, and refresh tokens are revocable: a password reset, a session policy change or an administrator revoking access will invalidate it, and the integration then needs a human to reauthorise. If an unattended process depends on a refresh token a person obtained interactively, that is a design defect rather than an operational one.

Three engineering rules follow, whichever flow you use. Cache the access token and reuse it until it fails, because authenticating per call is pure waste against a shared allowance. Guard the refresh so a fleet of workers finding an expired token at the same moment does not stampede the token endpoint: one refresh, the others wait. And treat an invalid session response as routine: reauthenticate once, replay the call once, and only then call it a failure. An integration that treats session expiry as an incident pages somebody every time a session ages out, until people learn to ignore the page.

Limits are a design constraint, not a dashboard

There is a meaningful difference between hitting a limit and being shaped by one. Hitting a limit is an incident: calls start failing, a queue backs up, somebody is paged. Being shaped by one is architecture: the integration was designed so that its throughput ceiling is set by its own scheduler rather than by the platform refusing it.

The figures move. Allocations differ by edition, licence mix and release, so the only correct place to read them is the current Salesforce platform API limits documentation, checked against your own org rather than a number somebody remembers from a previous project. What does not move is the shape of the constraint, and the shape is what you design against.

Three properties of that shape matter. The request allowance is org-wide and shared, so your integration is spending an allowance that belongs to every other integration, mobile client and report refresh as well. Long-running synchronous requests are constrained by concurrency rather than by count, so a handful of slow queries can block work that far more fast calls would not. And asynchronous processing is queued, so submitting work faster does not make it complete faster: it makes the queue longer and the failure later.

Designing to consume less is more durable than monitoring consumption. Move volume onto Bulk API 2.0 rather than looping. Collapse related operations into Composite requests instead of chaining round trips. Select only the fields you need and filter server side rather than retrieving broadly and filtering in the client, which is the most common source of unnecessary traffic we find. Replace polling with Platform Events or Change Data Capture wherever the platform can push, and cache reference data that changes weekly instead of fetching it hourly.

Then put your own ceiling in front of the platform's. A client-side rate limiter, a bounded worker pool and a queue you control turn a surge in source volume into a longer queue rather than a wall of refusals: the difference between an integration that degrades and one that fails.

Idempotency is the decision everything else depends on

If you take one thing from this piece, take this. An operation is idempotent when performing it twice leaves the system in the same state as performing it once. Every other resilience technique here assumes idempotency and none is safe without it: a retry is only a good idea if the retried call cannot duplicate anything, and a replay is only a recovery tool if replaying is harmless.

The mechanism on the platform is well established and undervalued: an external ID field on every integrated object, carrying the identifier from the source system, marked unique, and used as the target of an upsert. That is idempotent by construction. Send the same record five times and you get one record, updated five times, which is exactly the outcome you want.

The alternative that gets written instead is query-then-insert: look up whether a matching record exists, and insert if it does not. That is a race condition with a friendly interface. Two workers processing the same message concurrently both find nothing and both insert, and it doubles the call count for no benefit.

Three extensions are needed in practice. Child records need their own external ID, not a lookup resolved by name, or the parent stays idempotent while the lines quietly duplicate underneath it. Junction records need a composite external ID built from both sides of the relationship. And anything with a financial or notification side effect needs an idempotency key on the record itself, so the receiving side recognises a message it has already actioned. Writing the same payment record twice is survivable. Sending the payment twice is not.

Retries, backoff, and the errors you must never retry

Retry logic written without classification is a denial of service you built and pointed at yourself. The failing call is retried immediately, fails again for the same deterministic reason, and consumes the shared allowance faster than the original traffic ever did.

Classify first. A transient failure is one where the same call, sent later, could plausibly succeed: a row lock, a request limit condition, a gateway or server-side error, a connection reset, an expired session. A deterministic failure returns the same result no matter how many times you send it: a validation rule, a missing required field, an insufficient access error, a malformed query, a reference to a deleted record. Only the first class is worth retrying.

For the transient class, use exponential backoff with jitter rather than a fixed interval, which synchronises workers into waves that arrive together and fail together. Cap the attempts and cap the elapsed time, because an unbounded retry loop against a system that is genuinely down is indistinguishable from an attack. When the cap is reached, stop and record rather than drop.

Row locks deserve a note because they are the most common transient failure in a busy org and the most commonly mishandled. They usually mean two operations are touching records that share a parent. Backoff often works, but the better fix is upstream: group records by parent so concurrent workers stop contending on the same rollup.

A decision table for error response classes

This is the table worth pinning up wherever the client is maintained. It maps what came back to what the client should do about it, and having it written down is what stops each engineer inventing a private answer.

Error classWhat it usually meansCorrect client behaviour
INVALID_SESSION_IDToken expired or was invalidatedReauthenticate once, replay the call once, then escalate
REQUEST_LIMIT_EXCEEDEDOrg-wide allowance consumedStop, back off substantially, alert; never retry tightly
UNABLE_TO_LOCK_ROWContention, usually on a shared parentRetry with backoff and jitter, then regroup by parent
Server or gateway failureTransient platform or network conditionRetry with exponential backoff and jitter, capped
Connection reset or read timeoutOutcome unknown, the write may have landedRetry only if the operation is idempotent, otherwise reconcile
FIELD_CUSTOM_VALIDATION_EXCEPTIONA validation rule rejected the payloadDo not retry; route to the failure queue for a data fix
REQUIRED_FIELD_MISSINGContract mismatch between the two systemsDo not retry; escalate to the interface owner
INSUFFICIENT_ACCESS_OR_READONLYPermission or sharing gap on the integration userDo not retry; escalate, this is a configuration fault
MALFORMED_QUERY or INVALID_FIELDClient built against a different shape or versionDo not retry; escalate, usually a release or version issue
DUPLICATE_VALUEUnique constraint hit, often a competing writeDo not retry blind; upsert on the external ID instead
ENTITY_IS_DELETEDThe target record no longer existsDo not retry; reconcile against the source

Two rows carry more weight than the rest. The unknown-outcome row, where a timeout leaves you unable to say whether the write landed, is what makes idempotency non-negotiable: an upsert on an external ID can simply be sent again, and anything else needs a human to go and look. The permission row is the one most often misclassified as transient, because it can present intermittently while sharing recalculation is in progress, which teaches the client exactly the wrong lesson.

Partial failure inside a batch is a business decision

When a set of records is sent together and some fail, something has to decide whether the successful ones stand. That decision is routinely taken by whoever wrote the code, on the basis of whatever the default happened to be, and it is not a technical decision at all.

All-or-none says the batch is a unit: any failure rolls back the lot, the source is told to fix and resend, and the platform is never left holding a partial state. That is correct for anything with referential meaning. An order header written without its lines is worse than no order at all, because the first is a data incident somebody will act on and the second is an obvious gap.

Independent processing says each record stands on its own merits, the successes are kept, and the failures are collected for reprocessing. That is correct for high-volume, loosely related records: a preference sync, a list refresh, an activity feed. Rejecting a whole load because one row has a malformed postcode is an outage you chose.

Neither is a default. Ask which of the two the business would pick if it were asked, because it will be asked, on the morning after the first partial load.

Composite resources let you ask for either. Bulk API 2.0 processes records independently and hands you the failed set, which puts reprocessing squarely on your side. The rule is simple: decide per interface, write it into the interface contract, and make the choice explicit in code rather than inherited from a default. Then verify it, because the difference only shows up in a failure test, and failure tests are the ones that get skipped.

Error handling that produces a record, not a log line

An error written to a log is not error handling. It is a record of an event nobody will read, in a place nobody will look, until an incident forces someone to search for it and the retention window has closed.

What an operable integration produces instead is a failure record with enough on it to act. That means the business identifier from the source system, not only a platform record id, because the person resolving it works in the source. The operation attempted and the interface it belongs to. The payload, or a durable reference to it, so the work can be replayed without reconstructing it by hand. The error name and message as returned, unedited. The attempt count and the time of the last attempt. A retryable flag set by the classification above. And a status somebody can move.

That last field is what makes the difference. A failure queue that can only be read is a graveyard. One where a person can correct the data, mark the item for replay and watch it succeed is an operational tool, and it is what stops every integration exception becoming an engineering ticket.

Group failures by error class before alerting on them: forty rows failing one validation rule is one problem with one fix, and forty separate alerts is how a team learns to ignore alerts. Replay then has to be safe by construction, which returns the whole thing to idempotency. If replaying can duplicate, nobody will ever be allowed to press the button.

Versioning: pinning is easy, moving is the discipline

Every client calls a specific API version, and pinning one is trivial. Salesforce keeps older versions available for a long, published window and then retires them, which is generous and is precisely what causes the problem: the consequence of never moving arrives years later, when everybody involved has moved on.

The failure is never the pin. It is that nothing causes the pin to move. So build the mechanism rather than relying on intent. Hold the version in one configuration value per client, not as a literal repeated through a codebase, so changing it is a one-line change rather than an archaeology exercise. Record the version each interface uses alongside the interface itself, so the estate can answer the retirement question in minutes instead of weeks. Attach a version review to the platform release cycle, which happens on a schedule whether anybody plans for it or not, and use that rhythm to move one or two clients forward each time.

Then test the move properly. Point the client at the next version in a sandbox and run the failure cases, not only the happy path. Version changes remove fields and endpoints, but they also change behaviour in ways a success-path test will not surface: what a response contains, how an error is shaped, what a resource returns when nothing matches.

Before you call it done

Run the integration against the questions that only matter later. What happens when the token cannot be renewed, and who finds out. What happens when the same message arrives twice, and can you prove it. What happens when a record is locked, and does the job still report success. What happens when one row in a large load fails, and where does that row go. What happens on the day the API version is retired, and who holds that date.

An integration with an answer to each of those is one nobody has to watch. That is the objective, and it is won in the design rather than in the monitoring.

Sources

  1. Salesforce: What is REST API?
  2. Salesforce: Bulk API 2.0 and Bulk API Developer Guide
  3. Salesforce: Platform API limits

Common questions

Answered, directly.

The questions this piece settles about Data & Integration, answered in full on this page.

Bulk API 2.0. It is asynchronous, it processes records independently, and it returns separate successful and failed result sets you can collect and reprocess. REST API and Composite resources suit interactive, low-volume, transactional work. Confirm the current sizing and concurrency figures for your edition in the Salesforce developer documentation before designing around them.

Put an external ID field on every integrated object, carry the source system identifier in it, and upsert against that field instead of querying then inserting. Upsert on a unique external ID gives the same outcome whether a message arrives once or five times, which is what makes retries and replays safe rather than dangerous.

Transient ones: row lock contention, request limit conditions, gateway and server-side failures, and an expired session after a single re-authentication. Never retry validation rule failures, permission errors, malformed queries or references to deleted records. Those return the same result every time, so retrying converts a single failure into sustained load.

Free architect conversation

Talk to an architect, not a sales rep.

Free integration audit. 60 seconds to brief us, and a certified architect replies within one business day.

What are you trying to connect?

Pick the closest fit. The audit is free, and "you do not need middleware" is an answer we give often.

What is being connected?

Optional. Choose any that apply, or skip ahead.

Where does your org stand today?

Optional. A few sentences is plenty: what is working, what is stuck, and what you want to be true. Or skip ahead and tell us on the call.

Who should the architect reach?

A certified architect will reply to these details.

Takes about 30–60 seconds · No obligation · Architect replies within one business day

Protected by reCAPTCHA. Google's Privacy Policy and Terms apply.

More from Insights

Read by desk

Ten desks, one delivery team. Every piece is written by the people who do the work.