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.

  • Integrations
  • $salesforce-api
  • 171 lines
  • 3 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- salesforce-api

Installs to ~/.agents/skills/salesforce-api/ for every project on this machine.

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.

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

--- name: salesforce-api description: Integrates with Salesforce through the REST API, Composite and sObject Collections, Bulk API 2.0 and SOQL - JWT bearer and client credentials auth,… license: MIT ---

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 runUseWhy
1 to ~200, interactiveREST sObject endpointsSimple, immediate errors
Up to 200 per call, several objects or dependent stepsComposite / sObject CollectionsOne API call, optional all-or-none
Thousands to millionsBulk API 2.0 ingest or query jobsAsync, CSV, cheap on API limits
React to changes in SalesforceChange Data Capture or Platform Events via Pub/Sub APINo polling
Metadata (fields, flows, permission sets)Salesforce CLI / Metadata APIDeployable source, not data

Authentication

FlowWhen
OAuth 2.0 JWT bearer (certificate)Server-to-server, runs as a named integration user. Default choice
OAuth 2.0 client credentialsServer-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-passwordDo 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"]}
TaskEndpoint
Upsert by external IDPATCH /services/data/vXX.X/sobjects/Account/ERP_Id__c/ACME-001
Up to 200 records in one callPOST/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 metadataGET /sobjects/Contact/describe
Records changed in a windowGET /sobjects/Account/updated/?start=...&end=...
UsageGET /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; query does not.
  • OFFSET caps at 2,000; page with nextRecordsUrl or keyset (WHERE Id > :last ORDER BY Id).
  • Large objects need selective filters on indexed fields (Id, Name, external IDs, lookups, CreatedDate, SystemModstamp). Check with GET /query?explain=<soql>: a TableScan with high relative cost will time out at scale.
  • Use SystemModstamp rather than LastModifiedDate for 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.
ErrorMeaning
INVALID_SESSION_ID (401)Token expired or revoked; re-run the auth flow
INVALID_FIELD / No such columnField absent, wrong API name, or hidden by field-level security for the integration user
FIELD_CUSTOM_VALIDATION_EXCEPTIONA validation rule rejected the record; read its message
CANNOT_INSERT_UPDATE_ACTIVATE_ENTITYA trigger or flow failed; the message contains the Apex/flow error
DUPLICATES_DETECTEDDuplicate rules; decide with the user whether to bypass via header or fix data
INVALID_OR_NULL_FOR_RESTRICTED_PICKLISTUse 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 CurrencyIsoCode on 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, numberRecordsFailed must be 0 or every failure explained from failedResults.
  • Log Sforce-Limit-Info before 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.yamlgenerated from this page

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.md132 lines, 4.5 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem