Updated October 7, 2026
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. For large data volumes, Salesforce points you somewhere else: any data operation that includes more than 2,000 records is a good candidate for Bulk API 2.0.
This guide walks through authentication, the calls you’ll actually use, and the mistakes that cause 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 an app you register in Salesforce and an OAuth flow.
Setting up the app: External Client Apps first
For years, this step meant creating a Connected App in App Manager. That has changed. Starting in Spring ‘26, customers can no longer create new connected apps unless they request it from Salesforce Support, as Salesforce rolls out External Client Apps (ECAs) and moves connected apps toward a long-term end of support. Existing connected apps keep working. For a new integration, create an External Client App.
Either way, the setup is similar: 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 for flows that use refresh tokens, like the web server flow. Refresh tokens are only issued when the request includes the refresh_token or offline_access scope, and without one, your integration dies the moment the access token expires.
You’ll get a consumer key and consumer secret. Treat these like passwords. Keep them out of source control, because a leaked secret is a credential someone else can use against your org.
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 app, sign a JWT with your private key, and exchange it for an access token. No stored passwords and no interactive login. Note that the JWT bearer flow never issues a refresh token. When the access token expires, you sign a new JWT and request another one.
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 the PKCE extension.
One callout on sandboxes. For a sandbox, use test.salesforce.com or the sandbox’s My Domain login URL, not login.salesforce.com. An “invalid grant” error that won’t go away is often just a request pointed 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 default batch size is 2,000 records, and Salesforce doesn’t guarantee you’ll get exactly the size you ask for. 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 can return a 204 No Content with no body. It’s easy to go 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 a new one if the external ID isn’t matched. You never have to store Salesforce Ids on your side, and a retried request updates the same record instead of creating a duplicate. For any inbound sync from another system, we default to upsert on external ID.
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 allocation. 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. The symptom is ugly: the allocation is shared, so 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 loads over a couple of thousand records, switch to Bulk API 2.0, which is built for volume. It has its own allocations for records and batches per 24 hours, but its calls still count toward the same daily API request allocation as REST and SOAP. It uses far fewer calls per record, not a separate pool.
Check your usage on the way out. API responses include a Sforce-Limit-Info header showing how many calls you’ve used against your org’s limit. 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 or is invalid. 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. Salesforce’s own guidance for a 404 is to verify there are no sharing issues. The integration user may simply not be able to see it.
- 500 is an error on the Salesforce side. Retry with backoff, and contact Salesforce Support if it persists.
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. The examples here use v60.0 only as an illustration, and current releases are well past it, so pick the newest version you’ve tested against. 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. When an integration starts throwing 400s for no obvious reason, a renamed or newly required field is one of the first things to check.
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.
Sources
- Salesforce Help: New Connected Apps Can No Longer Be Created in Spring ‘26
- Salesforce Help: OAuth Tokens and Scopes
- Salesforce Help: OAuth Authorization Flows
- Salesforce Help: Proof Key for Code Exchange (PKCE) Extension
- Salesforce Help: OAuth 2.0 JWT Bearer Flow for Server-to-Server Integration
- Salesforce Help: OAuth Endpoints
- REST API Developer Guide: Query Options Header
- REST API Developer Guide: Status Codes and Error Responses
- REST API Developer Guide: Insert or Update (Upsert) a Record Using an External ID
- REST API Developer Guide: Composite
- REST API Developer Guide: Limit Info Header
- REST API Developer Guide: sObject Describe
- Limits Quick Reference: API Request Limits and Allocations
- Limits Quick Reference: Bulk API and Bulk API 2.0 Limits and Allocations
- Bulk API 2.0 Developer Guide: Introduction to Bulk API 2.0 and Bulk API