REST API Integration Patterns for Salesforce
The first integration I ever owned went down at 2am on a Sunday, and I heard about it from the client before I heard about it from any monitoring. Their overnight ERP job pushed about 8,000 order updates at a REST endpoint I'd built in Apex, and every single one of them created a duplicate — because I'd never once thought about what happens when the same order arrives twice. By Monday morning there were roughly 47,000 duplicate order records and a very unhappy operations manager named Sanjay who'd spent his weekend reconciling a warehouse against a database that had quietly lied to him.
That mess taught me more about integration design than any documentation ever did. Most of what I know about REST on Salesforce comes from cleaning up patterns that looked fine in a demo and fell over the moment real volume hit them. Here are the ones I bring to every engagement now.
Named Credentials Are Not Optional
I still find hardcoded endpoints and Base64-encoded credentials sitting in Apex classes in orgs I inherit. Every time, someone tells me it was "just for testing" and then it shipped to production three years ago.
Named Credentials exist so you never have to do this. They hold the endpoint URL and the authentication — Basic, OAuth 2.0, JWT, whatever the external system speaks — outside your code, and Salesforce injects the auth header for you at callout time. Your Apex references the credential by name and stays blissfully ignorant of the secret.
HttpRequest req = new HttpRequest();
req.setEndpoint('callout:Billing_API/v2/invoices');
req.setMethod('POST');
req.setHeader('Content-Type', 'application/json');
req.setBody(JSON.serialize(payload));
Http http = new Http();
HttpResponse res = http.send(req);
The callout:Billing_API prefix is the whole point. When the external vendor rotates a token or moves from staging to production, you change one metadata record, not a line of code. And because Named Credentials are metadata, they travel through your pipeline with everything else — no manual step where someone remembers to paste a secret into production and, inevitably, doesn't.
This is the hill I'll die on for integrations. If a callout endpoint or credential lives in Apex, in a custom setting, or in a custom label, it gets refactored to a Named Credential before I sign off on it. There is no scenario in 2023 where the alternative is worth defending.
Callouts Belong in Async, Not in Triggers
The single most common integration bug I see is a synchronous callout wired straight into a trigger. It works for one record in a sandbox. Then a data load touches 250 records, someone commits DML before the callout, and the whole transaction dies with "You have uncommitted work pending."
Salesforce forbids a callout after you've done DML in the same transaction, and it caps you at 100 callouts and 120 seconds of cumulative timeout per transaction. A trigger firing on a bulk update will blow through those limits fast. So the rule I follow is simple: triggers detect that something needs to sync, and hand off. They never make the callout themselves.
The clean way to hand off is a Queueable that implements Database.AllowsCallouts. The trigger collects the record IDs and enqueues the job; the job runs in its own transaction, does the SOQL to rehydrate what it needs, and makes the callouts there.
public class InvoicePublisher implements Queueable, Database.AllowsCallouts {
private final Set<Id> invoiceIds;
public InvoicePublisher(Set<Id> invoiceIds) {
this.invoiceIds = invoiceIds;
}
public void execute(QueueableContext ctx) {
List<Invoice__c> invoices = [
SELECT Id, External_Id__c, Amount__c, Status__c
FROM Invoice__c
WHERE Id IN :invoiceIds
];
for (Invoice__c inv : invoices) {
// one callout per invoice; keep the batch small enough to stay under limits
publish(inv);
}
}
}
Keeping the callout out of the trigger is really the same discipline I argue for in my trigger framework — the trigger delegates, it doesn't do the heavy lifting. And if you're syncing thousands of records, a Queueable isn't enough; you want Batch Apex chunking the work so each execution stays comfortably under the callout ceiling. Enqueue-per-record from inside a loop is a trap. You'll hit the async job limit before you hit anything useful.
Idempotency Is the Whole Game
This is the lesson from Sanjay's weekend. An integration that isn't idempotent is a bug waiting for a retry, and every network on earth retries.
Idempotency means processing the same message twice produces the same result as processing it once. On the inbound side, the mechanism is almost always an external ID field and an upsert. The external system owns the record's identity; Salesforce respects it.
Order__c ord = new Order__c(
External_Order_Id__c = payload.orderId,
Status__c = payload.status,
Total__c = payload.total
);
upsert ord External_Order_Id__c;
Mark External_Order_Id__c as an External ID and Unique. Now the eight-thousand-order replay that ruined my Sunday is a no-op — the second delivery updates the same row instead of minting a duplicate. If I'd written one line differently in 2019, there'd have been nothing to reconcile.
The subtle part is that upsert-on-external-id only protects you if the external ID is genuinely stable. I've been burned by systems that reused IDs across environments, so the staging order 1024 and the production order 1024 collided in a shared org. Confirm the ID space is global before you trust it. When it isn't, prefix it — PRD-1024 — and move on.
Design the Inbound Contract, Don't Just Expose Apex
When Salesforce is the one receiving data, Apex REST gives you a custom endpoint with a @RestResource annotation. It's tempting to treat the method signature as the contract and let callers figure it out. Don't. The endpoint is an API, and APIs deserve a designed shape — an explicit request wrapper, an explicit response wrapper, and status codes that mean something.
@RestResource(urlMapping='/orders/*')
global with sharing class OrderIntakeResource {
@HttpPost
global static ResponseWrapper upsertOrder(OrderPayload payload) {
ResponseWrapper resp = new ResponseWrapper();
try {
Order__c ord = new Order__c(
External_Order_Id__c = payload.orderId,
Status__c = payload.status,
Total__c = payload.total
);
upsert ord External_Order_Id__c;
resp.recordId = ord.Id;
resp.status = 'OK';
RestContext.response.statusCode = 200;
} catch (Exception e) {
resp.status = 'ERROR';
resp.message = e.getMessage();
RestContext.response.statusCode = 400;
}
return resp;
}
global class OrderPayload {
global String orderId;
global String status;
global Decimal total;
}
global class ResponseWrapper {
global String recordId;
global String status;
global String message;
}
}
Notice the with sharing on the class. Inbound integration users are still users, and an integration that runs without sharing is an integration that will eventually hand data to someone who shouldn't see it. Enforcing object and field permissions on the way in matters as much here as anywhere — the same argument I make about CRUD and FLS enforcement in Apex applies double when the caller is a machine that never sees a page layout. Run the intake as a dedicated integration user with a permission set scoped to exactly the objects it touches, and use Security.stripInaccessible before you write untrusted field values.
The other reason to design the contract is versioning. urlMapping='/orders/*' lets you branch on a version segment in the path later without breaking existing callers. Bake the seam in early. Retrofitting versioning onto an endpoint that six external teams already depend on is a conversation nobody enjoys.
Errors Are a Feature, Not an Afterthought
The integrations that survive are the ones that treat a failed callout as an expected event rather than an exception to be swallowed. External systems go down. They rate-limit you. They return a 500 with an HTML error page instead of the JSON you asked for. Your code needs an answer for all of it that isn't "log it and hope."
I persist every outbound attempt to a custom object — endpoint, payload hash, response code, timestamp, retry count. That log is the difference between "the finance sync is broken" and "the finance sync has been returning 429 since 14:00, here are the twelve invoices stuck in the queue."
private void record(Invoice__c inv, HttpResponse res) {
insert new Integration_Log__c(
Invoice__c = inv.Id,
Status_Code__c = res.getStatusCode(),
Response_Body__c = res.getBody().left(30000),
Succeeded__c = (res.getStatusCode() >= 200 && res.getStatusCode() < 300)
);
}
For retries, I lean on a status field and a scheduled job that re-enqueues anything that failed with a retryable code, with a backoff and a cap. What I do not do is retry blindly forever — a 400 is a bad payload and it will be a bad payload on the hundredth attempt too. Retry the 429s and the 503s. Route the 400s to a human. Querying that log efficiently when it grows to millions of rows is its own discipline, and the SOQL patterns that keep you under governor limits are what keep the retry job itself from becoming the next outage.
When REST Isn't the Answer
I reach for synchronous REST far less often than people expect. It's the right tool for low-volume, request-response interactions where the caller needs an answer now — check a customer's credit, fetch a quote, validate an address. Outside that, it's frequently the wrong choice.
If you're moving tens of thousands of records, the Bulk API 2.0 exists precisely so you don't hammer a synchronous endpoint one row at a time. If you're broadcasting an event to several consumers and you don't care about an immediate response, Platform Events decouple the producer from the subscribers far better than point-to-point REST ever will. And if you've got more than three or four systems talking to each other, a middleware layer — MuleSoft, or frankly a lightweight iPaaS — will save you from rebuilding retry, transformation, and monitoring logic inside Apex over and over.
The honest trade-off is that every one of those alternatives adds a moving part. On an APRA-regulated client a few years back we chose plain Named Credential callouts over middleware specifically because the compliance overhead of introducing another vendor into the data flow wasn't worth it for two integrations. It was the right call for them and would be the wrong call for an org with twenty. Match the pattern to the volume and the number of endpoints, not to whatever the last conference talk was about.
If you've inherited an integration that fights you every time volume spikes and you're not sure whether it's the pattern or the plumbing, a second pair of eyes on the design is the kind of review I do. Most of the time the fix is smaller than the fear.
I still think about those 47,000 duplicates. A robust integration and a fragile one look identical in the demo — the difference only shows up at 2am when the retries start, and by then you've either designed for it or you haven't.
Liked this?
New essays in your inbox when I publish. No cadence, no spam.
You might also like
Handling Bulk API Calls Without Hitting Limits
Bulk API failures are rarely about the daily record cap. How I handle row locks, batch sizing, and automation during large loads, with real production code.
Read →SOQL Patterns That Won't Kill Your Governor Limits
The SOQL antipatterns I keep finding in code review, the patterns that replace them, and the trade-offs nobody explains — from years of production Salesforce.
Read →Async Apex Patterns: Queueable vs Batch vs Future — The Decision Tree
Choosing between Queueable, Batch Apex, and Future methods is the asynchronous decision most Salesforce devs get wrong. Here's the framework I use, and the cases where the obvious choice is wrong.
Read →