[object Object]

The most expensive integration mistake we still see in 2026: a 50-million-row nightly load using single-record REST calls because “the team knows REST.” The job takes 14 hours. The right API would have taken 40 minutes.

API choice is a volume decision, not a familiarity decision.

The four options, distinguished

  • REST API — synchronous, one record per call (or up to 200 via composite). Best for low-volume, interactive use cases.
  • Composite REST API — synchronous, chains up to 25 sub-requests in one HTTP roundtrip. Great for transactional groups.
  • Bulk API 2.0 — asynchronous, ingest files of up to 150 million rows per job, parallel processing under the hood. Built for replication and ETL.
  • SOAP API — legacy, still supported, still appropriate in narrow cases (deep XML interop, partner tooling that doesn’t speak REST).

The decision tree

Run your use case through this in order:

  1. Is it sub-second user-facing? -> REST or Composite REST.
  2. Is the operation > 200 records and not user-facing? -> Bulk API 2.0.
  3. Does the operation need transactional all-or-nothing? -> Composite REST with allOrNone=true.
  4. Are you talking to a system that requires SOAP? -> SOAP, with regrets.
  5. Otherwise -> REST.

Most teams stop at step 1, assume “user-facing matters,” and use REST for everything. Then they discover that the nightly load is the bottleneck.

Cost math: the case for Bulk

Salesforce’s API call limit is per 24-hour rolling window. For an Unlimited Edition org with 2,000 users, the limit is roughly 1,000,000 + (per-user allocation) — call it ~1.5M calls/day.

A single Bulk API 2.0 job processing 5 million records counts as roughly 1 API call regardless of row count. The same 5M records via REST is 25,000 calls (composite batched). The same via uncomposite REST is 5,000,000 calls — which is well past the daily ceiling.

Bulk is not just faster. It is the only option once you cross a million rows in a day.

Bulk API 2.0 patterns

The ingest pattern is two-phase: create the job, upload the CSV, close to trigger processing. Pull status until complete.

# Create the job
sf api request rest /services/data/v62.0/jobs/ingest \
  --method POST \
  --body '{
    "object": "Account",
    "contentType": "CSV",
    "operation": "upsert",
    "externalIdFieldName": "ExternalId__c",
    "lineEnding": "LF"
  }' --target-org prod

# Upload CSV (returns jobId from previous step)
sf api request rest /services/data/v62.0/jobs/ingest/{jobId}/batches \
  --method PUT \
  --content-type "text/csv" \
  --body @accounts.csv --target-org prod

# Close to start processing
sf api request rest /services/data/v62.0/jobs/ingest/{jobId} \
  --method PATCH \
  --body '{"state": "UploadComplete"}' --target-org prod

# Poll
sf api request rest /services/data/v62.0/jobs/ingest/{jobId} \
  --target-org prod

Key flags:

  • operation: upsert with an externalIdFieldName is the workhorse. Avoids the lookup-or-create dance.
  • lineEnding: LF — match your file. Wrong line ending is the #1 cause of “all rows failed” mystery.
  • CSV must be UTF-8. Excel-saved CSVs default to platform encoding; convert before upload.

Composite REST patterns

Use when you have related-record creation in a single transaction.

{
  "allOrNone": true,
  "compositeRequest": [
    {
      "method": "POST",
      "url": "/services/data/v62.0/sobjects/Account",
      "referenceId": "newAccount",
      "body": { "Name": "Acme Corp", "Industry": "Manufacturing" }
    },
    {
      "method": "POST",
      "url": "/services/data/v62.0/sobjects/Contact",
      "referenceId": "newContact",
      "body": {
        "FirstName": "Wile",
        "LastName": "Coyote",
        "AccountId": "@{newAccount.id}"
      }
    },
    {
      "method": "POST",
      "url": "/services/data/v62.0/sobjects/Opportunity",
      "referenceId": "newOpp",
      "body": {
        "Name": "Initial Order",
        "StageName": "Prospecting",
        "CloseDate": "2026-12-31",
        "AccountId": "@{newAccount.id}"
      }
    }
  ]
}

@{reference.id} lets later requests use the IDs of earlier creates. One HTTP call, three coordinated records, atomic via allOrNone. This is the single best feature most integrations don’t know about.

When SOAP still wins

SOAP retains a small footprint in 2026:

  • Partner apps using the SOAP API’s convertLead (REST equivalent has minor edge-case gaps).
  • Workflows requiring the describe calls to return XML for downstream XSD-based tooling.
  • Long-lived sessions for legacy on-prem ESB integrations where reconfiguring auth flow is more expensive than keeping SOAP.

Outside these, SOAP is technical debt. Plan to retire.

Pub/Sub for events, not data

Don’t confuse “API” with “event stream.” If you’re moving data based on changes (replication), use Change Data Capture over Pub/Sub API, not polling REST queries. See Platform Events vs CDC vs Pub/Sub API for the event-side decision tree.

Watching API consumption

SELECT MetricsName, MetricsValue, StartDate
FROM ApiUsageMetrics
WHERE StartDate = LAST_N_DAYS:7
ORDER BY StartDate DESC, MetricsValue DESC

Watch the daily trend on ApiTotalUsage. If it’s growing 10%+ month-over-month while user count is flat, somebody is doing single-record REST when they should be batching.

UX note for integration users

If your integration writes large volumes via Bulk and an admin user can see the inbound data on a record page, surface the source job id on the record (SourceJobId__c). Admins debugging a bad row need to know which job inserted it. Without that, you’re forensics-blind.

Bottom line

  • REST for interactive, low-volume. Composite REST for transactional groups up to 25 sub-requests.
  • Bulk API 2.0 the moment you exceed 200 records in a non-interactive context — there is no excuse not to.
  • SOAP only for legacy interop or specific edge cases; treat as debt.
  • Track API consumption weekly; a growth trend without user growth means somebody is using the wrong API.
  • Tag inserted records with the source job id; integration forensics requires it.
[object Object]
Share