Most of the integration work we do lands on the REST API. SOAP still has its place for big bulk operations and a few legacy systems, but when we’re wiring up a web form, a customer portal, or a Node service that needs to read and write a handful of records, REST is what we reach for. It’s simpler to debug, the JSON is readable, and you can poke at most of it with a curl command before you write a line of real code.
This is the guide we wish we’d had when we started. It walks through authentication, the calls you’ll actually use, and the mistakes that cost us a few late nights.
Start with authentication, because everything depends on it
You can’t call a single endpoint without a valid access token, so this is where the work begins. The token comes from a Connected App and an OAuth flow.
Setting up the Connected App
In Setup, go to App Manager and create a new Connected App. Turn on OAuth settings, set a callback URL, and pick your scopes. For most integrations we grant api and refresh_token, offline_access. That second one matters more than people expect. Without offline_access you don’t get a refresh token, and your integration dies the moment the access token expires.
You’ll get a Consumer Key and Consumer Secret. Treat these like passwords. We’ve seen them committed to a public repo more than once, and Salesforce will happily let an attacker mint tokens with them.
Picking the right flow
For a server-to-server integration with no human in the loop, use the client credentials flow or the JWT bearer flow. We lean on JWT bearer for production. You upload a certificate to the Connected App, sign a JWT with your private key, and exchange it for an access token. No stored passwords, no interactive login, and the token refresh is clean.
A JWT bearer request looks like this:
POST https://login.salesforce.com/services/oauth2/token
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=<your signed JWT>
The response gives you an access_token and, importantly, an instance_url. That instance URL is the base for every other call. Don’t hardcode na123.salesforce.com or whatever you saw in a tutorial; read it from the token response. Org migrations and instance refreshes will change it, and a hardcoded host is the kind of thing that breaks at 2am with no warning.
For anything where a real user logs in (a portal, a connected mobile app), use the web server flow with PKCE.
One callout on sandboxes. The token endpoint for a sandbox is test.salesforce.com, not login.salesforce.com. We’ve burned an hour on an “invalid grant” error that was just us pointing at the wrong login host.
The calls you’ll actually use
Once you have a token, every request carries it in the header:
Authorization: Bearer <access_token>
Here are the endpoints we hit on basically every project.
Query records with SOQL
GET /services/data/v60.0/query/?q=SELECT+Id,Name,Email+FROM+Contact+WHERE+Email!=null
The response includes the records, a totalSize, and a done flag. If done is false, there’s a nextRecordsUrl you call to page through the rest. The API returns 2,000 records per page by default. People forget the pagination and then wonder why their sync only ever pulls the first chunk.
Create a record
POST /services/data/v60.0/sobjects/Account
Content-Type: application/json
{ "Name": "Acme Corp", "Industry": "Manufacturing" }
You get back the new record’s Id and a success flag. Simple.
Update a record
PATCH /services/data/v60.0/sobjects/Account/001XXXXXXXXXXXX
Content-Type: application/json
{ "Industry": "Technology" }
A successful PATCH returns a 204 with no body. That tripped us up early, we kept looking for a response payload that doesn’t exist. No news is good news here.
Upsert on an external ID
This one earns its keep. If your source system has its own primary key, store it in a Salesforce external ID field and upsert against it:
PATCH /services/data/v60.0/sobjects/Account/External_Id__c/AC-10042
Salesforce updates the matching record or creates one if it doesn’t exist. You never have to store Salesforce Ids on your side, and you don’t get duplicates from a retried request. For any inbound sync from another system, we default to upsert on external ID. It has saved us from a lot of duplicate cleanup.
Governor limits don’t disappear because you’re outside Apex
This is the thing that catches developers coming from a pure-API background. The API has limits too, just different ones.
Every org has a daily API request cap. It scales with your edition and license count, but it’s finite. If you build a sync that fires one API call per record across 50,000 records, you can drain a day’s quota in an afternoon. We’ve watched it happen, and the symptom is ugly: every integration in the org starts failing at once, not just the one that ran wild.
The fix is to batch. The Composite API lets you bundle up to 25 subrequests into a single call:
POST /services/data/v60.0/composite
You can also reference the output of one subrequest in the next, so you can create an Account and its Contacts in one round trip. For bulk loads of tens of thousands of records, switch to the Bulk API 2.0 instead, which is built for volume and counts against a separate, much higher limit.
Check your usage on the way out. Every API response includes a Sforce-Limit-Info header telling you how many calls you’ve used and how many you have left. Log it. When you’re at 80% of your daily limit, you want to know before you hit the wall, not after.
Error handling that won’t wake you up
The API returns real HTTP status codes, and they mean what they say. Build your handling around them.
- 400 usually means your request body is wrong: a bad field name, a required field left blank, or a validation rule firing. The response body names the field and the problem. Read it; it’s specific.
- 401 means your token expired. Refresh it and retry the call once. Don’t refresh on every request “just in case,” that’s wasteful and you’ll hit the token endpoint’s own limits.
- 403 with
REQUEST_LIMIT_EXCEEDEDmeans you blew the daily API cap. There’s no retry that fixes this; you have to wait or reduce your call volume. - 404 on a record you expect to exist often means a sharing or visibility issue, not a missing record. The integration user can’t see it.
- 500 is genuinely Salesforce’s side. Retry with backoff.
Our rule of thumb: retry on 401 (once, after refresh) and 5xx (with exponential backoff). Do not retry on 400 or 403, because the same request will fail the same way every time and you’re just burning quota.
Run the integration as a dedicated user
Don’t point your integration at an admin’s personal account. Create a dedicated integration user with a permission set scoped to exactly what it needs. Two reasons. First, when something writes garbage data, you want the audit trail to point at the integration, not at a person. Second, an admin who leaves the company and gets deactivated will take your integration down with them. A dedicated user with no human attached doesn’t have that problem.
A few habits that keep things sane
Pin the API version in your URL path and bump it deliberately. v60.0 won’t change behavior under you; “latest” can. When a new version ships, test against it before you move.
Validate against the org’s actual schema, not your assumptions. Field names, picklist values, and required fields drift as admins make changes. The /sobjects/Account/describe endpoint gives you the real field list. We hit it when an integration starts throwing 400s for no obvious reason, and nine times out of ten an admin renamed or required a field.
Log the full request and response on failure, including the body. The single most useful thing during a 2am outage is being able to see exactly what you sent and exactly what Salesforce said back. A log line that just says “API call failed” is worthless.
When you’re testing a new endpoint, build the call in Postman or a Workbench query first. Get a green response by hand before you wrap it in code. It separates “my request is wrong” from “my code is wrong,” and those are very different bugs to chase.