Salesforce API skill for Codex
Upsert by external ID, bulk-load without burning limits. Codex builds Salesforce integrations that authenticate with JWT bearer or client credentials instead of passwords, upsert by external ID so reruns are safe, and switch to Bulk API 2.0 once volume grows. It writes selective, injection-safe SOQL, reads Sforce-Limit-Info on every call, and tests in a sandbox before production, where triggers and flows can fire on every API write.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- salesforce-api Installs to ~/.agents/skills/salesforce-api/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install salesforce-api The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project salesforce-api Installs to .agents/skills/salesforce-api/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/salesforce-api.zipunzip -o salesforce-api.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) salesforce-api PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $salesforce-api, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates with Salesforce through the REST API, Composite and sObject Collections, Bulk API 2.0 and SOQL - JWT bearer and client credentials auth, external-ID upserts, selective queries with nextRecordsUrl paging, API limits and Sforce-Limit-Info, Change Data Capture, plus simple-salesforce, jsforce and the sf CLI. Use when asked to sync records with Salesforce, write or optimise SOQL, load or export large data volumes, build a server-to-server Salesforce integration, or fix INVALID_FIELD, REQUEST_LIMIT_EXCEEDED or session errors.
Use it when
- Records must be created, updated or exported between Salesforce and another system
- You are loading or extracting more than a few thousand rows
- A server-to-server Salesforce integration needs auth without a stored password
- SOQL is slow or the org is near its daily API request limit
Use something else when
- The CRM is HubSpot: use hubspot-api
- You need a generic SaaS-to-SaaS sync engine rather than Salesforce specifics: use data-sync-jobs
How to check the result
Compare SOQL counts before and after the run in a sandbox, require zero unexplained rows in Bulk failedResults, and report API usage from Sforce-Limit-Info.
The mistake it prevents
Importing through the API without checking the object's triggers and flows, which can email customers or overwrite fields on every record.
Done well means: the integration authenticates without a stored password, writes are idempotent upserts on an external ID, the right API is used for the volume, daily API usage is measured, and anything touching production data was tried in a sandbox and confirmed by the user first.
Pick the API by volume and shape
| Records per run | Use | Why |
|---|---|---|
| 1 to ~200, interactive | REST sObject endpoints | Simple, immediate errors |
| Up to 200 per call, several objects or dependent steps | Composite / sObject Collections | One API call, optional all-or-none |
| Thousands to millions | Bulk API 2.0 ingest or query jobs | Async, CSV, cheap on API limits |
| React to changes in Salesforce | Change Data Capture or Platform Events via Pub/Sub API | No polling |
| Metadata (fields, flows, permission sets) | Salesforce CLI / Metadata API | Deployable source, not data |
Authentication
| Flow | When |
|---|---|
| OAuth 2.0 JWT bearer (certificate) | Server-to-server, runs as a named integration user. Default choice |
| OAuth 2.0 client credentials | Server-to-server with a configured run-as user; no certificate handling |
| Web server (auth code + PKCE) | Your app acts as each signed-in Salesforce user |
| Username-password | Do not use; blocked by default in new orgs and bypasses MFA |
Create the app as an External Client App (Salesforce’s current model; creating classic
connected apps may be disabled in newer orgs). Use a dedicated API-only integration user with
a permission set granting exactly the objects and fields the job touches. Sandboxes log in
via https://test.salesforce.com; production via https://login.salesforce.com or the My
Domain URL.
import time, jwt, requests # PyJWT with cryptography installed
def jwt_login(client_id, username, key_pem, login_url="https://login.salesforce.com"):
assertion = jwt.encode(
{"iss": client_id, "sub": username, "aud": login_url, "exp": int(time.time()) + 180},
key_pem, algorithm="RS256")
r = requests.post(f"{login_url}/services/oauth2/token", timeout=30, data={
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion})
r.raise_for_status()
tok = r.json()
return tok["instance_url"], tok["access_token"]
Secrets and keys come from env or a secret manager (read AGENTS.md). Every call needs
network; in an offline Codex sandbox, write the code and ask before running it.
API version
List what the org supports with GET /services/data/ and pin the newest listed version in
config (for example v64.0). Never mix versions within one integration; field availability
and behaviour change between releases.
sf org login web --alias dev-sandbox --instance-url https://test.salesforce.com
sf data query --query "SELECT Id, Name FROM Account LIMIT 5" --target-org dev-sandbox
sf org list limits --target-org dev-sandbox | grep -i DailyApiRequests
REST essentials
from simple_salesforce import Salesforce
sf = Salesforce(instance_url=instance_url, session_id=access_token, version="64.0")
# Idempotent upsert by external ID (create if absent, update if present)
sf.Account.upsert("ERP_Id__c/ACME-001", {"Name": "Acme Ltd", "Industry": "Manufacturing"})
# Describe once; cache the field list and picklist values
meta = sf.Contact.describe()
writable = {f["name"] for f in meta["fields"] if f["updateable"]}
| Task | Endpoint |
|---|---|
| Upsert by external ID | PATCH /services/data/vXX.X/sobjects/Account/ERP_Id__c/ACME-001 |
| Up to 200 records in one call | POST/PATCH /composite/sobjects with allOrNone |
| Dependent steps (create Account, then Contact referencing it) | POST /composite with referenceId and @{ref.id}, up to 25 subrequests |
| Field metadata | GET /sobjects/Contact/describe |
| Records changed in a window | GET /sobjects/Account/updated/?start=...&end=... |
| Usage | GET /limits |
External ID fields must be marked External ID (and ideally Unique) on the object. Upserting
on a non-unique external ID with duplicates returns MULTIPLE_CHOICES (300).
SOQL
def query_all(sf, soql):
res = sf.query(soql)
while True:
yield from res["records"]
if res["done"]:
return
res = sf.query_more(res["nextRecordsUrl"], identifier_is_url=True)
soql = ("SELECT Id, Name, Owner.Email, (SELECT Id, Email FROM Contacts) "
"FROM Account WHERE LastModifiedDate >= LAST_N_DAYS:7 AND Type = 'Customer' "
"ORDER BY LastModifiedDate")
for acc in query_all(sf, soql):
...
- Values from users go through escaping: single quote and backslash must be escaped
(
value.replace("\\", "\\\\").replace("'", "\\'")) or SOQL injection follows. - Date literals are unquoted (
CreatedDate > 2026-10-01T00:00:00Z,LAST_N_DAYS:30). queryAll(/queryAll) includes deleted and archived records;querydoes not.OFFSETcaps at 2,000; page withnextRecordsUrlor keyset (WHERE Id > :last ORDER BY Id).- Large objects need selective filters on indexed fields (Id, Name, external IDs, lookups,
CreatedDate,SystemModstamp). Check withGET /query?explain=<soql>: aTableScanwith high relative cost will time out at scale. - Use
SystemModstamprather thanLastModifiedDatefor incremental syncs; it also moves on system updates.
Bulk API 2.0
Use for anything beyond a few thousand rows. Flow: create job -> upload CSV -> mark
UploadComplete -> poll -> download successful, failed and unprocessed results. Full request
sequence, CSV rules and query-job paging with Sforce-Locator are in
references/bulk-api-2.md; read it before writing a Bulk job.
delete moves records to the Recycle Bin; hardDelete is permanent and needs a special
permission. Both need explicit user confirmation of the exact record set (show the count and a
sample of IDs from the query that built the CSV).
Limits and errors
- Every response carries
Sforce-Limit-Info: api-usage=1234/100000. Log it; alert above 80%. - Long-running requests (over 20 seconds) are capped at 25 concurrent in production.
- Back off on
REQUEST_LIMIT_EXCEEDED,UNABLE_TO_LOCK_ROW(retry with jitter; sort child records by parent ID in Bulk to reduce lock contention) and 503.
| Error | Meaning |
|---|---|
INVALID_SESSION_ID (401) | Token expired or revoked; re-run the auth flow |
INVALID_FIELD / No such column | Field absent, wrong API name, or hidden by field-level security for the integration user |
FIELD_CUSTOM_VALIDATION_EXCEPTION | A validation rule rejected the record; read its message |
CANNOT_INSERT_UPDATE_ACTIVATE_ENTITY | A trigger or flow failed; the message contains the Apex/flow error |
DUPLICATES_DETECTED | Duplicate rules; decide with the user whether to bypass via header or fix data |
INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST | Use picklist API values, not labels |
Change events instead of polling
Enable Change Data Capture for the objects (Setup -> Change Data Capture), then subscribe to
/data/AccountChangeEvent through the Pub/Sub API (gRPC). Store the replay ID after processing;
events are retained for 72 hours, so a consumer down longer than that must resync with SOQL.
Gotchas
- IDs are 15-char case-sensitive or 18-char case-insensitive; store and compare the 18-char form.
- API writes fire triggers, flows and assignment rules; a “simple” import can send emails to customers. Check automation on the object in the sandbox first.
- Datetimes are UTC; dates have no time zone. Do not convert dates through local time.
- Record types and required fields differ per profile; describe as the integration user.
- Multi-currency orgs need
CurrencyIsoCodeon money fields.
Verification
- Run the job against a sandbox seeded with representative data; compare counts by SOQL
(
SELECT COUNT() FROM Account WHERE ERP_Id__c != null) before and after. - For Bulk jobs,
numberRecordsFailedmust be 0 or every failure explained fromfailedResults. - Log
Sforce-Limit-Infobefore and after to report API calls consumed.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Salesforce API"
short_description: "Upsert by external ID, bulk-load without burning limits"
default_prompt: "$salesforce-api Sync our ERP customers into Salesforce Accounts nightly without creating duplicates" references/bulk-api-2.md
# Bulk API 2.0 walkthrough
Read before writing a Bulk ingest or query job. All paths are relative to
`{instance_url}/services/data/vXX.X`. Send `Authorization: Bearer <token>`.
## Ingest job (insert, update, upsert, delete, hardDelete)
### 1. Create the job
```http
POST /jobs/ingest
Content-Type: application/json
{
"object": "Contact",
"operation": "upsert",
"externalIdFieldName": "ERP_Id__c",
"contentType": "CSV",
"lineEnding": "LF",
"columnDelimiter": "COMMA"
}
```
Response gives `id` and `contentUrl` (`services/data/vXX.X/jobs/ingest/<id>/batches`).
### 2. Upload CSV data
```http
PUT /jobs/ingest/<id>/batches
Content-Type: text/csv
ERP_Id__c,FirstName,LastName,Email,Account.ERP_Id__c
C-1001,Ada,Lovelace,[email protected],ACME-001
```
- One upload per job; the limit is 150 MB measured after Salesforce's internal base64
encoding, so keep raw CSV under about 100 MB and split bigger files into several jobs.
- Header row uses field API names. Relationship by external ID: `Account.ERP_Id__c`
(or `Parent.ExtId__c` for the relationship name).
- `lineEnding` must match the file (`CRLF` for files written on Windows or by Excel).
- Blank cell means "leave unchanged" on update; to set null, write `#N/A`.
- Dates `2026-10-28`; datetimes `2026-10-28T09:30:00Z`; booleans `true`/`false`.
- UTF-8 without BOM is safest.
### 3. Close the job
```http
PATCH /jobs/ingest/<id>
Content-Type: application/json
{"state": "UploadComplete"}
```
To cancel before processing: `{"state": "Aborted"}`.
### 4. Poll
```http
GET /jobs/ingest/<id>
```
States: `Open` -> `UploadComplete` -> `InProgress` -> `JobComplete` | `Failed` | `Aborted`.
Poll with backoff (5 s, then 10 s, up to 60 s). Fields worth logging: `numberRecordsProcessed`,
`numberRecordsFailed`, `errorMessage`, `totalProcessingTime`.
### 5. Results
```http
GET /jobs/ingest/<id>/successfulResults/ # sf__Id, sf__Created + original columns
GET /jobs/ingest/<id>/failedResults/ # sf__Id, sf__Error + original columns
GET /jobs/ingest/<id>/unprocessedrecords/ # rows never attempted (job aborted or failed)
```
Save all three next to the input file. Re-submit only failed rows after fixing the cause;
upserts make the retry safe.
## Query job
```http
POST /jobs/query
Content-Type: application/json
{"operation": "query", "query": "SELECT Id, Email, SystemModstamp FROM Contact WHERE SystemModstamp >= 2026-10-01T00:00:00Z"}
```
Use `"operation": "queryAll"` to include deleted and archived records. Poll
`GET /jobs/query/<id>` until `JobComplete`, then page results:
```http
GET /jobs/query/<id>/results?maxRecords=50000
GET /jobs/query/<id>/results?locator=<Sforce-Locator>&maxRecords=50000
```
Keep requesting while the `Sforce-Locator` response header is not the string `null`. The
`Sforce-NumberOfRecords` header gives the count in each chunk.
Bulk query does not support `OFFSET`, `GROUP BY ... ROLLUP`, or aggregate functions with some
subquery shapes; for those use REST SOQL.
## Python sketch
```python
import csv, io, time, requests
def bulk_upsert(base, token, obj, ext_field, rows):
h = {"Authorization": f"Bearer {token}"}
job = requests.post(f"{base}/jobs/ingest", headers=h, json={
"object": obj, "operation": "upsert", "externalIdFieldName": ext_field,
"contentType": "CSV", "lineEnding": "LF"}, timeout=60).json()
buf = io.StringIO()
w = csv.DictWriter(buf, fieldnames=list(rows[0].keys()), lineterminator="\n")
w.writeheader(); w.writerows(rows)
requests.put(f"{base}/jobs/ingest/{job['id']}/batches", data=buf.getvalue().encode(),
headers={**h, "Content-Type": "text/csv"}, timeout=300).raise_for_status()
requests.patch(f"{base}/jobs/ingest/{job['id']}", headers=h,
json={"state": "UploadComplete"}, timeout=60).raise_for_status()
delay = 5
while True:
st = requests.get(f"{base}/jobs/ingest/{job['id']}", headers=h, timeout=60).json()
if st["state"] in ("JobComplete", "Failed", "Aborted"):
return st
time.sleep(delay); delay = min(delay * 2, 60)
```
## Locking and throughput
- Sort rows by parent ID (e.g. AccountId) so parallel chunks do not contend for the same
parent lock (`UNABLE_TO_LOCK_ROW`).
- Disable or bypass heavy automation only with the admin's agreement, typically via a custom
setting the triggers respect, never by deactivating flows in production unannounced.
- Daily Bulk limits: records processed and jobs per rolling 24 hours appear in `/limits`
(`DailyBulkV2QueryJobs`, `DailyBulkV2QueryFileStorageMB`, and ingest batch allocations).
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.