Integrations No. 416

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.

  • namesalesforce-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.

~/.claude/skills/salesforce-api/SKILL.md

--- name: salesforce-api description: Integrates applications with Salesforce through the REST, Composite, and Bulk API 2.0 - External Client Apps or existing Connected Apps… license: MIT ---

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 with GET /services/data/. Upgrade deliberately and run the test suite.
  • Always use the instance_url returned with the token; never hardcode a pod hostname. Sandboxes authenticate against test.salesforce.com or the sandbox My Domain.

Choose the auth flow

SituationFlowSetup
Your backend, one org (or a few), no human in the loopOAuth 2.0 JWT bearerUpload 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 userClient credentialsEnable the flow on the app and set the run-as integration user
Your SaaS connecting to customers’ orgsWeb server flow with PKCE, refresh tokenStore refresh token per org ID; handle revocation
Admin scriptsSalesforce 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 / needAPI
Single record CRUD, low volumeREST sObject: /sobjects/Account/{Id}
Upsert one record by external IDPATCH /sobjects/Account/App_Account_Id__c/{value} (201 created, 200/204 updated)
Up to 200 records of one or mixed typessObject 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 recordsBulk API 2.0 ingest or query jobs
Real-time change streamChange 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 nextRecordsUrl until done: true.
  • queryAll includes 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 failedResults and unprocessedrecords; a JobComplete job can still contain thousands of row failures.
  • Query jobs (/jobs/query) page results with the Sforce-Locator header.
  • 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

StrategyUse whenNotes
Change Data Capture via Pub/Sub APINear-real-time, many objectsSubscribe 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 pollingSimpler, minutes of latency OKQuery SystemModstamp > :last - 5 min overlap, upsert idempotently, advance watermark to max seen
Outbound Messages / Flow calloutsAdmin-configured pushesLegacy 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

ErrorCauseFix
INVALID_SESSION_ID (401)Token expired or revokedRefresh once, then fail loudly
REQUEST_LIMIT_EXCEEDEDDaily API allocation or concurrent long-running limitMove volume to Bulk 2.0, cache, spread jobs; alert before 90%
UNABLE_TO_LOCK_ROWMany children of one parent updated in parallelSort by parent, smaller batches, retry with backoff
INVALID_FIELD / No such columnField-level security hides it from the integration user, or wrong API nameFix permission set; check __c suffix
FIELD_CUSTOM_VALIDATION_EXCEPTIONValidation ruleSurface to admins; do not bypass with profile hacks
DUPLICATES_DETECTEDDuplicate rulesUpsert 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/describe as 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-Info before 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.

esc