Salesforce API skill for Claude Code
JWT auth, Bulk 2.0 loads and CDC-driven CRM sync. Claude connects backends to Salesforce through an API-only integration user and JWT bearer or client credentials auth on an External Client App, never stored passwords. It picks REST, Composite or Bulk API 2.0 by volume, upserts on external IDs, syncs incrementally with Change Data Capture or a SystemModstamp watermark, and tracks usage against the org's daily API allocation.
- name
salesforce-api - size192 lines · ≈2,650 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Integrates applications with Salesforce through the REST, Composite, and Bulk API 2.0 - External Client Apps or existing Connected Apps with the JWT bearer or client credentials flow, integration users with least-privilege permission sets, SOQL with injection-safe binding, upsert by external ID, Change Data Capture via the Pub/Sub API, daily API limits, and sync strategies with watermarks and delete handling. Use when reading or writing Salesforce objects from a backend, loading or exporting large datasets, building two-way CRM sync, or debugging INVALID_SESSION_ID, REQUEST_LIMIT_EXCEEDED, or UNABLE_TO_LOCK_ROW. Try asking
- Set up server-to-server auth from our backend to Salesforce without a stored password
- Load 400k accounts into Salesforce and re-run safely without duplicates
- Our Salesforce sync hits REQUEST_LIMIT_EXCEEDED every afternoon - redesign it
Use it when
- A backend reads or writes Salesforce objects
- Loading or exporting large datasets with Bulk API 2.0
- Building incremental or two-way sync between Salesforce and your database
- Replacing username-password auth or a Connected App with an External Client App
Skip it when
- The CRM is HubSpot - use hubspot-crm instead
- You are writing Apex, LWC or org metadata inside Salesforce rather than integrating from outside
How to check the result
The integration user's describe call exposes only the intended fields, a second sync run produces no inserts or failed rows, and Sforce-Limit-Info shows daily usage with at least 30% headroom.
The common mistake it prevents
Testing loads in a bare Developer Edition org, then failing in production because triggers, flows, validation and duplicate rules all fire on API writes.
Done well means: a dedicated API-only integration user with exactly the object and field permissions it needs, server-to-server auth with no stored passwords, upserts keyed on external IDs, the right API for the volume (REST, Composite, or Bulk 2.0), incremental sync driven by Change Data Capture or a SystemModstamp watermark, and API usage tracked against the org’s daily allocation.
Ground rules
- No credentials in code. Private keys, client secrets, and refresh tokens live in a secret manager; the JWT signing key never leaves it if your KMS can sign.
- Do not use the username-password OAuth flow. External Client Apps do not support it, and Salesforce is retiring it. Use JWT bearer or client credentials for servers.
- Since Spring ‘26, creating new Connected Apps is disabled by default; create an External Client App for new integrations. Existing Connected Apps keep working.
- Pin the API version in one config value (examples use
v66.0, Spring ‘26; Summer ‘26 is v67.0 and Winter ‘27 is v68.0). List what an org supports withGET /services/data/. Upgrade deliberately and run the test suite. - Always use the
instance_urlreturned with the token; never hardcode a pod hostname. Sandboxes authenticate againsttest.salesforce.comor the sandbox My Domain.
Choose the auth flow
| Situation | Flow | Setup |
|---|---|---|
| Your backend, one org (or a few), no human in the loop | OAuth 2.0 JWT bearer | Upload an X.509 certificate to the External Client App; pre-authorize the integration user via policy/permission set |
| Your backend, simpler setup, one run-as user | Client credentials | Enable the flow on the app and set the run-as integration user |
| Your SaaS connecting to customers’ orgs | Web server flow with PKCE, refresh token | Store refresh token per org ID; handle revocation |
| Admin scripts | Salesforce CLI (sf org login web) | Never reuse personal sessions in production |
Integration user: create a user with the API-only “Salesforce Integration” user license where available, assign permission sets (not profiles) with object CRUD and field-level access for exactly the objects you touch, and enable “API Only User”.
Workflow
- [ ] 1. Inventory objects, fields, volumes, and direction of each field
- [ ] 2. Add External ID fields (unique) on both sides
- [ ] 3. Create the integration user, permission sets, and External Client App
- [ ] 4. Implement auth and a client with version pinning and limit tracking
- [ ] 5. Pick API per operation by volume
- [ ] 6. Initial load (Bulk 2.0), then incremental sync (CDC or watermark polling)
- [ ] 7. Handle deletes, merges, automation side effects, and errors
- [ ] 8. Verify in a sandbox with production-like automation; then monitor limits
4. Auth and client (JWT bearer, Node)
import { createSign } from 'node:crypto';
async function getToken() {
const header = Buffer.from(JSON.stringify({ alg: 'RS256' })).toString('base64url');
const claims = Buffer.from(JSON.stringify({
iss: process.env.SF_CLIENT_ID, // consumer key of the External Client App
sub: process.env.SF_USERNAME, // integration user's username
aud: process.env.SF_LOGIN_URL, // https://login.salesforce.com or test.salesforce.com
exp: Math.floor(Date.now() / 1000) + 180,
})).toString('base64url');
const sig = createSign('RSA-SHA256').update(`${header}.${claims}`)
.sign(process.env.SF_JWT_PRIVATE_KEY!, 'base64url');
const res = await fetch(`${process.env.SF_LOGIN_URL}/services/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: `${header}.${claims}.${sig}`,
}),
});
if (!res.ok) throw new Error(`SF auth ${res.status}: ${await res.text()}`);
return res.json() as Promise<{ access_token: string; instance_url: string }>;
}
Wrap calls so a 401 INVALID_SESSION_ID triggers one token refresh and retry. Libraries
(jsforce for Node, simple-salesforce for Python) are fine; still pin the version.
Track usage: every REST response carries Sforce-Limit-Info: api-usage=used/max; poll
GET /services/data/v66.0/limits for DailyApiRequests and alert at 70% and 90%.
5. Pick the API
| Volume / need | API |
|---|---|
| Single record CRUD, low volume | REST sObject: /sobjects/Account/{Id} |
| Upsert one record by external ID | PATCH /sobjects/Account/App_Account_Id__c/{value} (201 created, 200/204 updated) |
| Up to 200 records of one or mixed types | sObject Collections: /composite/sobjects with allOrNone |
| Dependent steps in one round trip (create Account, then Contact referencing it) | Composite: /composite with referenceId and @{ref.id}, up to 25 subrequests |
| Thousands to millions of records | Bulk API 2.0 ingest or query jobs |
| Real-time change stream | Change Data Capture over the Pub/Sub API (gRPC) |
Rule of thumb: above ~2,000 records per run, use Bulk 2.0. Each REST call costs one API request; a Bulk job costs a handful regardless of row count.
SOQL
GET /services/data/v66.0/query?q=SELECT+Id,Name,App_Account_Id__c,SystemModstamp+FROM+Account+WHERE+SystemModstamp+>+2026-10-01T00:00:00Z+ORDER+BY+SystemModstamp+LIMIT+2000
- Follow
nextRecordsUrluntildone: true. queryAllincludes deleted and archived rows (IsDeleted = true).- Never concatenate untrusted input into SOQL. In Apex use bind variables
(
WHERE Email = :email); from client code, escape\and'in string literals and validate IDs against^[a-zA-Z0-9]{15}([a-zA-Z0-9]{3})?$. Better: avoid dynamic field names entirely, or allowlist them. - Filter on indexed fields (Id, Name, external IDs, SystemModstamp, lookups) for selective queries on large objects.
6. Bulk API 2.0
V=v66.0; H=(-H "Authorization: Bearer $SF_TOKEN")
JOB=$(curl -sS "${H[@]}" -H 'Content-Type: application/json' \
-X POST "$SF_INSTANCE/services/data/$V/jobs/ingest" \
-d '{"object":"Account","operation":"upsert","externalIdFieldName":"App_Account_Id__c","contentType":"CSV","lineEnding":"LF"}' \
| python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')
curl -sS "${H[@]}" -H 'Content-Type: text/csv' -X PUT \
--data-binary @accounts.csv "$SF_INSTANCE/services/data/$V/jobs/ingest/$JOB/batches"
curl -sS "${H[@]}" -H 'Content-Type: application/json' -X PATCH \
-d '{"state":"UploadComplete"}' "$SF_INSTANCE/services/data/$V/jobs/ingest/$JOB"
# poll GET .../jobs/ingest/$JOB until state is JobComplete or Failed, then:
curl -sS "${H[@]}" "$SF_INSTANCE/services/data/$V/jobs/ingest/$JOB/failedResults" > failed.csv
- Always download
failedResultsandunprocessedrecords; a JobComplete job can still contain thousands of row failures. - Query jobs (
/jobs/query) page results with theSforce-Locatorheader. - Keep each upload well under the documented data-size limit (split large files) and sort rows by parent ID to reduce lock contention.
6b. Incremental sync
| Strategy | Use when | Notes |
|---|---|---|
| Change Data Capture via Pub/Sub API | Near-real-time, many objects | Subscribe to /data/AccountChangeEvent; persist the replay ID after processing; events are retained for about 3 days, so a longer outage needs a watermark resync |
| SystemModstamp watermark polling | Simpler, minutes of latency OK | Query SystemModstamp > :last - 5 min overlap, upsert idempotently, advance watermark to max seen |
| Outbound Messages / Flow callouts | Admin-configured pushes | Legacy or org-specific; see inbound verification below |
Inbound pushes from Salesforce: Pub/Sub API is a pull over an authenticated gRPC
channel, so no signature is needed. For Flow or Apex HTTP callouts to your endpoint, use
a Named Credential and sign the body in Apex with Crypto.generateMac('hmacSHA256', ...)
using a secret stored in the credential, then verify the HMAC over the raw body in your
receiver; also check the org ID in the payload. Outbound Messages should be checked for
the expected organization ID and served over TLS with an allowlisted client.
Deletes: CDC sends DELETE change events; with polling use queryAll ... WHERE IsDeleted = true or GET /sobjects/Account/deleted/?start=...&end=... (recycle bin window is
limited, about 15 days). Merges surface as delete of the losing records plus an update
of the master; remap stored IDs.
Errors
| Error | Cause | Fix |
|---|---|---|
INVALID_SESSION_ID (401) | Token expired or revoked | Refresh once, then fail loudly |
REQUEST_LIMIT_EXCEEDED | Daily API allocation or concurrent long-running limit | Move volume to Bulk 2.0, cache, spread jobs; alert before 90% |
UNABLE_TO_LOCK_ROW | Many children of one parent updated in parallel | Sort by parent, smaller batches, retry with backoff |
INVALID_FIELD / No such column | Field-level security hides it from the integration user, or wrong API name | Fix permission set; check __c suffix |
FIELD_CUSTOM_VALIDATION_EXCEPTION | Validation rule | Surface to admins; do not bypass with profile hacks |
DUPLICATES_DETECTED | Duplicate rules | Upsert by external ID; coordinate rule exceptions with admins |
Gotchas
- API writes fire triggers, flows, validation rules, and assignment rules. A clean load in a Developer Edition org can fail in production; test in a full or partial sandbox with real automation.
- IDs come in 15-character (case-sensitive) and 18-character (case-insensitive) forms; store the 18-character form and compare consistently.
- Datetimes are UTC in the API; date fields have no time zone. Do not shift dates.
- Restricted picklists reject unknown values; multi-currency orgs need
CurrencyIsoCode. - Sandbox refreshes reset integration configuration and certificates; script the setup.
- Destructive operations (Bulk
hardDelete, mass deletes) skip the recycle bin; require explicit user confirmation and a backup export first.
Verification
GET /services/data/v66.0/sobjects/Account/describeas the integration user returns only the fields you expect to access; a field outside the permission set is absent.- Run the sync twice in a sandbox: the second run produces no inserts and no failed rows; failed-results CSVs are empty or explained.
- Delete and merge records in Salesforce; confirm your store reflects both.
- Check
Sforce-Limit-Infobefore and after a full run and project daily usage against the allocation with headroom of at least 30%.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.