Skip to content

How to Handle Custom Fields and Custom Objects in Salesforce via API

Learn how to programmatically handle Salesforce custom fields (__c) and custom objects via API without writing per-customer integration code. Covers SOQL, Metadata API, and data-driven mapping.

Nachi Raman Nachi Raman · · 37 min read
How to Handle Custom Fields and Custom Objects in Salesforce via API

If you're building a B2B SaaS product, you will eventually integrate with Salesforce. And the moment you do, you will hit the wall of custom fields and custom objects.

Customer A has Industry_Vertical__c on their Account object. Customer B calls it Sector__c. Customer C has a completely custom object called Deal_Registration__c with 47 fields that don't exist anywhere else. Your integration code, which worked perfectly in your test org, breaks the moment it encounters a real enterprise deployment.

This is not an edge case. Salesforce holds a roughly 21% share of the global CRM market, and 80% of Fortune 500 companies rely on it. Nearly every one of those enterprise deployments is heavily customized. Large organizations routinely have hundreds of custom objects, complex permission structures, and strict data governance requirements. One customer might have 50 custom fields on their Account object while another has 12 entirely custom objects tracking a proprietary sales process.

If your integration only supports standard objects like Account and Contact, you are ignoring the actual data your enterprise customers care about—and failing to deliver the integrations your B2B sales team actually asks for.

This guide breaks down exactly how to interact with Salesforce's custom fields (__c) programmatically, why rigid unified APIs and code-first integration platforms fail at scale, and how to use data-driven mapping to handle infinite per-customer schema variations without writing a single line of integration-specific code.

The __c Problem: Why Salesforce API Integrations Break

Whenever a user creates a custom field in Salesforce, the system appends __c at the end of the field name. Custom objects get the same treatment. This suffix is required when writing SOQL queries or using API integrations, as it ensures that the system correctly identifies custom fields versus standard ones.

When you query a standard Contact object, you might expect a predictable JSON response:

{
  "Id": "0035e00000abc12AAA",
  "FirstName": "Jane",
  "LastName": "Doe",
  "Email": "jane@example.com"
}

But in a real enterprise environment, the response looks more like this:

{
  "Id": "0035e00000abc12AAA",
  "FirstName": "Jane",
  "LastName": "Doe",
  "Email": "jane@example.com",
  "LTV_Cohort__c": "Enterprise_Tier_1",
  "Churn_Risk_Score__c": 12.5,
  "Last_NPS_Date__c": "2025-11-04",
  "Billing_System_ID__c": "cus_987654321"
}

If your application's data model expects a strict schema, it will either drop these custom fields entirely or fail to deserialize the payload.

To make matters worse, Customer A might store their billing ID in Billing_System_ID__c, while Customer B stores it in Stripe_Customer_ID__c. Hardcoding these mappings in your application logic means you are no longer building a product — you are running a custom development agency for your users.

Changes to automation rules or custom fields in Salesforce can easily break real-time API integrations if not architected correctly. Salesforce documentation explicitly warns that long run times on custom Apex triggers or Flow automations triggered by field updates can cause timeout issues, breaking external callouts during API syncs.

Why Runtime Discovery Is Required

Before writing any code, understand this: you cannot ship a Salesforce integration with a static schema baked in. Every enterprise org is unique. What worked in your dev sandbox is almost guaranteed to break in a customer's production org.

The reason is simple math. Salesforce lets admins add up to 800 custom fields per object and 3,000 custom objects per org. Fields can be added, renamed (with the API name preserved but the display label changed), soft-deleted, hard-deleted, or restricted via field-level security. Picklist values can be deactivated. Validation rules can be introduced that reject writes your code used to succeed at.

Static-schema strategies that fail in the wild:

  • Hardcoded field lists in SOQL - break the moment a customer revokes read access on any field in the list.
  • Generated TypeScript types from your test org - describe how your test org looks, not how your customer's org looks.
  • Vendor-supplied "typed" SDKs - only cover standard fields and ignore every __c field that drives the customer's business logic.

Runtime discovery means every integrated account starts with a call to the describe endpoints. You cache the resulting schema per account, refresh it on a schedule (and on every INVALID_FIELD error), and drive SOQL construction, field validation, and mapping off the cached metadata. This is the only pattern that survives contact with real customer orgs.

Quickstart: Describe an Object via the REST API

Before you can build SOQL queries or map fields, you need to know what's actually on the object. The fastest path is a single describe call against the sObject endpoint. This is the one API call every Salesforce integration should make first.

Request:

GET /services/data/v59.0/sobjects/Account/describe/ HTTP/1.1
Host: yourInstance.my.salesforce.com
Authorization: Bearer 00D5e00000...!AQ...
Accept: application/json

Trimmed response for an org with two custom fields:

{
  "name": "Account",
  "custom": false,
  "queryable": true,
  "createable": true,
  "updateable": true,
  "deletable": true,
  "fields": [
    {
      "name": "Id",
      "label": "Account ID",
      "type": "id",
      "custom": false,
      "nillable": false,
      "createable": false,
      "updateable": false
    },
    {
      "name": "Name",
      "label": "Account Name",
      "type": "string",
      "length": 255,
      "custom": false,
      "nillable": false,
      "createable": true,
      "updateable": true
    },
    {
      "name": "Industry_Vertical__c",
      "label": "Industry Vertical",
      "type": "picklist",
      "custom": true,
      "restrictedPicklist": true,
      "picklistValues": [
        { "value": "SaaS", "label": "SaaS", "active": true, "defaultValue": false },
        { "value": "Fintech", "label": "Fintech", "active": true, "defaultValue": false },
        { "value": "Healthcare", "label": "Healthcare", "active": true, "defaultValue": false }
      ]
    },
    {
      "name": "ARR__c",
      "label": "Annual Recurring Revenue",
      "type": "currency",
      "precision": 18,
      "scale": 2,
      "custom": true,
      "filterable": true
    }
  ],
  "childRelationships": [
    {
      "childSObject": "Contact",
      "field": "AccountId",
      "relationshipName": "Contacts"
    },
    {
      "childSObject": "Invoice__c",
      "field": "Account__c",
      "relationshipName": "Invoices__r"
    }
  ]
}

Three things to notice:

  • custom: true and the __c suffix flag every user-defined field.
  • picklistValues gives you the exact set of allowed enum values - store these to render form UIs or validate writes.
  • childRelationships shows how custom objects link back to standard ones. Invoices__r is the relationship name you use in SOQL subqueries (not Invoice__c).

Listing All Objects in an Org

To enumerate every object (standard and custom) available to the connected user, call the global describe endpoint:

GET /services/data/v59.0/sobjects/ HTTP/1.1
Authorization: Bearer <access_token>

The response includes a sobjects array with a summary of each object:

{
  "encoding": "UTF-8",
  "sobjects": [
    { "name": "Account", "label": "Account", "custom": false, "queryable": true, "createable": true },
    { "name": "Contact", "label": "Contact", "custom": false, "queryable": true, "createable": true },
    { "name": "Invoice__c", "label": "Invoice", "custom": true, "queryable": true, "createable": true },
    { "name": "Deal_Registration__c", "label": "Deal Registration", "custom": true, "queryable": true, "createable": true }
  ]
}

Filter for custom: true to discover every custom object in the org. Then loop through and call the per-object describe/ endpoint on each to build a complete schema catalog.

Batching Describes with describeSObjects and Composite Batch

For orgs with dozens of custom objects, calling /sobjects/<name>/describe/ one at a time is slow and burns through API quota. Two batching options:

  • SOAP describeSObjects(): The SOAP API's describeSObjects() call accepts up to 100 sObject names in a single request and returns their describes together. It's the SOAP counterpart to per-object REST describe and is useful during bulk onboarding.
  • REST Composite Batch: Batch up to 25 REST subrequests into a single HTTP call:
POST /services/data/v59.0/composite/batch/ HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "batchRequests": [
    { "method": "GET", "url": "v59.0/sobjects/Account/describe/" },
    { "method": "GET", "url": "v59.0/sobjects/Contact/describe/" },
    { "method": "GET", "url": "v59.0/sobjects/Invoice__c/describe/" },
    { "method": "GET", "url": "v59.0/sobjects/Deal_Registration__c/describe/" }
  ]
}

The response returns each sub-result under a results array with its own status code. Describe payloads can be 500KB+ per object, so cache aggressively; the schema rarely changes hour to hour.

Standard Objects vs. Custom Objects in the Salesforce API

Before you can map custom data, you need to understand how to extract it.

Standard objects are the ones Salesforce ships out of the box: Account, Contact, Lead, Opportunity, Case. They have predictable API names and well-documented fields.

Custom objects are created by admins or developers to model business-specific data. They always end in __c — for example, Invoice__c, Product_Configuration__c, or Warranty_Claim__c.

The API mechanics are identical for both — that's the good news. The bad news is that you can't know the schema ahead of time. The big numbers to keep in your head: 3,000 total custom objects per org, a ceiling of 800 custom fields on any single object, and a firm cap of 40 total relationships per object. Enterprise orgs routinely push these limits.

REST API vs. SOQL

If you know the exact API name of the custom object, you can hit the standard sObject REST endpoints:

# Standard object
GET /services/data/v59.0/sobjects/Contact/003XXXXXXXXXXXX
 
# Custom object
GET /services/data/v59.0/sobjects/Invoice__c/a01XXXXXXXXXXXX

This works for simple CRUD operations on a single record. But it falls apart when you need to query lists of records based on custom criteria, or when you need to traverse relationships.

For enterprise integrations, SOQL is mandatory. SOQL allows you to specify exactly which standard and custom fields you want to return, and it supports relationship queries using the __r suffix:

SELECT Id, Amount__c, Status__c, Account__r.Name, Account__r.ARR__c
FROM Invoice__c
WHERE Status__c = 'Unpaid'

Executing this via the API requires a URL-encoded GET request:

GET /services/data/v59.0/query/?q=SELECT+Id,+Amount__c...+FROM+Invoice__c

Building these SOQL queries dynamically based on what custom fields a specific customer has configured is the hardest part of integrating Salesforce. You have to inspect the customer's schema, validate the field names, construct the SOQL string, and handle pagination via the nextRecordsUrl pointer.

Warning

Watch out for API Limits: Salesforce enforces strict resource constraints at every level. Synchronous Apex transactions are limited to 100 SOQL queries and 50,000 retrieved records. API call limits start at 100,000 requests per 24-hour period for paid editions. Inefficient queries or aggressive polling for custom object changes will burn through a customer's daily quota, causing the integration to shut down entirely until the next rolling window.

Discovering Custom Fields with the sObject Describe API

The single most important API call for handling custom fields is describe. The sObject Describe resource retrieves all the metadata for an object, including information about each field, URLs, and child relationships.

GET /services/data/v59.0/sobjects/Contact/describe/
Authorization: Bearer <access_token>

The response includes every field on the object — standard and custom — with metadata like type, label, length, and whether the field is required:

{
  "fields": [
    {
      "name": "FirstName",
      "label": "First Name",
      "type": "string",
      "custom": false
    },
    {
      "name": "NPS_Score__c",
      "label": "NPS Score",
      "type": "double",
      "custom": true
    },
    {
      "name": "Preferred_Language__c",
      "label": "Preferred Language",
      "type": "picklist",
      "custom": true
    }
  ]
}

You can filter for custom fields by checking the custom: true flag, or by pattern-matching on the __c suffix. This describe call should be your first step when connecting any new Salesforce integrated account — run it once, cache the schema, and use it to drive your field mapping logic.

Call describe when a customer first connects their Salesforce account to your app. Store the schema locally and use it to present a field-mapping UI or to auto-configure your data pipeline. Re-fetch it periodically to catch schema changes.

Validating Field Names Before Building SOQL

A describe call returns every field the connected user can access. Before constructing a SOQL query, you need to validate your requested fields against this cached schema. SOQL does not support SELECT * - you must explicitly name every field, and referencing a field that doesn't exist (or that the user can't access) returns an INVALID_FIELD error.

Here's a practical pattern in Python:

import requests
 
def get_field_metadata(instance_url, access_token, object_name):
    """Fetch field metadata via sObject Describe and return a lookup dict."""
    url = f"{instance_url}/services/data/v59.0/sobjects/{object_name}/describe/"
    headers = {"Authorization": f"Bearer {access_token}"}
    resp = requests.get(url, headers=headers)
    resp.raise_for_status()
 
    fields = resp.json()["fields"]
    return {
        f["name"]: {
            "type": f["type"],
            "custom": f["custom"],
            "filterable": f["filterable"],
            "createable": f["createable"],
            "updateable": f["updateable"],
            "nillable": f["nillable"],
            "permissionable": f.get("permissionable", True),
        }
        for f in fields
        if not f.get("deprecatedAndHidden", False)
    }
 
 
def validate_fields(requested_fields, field_metadata):
    """Return only the fields that exist and are queryable. Log any that were skipped."""
    safe = []
    skipped = []
    for field in requested_fields:
        if field in field_metadata:
            safe.append(field)
        else:
            skipped.append(field)
    if skipped:
        print(f"Warning: skipping unknown or inaccessible fields: {skipped}")
    return safe

The deprecatedAndHidden flag catches fields that Salesforce has marked as deprecated. Including them in a query may work today but will break in a future API version - filter them out proactively. The filterable property is equally important: if you need a field in a WHERE clause, confirm filterable is true first, or Salesforce will reject the query.

Building Dynamic SOQL Safely

Once you've validated the field list, you need to assemble the SOQL string and send it to the REST query endpoint. The query string must be URL-encoded in the q parameter. The safest approach is to let your HTTP library handle the encoding rather than manually constructing + and %XX sequences:

def build_soql(object_name, fields, where_clause=None, limit=None):
    """Build a SOQL query string from validated field names."""
    field_list = ", ".join(fields)
    soql = f"SELECT {field_list} FROM {object_name}"
    if where_clause:
        soql += f" WHERE {where_clause}"
    if limit:
        soql += f" LIMIT {limit}"
    return soql
 
 
def execute_soql(instance_url, access_token, soql):
    """Execute a SOQL query via the REST API with proper URL encoding."""
    url = f"{instance_url}/services/data/v59.0/query/"
    headers = {"Authorization": f"Bearer {access_token}"}
    # Pass raw SOQL as a param — requests handles URL-encoding
    resp = requests.get(url, headers=headers, params={"q": soql})
 
    if resp.status_code == 400:
        error = resp.json()[0]
        if error.get("errorCode") == "INVALID_FIELD":
            raise ValueError(f"Invalid field in query: {error['message']}")
        if error.get("errorCode") == "MALFORMED_QUERY":
            raise ValueError(f"SOQL syntax error: {error['message']}")
    resp.raise_for_status()
    return resp.json()

The resulting HTTP request looks like:

GET /services/data/v59.0/query/?q=SELECT+Id%2C+FirstName%2C+NPS_Score__c+FROM+Contact+WHERE+NPS_Score__c+%3E+8 HTTP/1.1
Authorization: Bearer <access_token>
Warning

Never interpolate user input directly into SOQL strings. If you accept filter values from end users, always sanitize them aggressively. SOQL injection is a real attack vector - a malicious value in a WHERE clause can exfiltrate records the user should not see. Treat SOQL construction with the same paranoia you'd apply to SQL.

Reading Custom Fields: SOQL with __c and __r Traversal

Custom lookup and master-detail relationships use a __r suffix in SOQL. There are three query shapes you need in your toolkit:

1. Parent traversal - pull fields from the parent record while querying a child custom object:

SELECT
  Id,
  Name,
  Amount__c,
  Status__c,
  Account__r.Name,
  Account__r.Industry_Vertical__c,
  Account__r.Owner.Email
FROM Invoice__c
WHERE Account__r.ARR__c > 100000
  AND Status__c = 'Unpaid'
ORDER BY Due_Date__c ASC
LIMIT 200

URL-encoded GET:

GET /services/data/v59.0/query/?q=SELECT+Id%2C+Name%2C+Amount__c%2C+Account__r.Name+FROM+Invoice__c+WHERE+Status__c+%3D+'Unpaid'+LIMIT+200 HTTP/1.1
Authorization: Bearer <access_token>
Accept: application/json

The response nests the parent inline:

{
  "totalSize": 1,
  "done": true,
  "records": [
    {
      "attributes": {
        "type": "Invoice__c",
        "url": "/services/data/v59.0/sobjects/Invoice__c/a015e00000abc12AAA"
      },
      "Id": "a015e00000abc12AAA",
      "Name": "INV-000123",
      "Amount__c": 12500.00,
      "Status__c": "Unpaid",
      "Account__r": {
        "attributes": {
          "type": "Account",
          "url": "/services/data/v59.0/sobjects/Account/0015e00000xyz98AAA"
        },
        "Name": "Acme Corp",
        "Industry_Vertical__c": "SaaS"
      }
    }
  ]
}

2. Child subquery - fetch related child records inline. Note the plural, Invoices__r, which comes from the childRelationships [].relationshipName in the describe response:

SELECT
  Id,
  Name,
  Industry_Vertical__c,
  (SELECT Id, Amount__c, Status__c FROM Invoices__r WHERE Status__c = 'Unpaid')
FROM Account
WHERE Industry_Vertical__c = 'SaaS'

The response embeds a paginated child block:

{
  "records": [
    {
      "Id": "0015e00000xyz98AAA",
      "Name": "Acme Corp",
      "Industry_Vertical__c": "SaaS",
      "Invoices__r": {
        "totalSize": 2,
        "done": true,
        "records": [
          { "Id": "a015e00000abc12AAA", "Amount__c": 12500.00, "Status__c": "Unpaid" },
          { "Id": "a015e00000abc13AAA", "Amount__c": 3000.00, "Status__c": "Unpaid" }
        ]
      }
    }
  ]
}

3. Multi-level parent traversal - Salesforce allows up to five levels of parent-relationship dot notation in a single query:

SELECT Id, Contact__r.Account__r.Owner.Manager.Email
FROM Support_Ticket__c
LIMIT 50

When your mapping layer sees an __r key in the response, treat it as a nested object and flatten it into your unified schema. JSONata handles this natively with dot notation:

{
  "invoice_id": Id,
  "amount": Amount__c,
  "account_name": Account__r.Name,
  "account_industry": Account__r.Industry_Vertical__c
}

Handling Pagination with nextRecordsUrl

Salesforce returns a maximum of 2,000 records per SOQL query response by default. For any non-trivial dataset, you need to handle cursor-based pagination. The response includes a done boolean and, when there are more records, a nextRecordsUrl pointer that you follow to fetch the next page.

A typical first-page response:

{
  "totalSize": 4750,
  "done": false,
  "nextRecordsUrl": "/services/data/v59.0/query/01gRM00000EbcW1YAJ-2000",
  "records": [
    { "Id": "003xx000004TmiUAAS", "FirstName": "Jane", "NPS_Score__c": 9.2 },
    { "Id": "003xx000004TmiVAAS", "FirstName": "Carlos", "NPS_Score__c": 7.8 }
  ]
}

To collect all records, follow the nextRecordsUrl until done is true:

def fetch_all_records(instance_url, access_token, soql):
    """Execute a SOQL query and paginate through all results."""
    all_records = []
    result = execute_soql(instance_url, access_token, soql)
    all_records.extend(result["records"])
 
    while not result["done"]:
        next_url = f"{instance_url}{result['nextRecordsUrl']}"
        headers = {"Authorization": f"Bearer {access_token}"}
        resp = requests.get(next_url, headers=headers)
        resp.raise_for_status()
        result = resp.json()
        all_records.extend(result["records"])
 
    return all_records

Two things to watch for. First, each page request counts against the org's daily API call limit - a query returning 10,000 records consumes 5 API calls just for pagination. Second, the nextRecordsUrl cursor expires after 15 minutes in most editions. If your processing takes longer than that between page fetches, you'll need to re-run the query from scratch.

Tip

Don't use SOQL's OFFSET clause as a pagination strategy for large datasets. OFFSET is capped at 2,000 rows and gets progressively slower. The nextRecordsUrl cursor is the only reliable way to paginate through large result sets via the REST API.

Field-Level Security and Graceful Degradation

When you call the describe endpoint using an OAuth token, Salesforce filters the response based on the connected user's field-level security (FLS) settings. If a custom field is hidden from the API user's profile, it won't appear in the fields array at all. This means your cached schema from describe is already your source of truth for what the connected user can read.

But things get tricky in practice:

Write operations need extra checks. A field appearing in describe means the user can read it, but not necessarily write to it. Always check the createable property before inserts and updateable before updates. Attempting to write to a read-only field returns a FIELD_NOT_UPDATEABLE error.

Cached schemas go stale. A Salesforce admin can change field-level security at any time. If you cached the schema on Monday and a field's read permission is revoked on Tuesday, your SOQL query referencing that field will return an INVALID_FIELD error on Wednesday. Schedule periodic describe refreshes - once every 24 hours is a reasonable default, with an on-demand refresh when you encounter an unexpected error.

Fields can be deleted entirely. If a Salesforce admin deletes a custom field, any SOQL query referencing it fails. Your error handling should catch this and automatically remove the stale field from your cached schema:

def safe_query_with_fallback(instance_url, access_token, object_name, fields, where=None):
    """Query with automatic retry if a field has been deleted or revoked."""
    soql = build_soql(object_name, fields, where)
    try:
        return fetch_all_records(instance_url, access_token, soql)
    except ValueError as e:
        if "INVALID_FIELD" in str(e):
            # Re-fetch the schema and retry with only valid fields
            valid = get_field_metadata(instance_url, access_token, object_name)
            safe_fields = [f for f in fields if f in valid]
            if not safe_fields:
                raise
            soql = build_soql(object_name, safe_fields, where)
            return fetch_all_records(instance_url, access_token, soql)
        raise

This pattern gives you automatic recovery: if a field disappears, the integration retries with a reduced field list rather than failing outright. Log the dropped fields so your team can investigate and update any dependent mappings.

Tip

The describe response includes a permissionable flag on each field. When permissionable is true, the field's visibility can be controlled via profiles and permission sets - meaning it could disappear from a user's view at any time. Fields where permissionable is false (like Id and Name) are always visible and safe to hardcode in your queries.

CRUD Operations on Custom Objects via REST API

Reading data is only half the job. Once you've mapped a customer's custom object, you'll need to create, update, and delete records against it. The sObject REST endpoints handle all four operations with the same URL pattern - only the HTTP method and payload change.

Create: POST /sobjects/{ObjectName}/

POST /services/data/v59.0/sobjects/Invoice__c/ HTTP/1.1
Host: yourInstance.my.salesforce.com
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "Name": "INV-000456",
  "Account__c": "0015e00000xyz98AAA",
  "Amount__c": 8750.00,
  "Status__c": "Unpaid",
  "Due_Date__c": "2026-01-31",
  "Notes__c": "Net-30 terms, PO required."
}

A successful create returns HTTP 201 Created with the new record's Id:

{
  "id": "a015e00000def34AAA",
  "success": true,
  "errors": []
}

Two things to check before you POST:

  • Every field in the payload must have createable: true in the describe metadata. System-managed fields like Id, CreatedDate, and LastModifiedDate will reject writes with FIELD_NOT_UPDATEABLE.
  • Relationship fields take the parent's Id, not the __r object. Use "Account__c": "0015...", not "Account__r": { ... }.

Read: GET /sobjects/{ObjectName}/{Id}

Fetch a single record by Id. Use the optional ?fields= parameter to limit the returned columns:

GET /services/data/v59.0/sobjects/Invoice__c/a015e00000def34AAA?fields=Id,Name,Amount__c,Status__c HTTP/1.1
Authorization: Bearer <access_token>
{
  "attributes": {
    "type": "Invoice__c",
    "url": "/services/data/v59.0/sobjects/Invoice__c/a015e00000def34AAA"
  },
  "Id": "a015e00000def34AAA",
  "Name": "INV-000456",
  "Amount__c": 8750.00,
  "Status__c": "Unpaid"
}

Update: PATCH /sobjects/{ObjectName}/{Id}

Partial updates use HTTP PATCH. Only include the fields you want to change:

PATCH /services/data/v59.0/sobjects/Invoice__c/a015e00000def34AAA HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "Status__c": "Paid",
  "Paid_Date__c": "2026-01-15"
}

A successful update returns HTTP 204 No Content with an empty body. Confirm updateable: true in the describe metadata for every field you include in the payload.

Upsert: PATCH /sobjects/{ObjectName}/{ExternalIdField}/{ExternalId}

For idempotent writes keyed on a customer's own ID (a common pattern when syncing from your app to Salesforce), define an external ID field on the custom object and use the upsert endpoint:

PATCH /services/data/v59.0/sobjects/Invoice__c/External_ID__c/inv_abc_123 HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "Name": "INV-000456",
  "Account__c": "0015e00000xyz98AAA",
  "Amount__c": 8750.00,
  "Status__c": "Unpaid"
}

Salesforce creates the record if no match exists, or updates it if a match is found. The response tells you which happened:

{
  "id": "a015e00000def34AAA",
  "success": true,
  "errors": [],
  "created": true
}

Upsert is the safest way to avoid duplicate records when your app might retry a failed write.

Delete: DELETE /sobjects/{ObjectName}/{Id}

DELETE /services/data/v59.0/sobjects/Invoice__c/a015e00000def34AAA HTTP/1.1
Authorization: Bearer <access_token>

Returns HTTP 204 No Content on success. Deleted records move to the Recycle Bin for 15 days by default, so a mistaken delete can usually be recovered - but do not rely on that for production logic.

Bulk Writes: Composite and Collections Endpoints

Doing 500 individual POSTs will burn through API quota fast. For bulk writes, use the sObject Collections endpoint (up to 200 records per call):

POST /services/data/v59.0/composite/sobjects/ HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "allOrNone": false,
  "records": [
    {
      "attributes": { "type": "Invoice__c" },
      "Name": "INV-A1",
      "Account__c": "0015e00000xyz98AAA",
      "Amount__c": 1000.00
    },
    {
      "attributes": { "type": "Invoice__c" },
      "Name": "INV-A2",
      "Account__c": "0015e00000xyz98AAA",
      "Amount__c": 2000.00
    }
  ]
}

The response is a parallel array of per-record success/error results. When allOrNone is false, partial success is allowed and you must inspect each entry.

For anything beyond a few thousand records, switch to Bulk API 2.0, which is designed for CSV-based ingestion and does not count each record against the daily REST API limit.

Handling Picklists and Field Type Conversions

Salesforce field types don't map cleanly onto JSON. A double from Salesforce might round-trip through JavaScript as a slightly different value. A date is a string, but a datetime is a different string. Picklists look like strings but have hidden validation rules. Getting these conversions wrong causes silent data corruption.

Field Type to JSON Mapping

Salesforce Type JSON Type Notes
string, textarea, phone, email, url string Length caps enforced server-side
int integer 32-bit signed
double, currency, percent number Prefer strings if precision matters
boolean boolean
date string YYYY-MM-DD
datetime string ISO 8601 UTC, e.g. 2026-01-15T10:30:00.000+0000
reference string 15 or 18 character record Id
picklist string Must match an active picklistValues [].value
multipicklist string Semicolon-separated values, e.g. "SaaS;Fintech"
id string Read-only
base64 string Base64-encoded binary, used for attachments

Reading and Writing Picklists

Picklists are strings on the wire but strictly validated on write. The describe response gives you the allowed values:

{
  "name": "Status__c",
  "type": "picklist",
  "restrictedPicklist": true,
  "picklistValues": [
    { "value": "Draft", "label": "Draft", "active": true, "defaultValue": true },
    { "value": "Sent", "label": "Sent", "active": true, "defaultValue": false },
    { "value": "Paid", "label": "Paid", "active": true, "defaultValue": false },
    { "value": "Void", "label": "Void", "active": false, "defaultValue": false }
  ]
}

Two behaviors to watch for:

  • If restrictedPicklist is true, writing any value not in the active picklistValues returns INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST. Non-restricted picklists silently accept arbitrary strings, which can pollute the customer's reporting.
  • Multipicklists concatenate values with a semicolon. To write both SaaS and Fintech, send "Industries__c": "SaaS;Fintech" - not a JSON array.

Cache the picklist values in your field metadata store so your app can render dropdowns and validate before hitting the API.

Dependent Picklists

When a picklist's available options depend on another picklist's value (e.g., State depends on Country), the describe response includes controllerName on the dependent field. Full valid-value combinations are exposed via the UI API:

GET /services/data/v59.0/ui-api/object-info/Account/picklist-values/{RecordTypeId}/State__c HTTP/1.1
Authorization: Bearer <access_token>

Fetch this once per record type and cache it - the mapping is stable until an admin changes it.

Date and Datetime Gotchas

  • Salesforce accepts both 2026-01-15T10:30:00Z and 2026-01-15T10:30:00.000+0000 for datetimes. It always returns the second form. Normalize on write.
  • Date-only fields ignore any time component you send. A payload of "Due_Date__c": "2026-01-15T23:00:00Z" stores as 2026-01-15 regardless of timezone.
  • Timezone conversion is done relative to the org's default time zone, not the API user's. If your app is UTC-first, always send UTC and let Salesforce translate.

Numbers, Currency, and Precision

Currency and decimal fields have a fixed precision and scale defined in describe. ARR__c with precision: 18, scale: 2 accepts up to 16 integer digits and 2 fractional digits. Sending more decimals silently truncates. If you're moving money, pass values as strings from a decimal library rather than trusting JavaScript's number type.

Reference Fields (15 vs 18 Character IDs)

Salesforce record IDs come in two forms: 15 characters (case-sensitive) and 18 characters (case-insensitive). The REST API accepts either on write, but always returns the 18-character form. Store the 18-character version to avoid case-sensitivity bugs when comparing IDs across systems.

Common Error Responses and Troubleshooting

Salesforce returns errors as an array of objects with errorCode and message fields:

[
  {
    "message": "No such column 'NPS_Scor__c' on entity 'Contact'. If you are attempting to use a custom field, be sure to append the '__c' after the custom field name.",
    "errorCode": "INVALID_FIELD"
  }
]

Good error handling means recognizing the common codes and reacting appropriately.

Error Code HTTP Status Cause Recovery
INVALID_FIELD 400 Field name misspelled, doesn't exist, or user lacks FLS read access Re-run describe, drop unknown fields, retry
MALFORMED_QUERY 400 SOQL syntax error Log the exact query, do not retry blindly
INVALID_TYPE 400 Object name doesn't exist or user has no access Verify object exists in this org's /sobjects/ list
REQUIRED_FIELD_MISSING 400 POST omitted a field where nillable: false and no default Check describe, include the field
FIELD_CUSTOM_VALIDATION_EXCEPTION 400 A validation rule on the object rejected the write Surface the message to the user - it comes from an admin-defined rule
INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST 400 Value not in the active picklist set Refresh picklist metadata, map to a valid value
FIELD_NOT_UPDATEABLE 400 Writing to a read-only or system field Check updateable before including in PATCH
INSUFFICIENT_ACCESS_OR_READONLY 403 Profile or sharing rules block the operation Requires admin action on Salesforce side
INVALID_SESSION_ID 401 Access token expired or revoked Refresh the OAuth token, retry
REQUEST_LIMIT_EXCEEDED 403 Daily API call limit hit Back off, alert the customer, consider Bulk API
UNABLE_TO_LOCK_ROW 400 Concurrent write on the same record Exponential backoff and retry
ENTITY_IS_DELETED 400 Record was deleted between read and write Remove from queue or resurrect from Recycle Bin
DUPLICATE_VALUE 400 External ID collision on upsert Query the existing record and merge
NOT_FOUND 404 Record Id doesn't exist or was permanently deleted Confirm Id, check Recycle Bin

Rate limits deserve special attention. Salesforce emits a Sforce-Limit-Info header on every response:

Sforce-Limit-Info: api-usage=42311/100000

Log this on every call. When usage crosses 80%, throttle non-critical requests and switch bulk operations to Bulk API 2.0. Waiting for a hard REQUEST_LIMIT_EXCEEDED means your integration is already broken for that customer for the rest of the day.

A Defensive Wrapper

class SalesforceError(Exception): pass
class InvalidField(SalesforceError): pass
class TokenExpired(SalesforceError): pass
class RateLimited(SalesforceError): pass
class RetryableError(SalesforceError): pass
 
 
def salesforce_request(method, url, access_token, **kwargs):
    """Wrap Salesforce API calls with structured error handling."""
    headers = kwargs.pop("headers", {})
    headers["Authorization"] = f"Bearer {access_token}"
    resp = requests.request(method, url, headers=headers, **kwargs)
 
    # Track API usage from every response
    limit_header = resp.headers.get("Sforce-Limit-Info", "")
    # e.g. "api-usage=42311/100000" — parse and emit as a metric
 
    if resp.status_code == 401:
        raise TokenExpired("Refresh the OAuth token and retry.")
    if resp.status_code == 204:
        return None
    if 200 <= resp.status_code < 300:
        return resp.json() if resp.content else None
 
    # Salesforce returns errors as a list
    try:
        error = resp.json()[0]
    except (ValueError, IndexError, KeyError):
        resp.raise_for_status()
 
    code = error.get("errorCode")
    message = error.get("message", "")
 
    if code == "INVALID_FIELD":
        raise InvalidField(message)
    if code == "REQUEST_LIMIT_EXCEEDED":
        raise RateLimited(message)
    if code in ("UNABLE_TO_LOCK_ROW", "CONCURRENT_REQUESTS_LIMIT_EXCEEDED"):
        raise RetryableError(message)
    raise SalesforceError(f"{code}: {message}")

Wrap every Salesforce call through a function like this. Consistent error taxonomy is the difference between a support ticket that reads "sync failed" and one that reads "customer's admin removed FLS for NPS_Score__c on the Sales Manager profile."

End-to-End Code: Discover, Map, Read, and Write

The helper functions above (get_field_metadata, build_soql, fetch_all_records, salesforce_request) are the building blocks. Here's how they fit together into a tenant-onboarding flow that discovers a customer's schema, produces a per-tenant mapping, and drives both reads and writes off that mapping.

Step 1: Discover the Schema

On first connect, describe every relevant object and persist the result under the integrated account's ID. Do this once per object at connect time, then refresh on a schedule.

def onboard_tenant_schema(instance_url, access_token, object_name):
    """
    First-connect flow: discover an org's schema and produce a tenant-specific
    mapping used for both reads and writes.
    """
    metadata = get_field_metadata(instance_url, access_token, object_name)
 
    standard = {name for name, m in metadata.items() if not m["custom"]}
    custom = {name for name, m in metadata.items() if m["custom"]}
 
    # Canonical field names your app knows about
    canonical_map = {
        "Id": "id",
        "FirstName": "first_name",
        "LastName": "last_name",
        "Email": "email",
    }
 
    tenant_mapping = {
        "object": object_name,
        "readable_fields": sorted(standard | custom),
        "canonical_mapping": {sf: canonical_map[sf] for sf in canonical_map if sf in standard},
        "custom_field_map": {name: name for name in sorted(custom)},
        "writeable_fields": sorted(n for n, m in metadata.items() if m["updateable"]),
        "createable_fields": sorted(n for n, m in metadata.items() if m["createable"]),
    }
    return tenant_mapping

Persist tenant_mapping in your config store, keyed by integrated_account_id and object name. This is now the source of truth for how that tenant's Salesforce data maps to your unified schema.

Step 2: Read Records Using the Mapping

def read_records_for_tenant(instance_url, access_token, tenant_mapping, where=None):
    """Use the tenant's cached mapping to build a SOQL query and normalize the response."""
    fields = tenant_mapping["readable_fields"]
    soql = build_soql(tenant_mapping["object"], fields, where, limit=200)
    raw = fetch_all_records(instance_url, access_token, soql)
 
    normalized = []
    for row in raw:
        record = {"custom_fields": {}}
        # Standard field translation
        for sf_name, canon_name in tenant_mapping["canonical_mapping"].items():
            record[canon_name] = row.get(sf_name)
        # Every __c field goes into the custom_fields bag, keyed by its API name
        for cf_name in tenant_mapping["custom_field_map"]:
            value = row.get(cf_name)
            if value is not None:
                record["custom_fields"][cf_name] = value
        normalized.append(record)
    return normalized

Usage:

mapping = onboard_tenant_schema(instance_url, token, "Contact")
contacts = read_records_for_tenant(
    instance_url, token, mapping,
    where="LastModifiedDate > LAST_N_DAYS:1"
)
# contacts[0] -> {
#   "id": "003xx000004TmiUAAS",
#   "first_name": "Jane",
#   "last_name": "Doe",
#   "email": "jane@acme.com",
#   "custom_fields": {"NPS_Score__c": 9.2, "LTV_Cohort__c": "Enterprise_Tier_1"}
# }

Every custom field the connected user can read is captured automatically. When the admin adds a new __c field, the next describe refresh picks it up and it starts appearing in custom_fields on the next read - no code deploy required.

Step 3: Upsert Records with Custom Fields

Writes reverse the mapping. Anything under custom_fields in the unified payload passes through by its Salesforce API name, filtered against createable_fields / writeable_fields to prevent FIELD_NOT_UPDATEABLE errors.

def write_record_for_tenant(instance_url, access_token, tenant_mapping,
                            unified_payload, external_id_field=None, external_id_value=None):
    """
    Translate a unified payload into Salesforce field names using the tenant mapping,
    then upsert (if external ID provided) or insert.
    """
    reverse_canonical = {v: k for k, v in tenant_mapping["canonical_mapping"].items()}
    is_upsert = external_id_field and external_id_value
    writeable = set(tenant_mapping["writeable_fields"] if is_upsert
                    else tenant_mapping["createable_fields"])
 
    body = {}
    for key, value in unified_payload.items():
        if key == "custom_fields":
            for cf_name, cf_value in value.items():
                if cf_name in writeable:
                    body[cf_name] = cf_value
            continue
        sf_name = reverse_canonical.get(key)
        if sf_name and sf_name in writeable:
            body[sf_name] = value
 
    obj = tenant_mapping["object"]
    if is_upsert:
        url = f"{instance_url}/services/data/v59.0/sobjects/{obj}/{external_id_field}/{external_id_value}"
        return salesforce_request("PATCH", url, access_token, json=body)
    url = f"{instance_url}/services/data/v59.0/sobjects/{obj}/"
    return salesforce_request("POST", url, access_token, json=body)

Usage:

unified_contact = {
    "first_name": "Jane",
    "last_name": "Doe",
    "email": "jane@acme.com",
    "custom_fields": {
        "NPS_Score__c": 9.2,
        "Preferred_Language__c": "English",
        "LTV_Cohort__c": "Enterprise_Tier_1",
    }
}
 
result = write_record_for_tenant(
    instance_url, token, mapping, unified_contact,
    external_id_field="External_ID__c",
    external_id_value="ext_jane_001"
)
# -> { "id": "003xx000004TmiUAAS", "success": true, "created": true, ... }

The write path defensively drops anything the connected user can't touch. If Customer A's admin locks down LTV_Cohort__c next week, the field is silently omitted from the payload rather than blowing up the sync.

The Same Pattern in Node.js

For JavaScript-first teams, the pattern is identical:

async function onboardTenantSchema(instanceUrl, accessToken, objectName) {
  const res = await fetch(
    `${instanceUrl}/services/data/v59.0/sobjects/${objectName}/describe/`,
    { headers: { Authorization: `Bearer ${accessToken}` } }
  );
  const { fields } = await res.json();
 
  const canonical = { Id: 'id', FirstName: 'first_name', LastName: 'last_name', Email: 'email' };
  const readable = fields.filter(f => !f.deprecatedAndHidden).map(f => f.name);
  const custom = fields.filter(f => f.custom).map(f => f.name);
  const writeable = fields.filter(f => f.updateable).map(f => f.name);
 
  return {
    object: objectName,
    readableFields: readable,
    canonicalMapping: Object.fromEntries(
      Object.entries(canonical).filter(([sf]) => readable.includes(sf))
    ),
    customFieldMap: Object.fromEntries(custom.map(n => [n, n])),
    writeableFields: writeable,
  };
}
 
async function readForTenant(instanceUrl, accessToken, mapping, where) {
  const soql = `SELECT ${mapping.readableFields.join(', ')} FROM ${mapping.object}`
    + (where ? ` WHERE ${where}` : '') + ' LIMIT 200';
  const url = `${instanceUrl}/services/data/v59.0/query/?q=${encodeURIComponent(soql)}`;
  const res = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` } });
  const { records } = await res.json();
 
  return records.map(row => {
    const record = { custom_fields: {} };
    for (const [sf, canon] of Object.entries(mapping.canonicalMapping)) {
      record[canon] = row[sf];
    }
    for (const cf of Object.keys(mapping.customFieldMap)) {
      if (row[cf] != null) record.custom_fields[cf] = row[cf];
    }
    return record;
  });
}

The language changes; the architecture doesn't. Discovery → mapping → read/write, driven entirely by data pulled from describe.

Using the Metadata API for Schema Discovery and Management

The sObject describe endpoint tells you what fields exist right now. The Metadata API and its REST cousin, the Tooling API, tell you how the schema was configured - and let you create, modify, or delete objects and fields programmatically. You'll reach for these surfaces in three scenarios: bulk schema discovery across many objects, deploying schema as part of a managed package, and inspecting configuration that describe doesn't expose (validation rules, workflow rules, field-level security assignments per profile).

Tooling API: REST-Flavored Schema Queries

The Tooling API exposes metadata as queryable objects over REST. It's the fastest way to enumerate custom objects and fields without writing SOAP envelopes.

List every custom object in an org:

GET /services/data/v59.0/tooling/query/?q=SELECT+Id,+DeveloperName,+NamespacePrefix+FROM+CustomObject HTTP/1.1
Authorization: Bearer <access_token>

Fetch every field definition on a specific custom object with type, length, and required-ness:

GET /services/data/v59.0/tooling/query/?q=SELECT+QualifiedApiName,+DataType,+Length,+IsNillable,+IsCustom+FROM+FieldDefinition+WHERE+EntityDefinition.QualifiedApiName+%3D+'Invoice__c' HTTP/1.1
Authorization: Bearer <access_token>

Response:

{
  "size": 4,
  "records": [
    { "QualifiedApiName": "Name",       "DataType": "Auto Number",     "Length": 80,   "IsNillable": false, "IsCustom": false },
    { "QualifiedApiName": "Account__c", "DataType": "Lookup(Account)", "Length": 18,   "IsNillable": true,  "IsCustom": true },
    { "QualifiedApiName": "Amount__c",  "DataType": "Currency(18, 2)", "Length": null, "IsNillable": true,  "IsCustom": true },
    { "QualifiedApiName": "Status__c",  "DataType": "Picklist",        "Length": 255,  "IsNillable": true,  "IsCustom": true }
  ]
}

Read validation rules that could reject your writes:

GET /services/data/v59.0/tooling/query/?q=SELECT+ValidationName,+Active,+ErrorMessage+FROM+ValidationRule+WHERE+EntityDefinition.QualifiedApiName+%3D+'Invoice__c' HTTP/1.1

Knowing which validation rules are active lets you surface admin-defined error messages to end users instead of a raw stack trace.

Metadata API (SOAP): Deploying Schema

If your product genuinely needs to deploy custom objects or fields into a customer's org (managed packages, verticalized templates, installer flows), the SOAP-based Metadata API is the supported path. The pattern is:

  1. Package the changes as XML files inside a zip (objects/Invoice__c.object-meta.xml, objects/Invoice__c/fields/Amount__c.field-meta.xml, etc.).
  2. Base64-encode the zip.
  3. Call deploy() with the encoded zip and deployment options.
  4. Poll checkDeployStatus() until the async job completes.

A field definition XML looks like:

<?xml version="1.0" encoding="UTF-8"?>
<CustomField xmlns="http://soap.sforce.com/2006/04/metadata">
  <fullName>Priority_Score__c</fullName>
  <label>Priority Score</label>
  <type>Number</type>
  <precision>5</precision>
  <scale>2</scale>
  <required>false</required>
</CustomField>

The same operation via the Tooling API's REST endpoint (simpler for single-field changes):

POST /services/data/v59.0/tooling/sobjects/CustomField/ HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "FullName": "Account.Priority_Score__c",
  "Metadata": {
    "type": "Number",
    "label": "Priority Score",
    "precision": 5,
    "scale": 2,
    "required": false
  }
}

Creating a full custom object via the Tooling API:

POST /services/data/v59.0/tooling/sobjects/CustomObject/ HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "FullName": "Warranty_Claim__c",
  "Metadata": {
    "label": "Warranty Claim",
    "pluralLabel": "Warranty Claims",
    "nameField": { "type": "AutoNumber", "label": "Claim Number", "displayFormat": "WC-{0000}" },
    "deploymentStatus": "Deployed",
    "sharingModel": "ReadWrite"
  }
}

A few guardrails:

  • Deploying schema requires the ModifyAllData and ModifyMetadata permissions, which most customer admins will not grant to a third-party OAuth app. Ask before assuming this is possible.
  • Metadata operations don't count against normal REST API limits, but there's a separate deploy quota (typically 5 concurrent deployments per org).
  • The Tooling API's REST surface covers most schema types. The remainder (custom apps, page layouts, complex flows) still require SOAP.

For most B2B SaaS integrations, you should read metadata (Tooling API) but not write it. Let customers' admins own schema; your job is to discover and adapt.

Testing and Permission Considerations

Getting a Salesforce integration to pass QA in your dev sandbox is trivial. Getting it to survive a customer's production org - with restrictive profiles, layered permission sets, and validation rules written by an admin who left the company two years ago - is where most integrations fall over. Plan for the following before shipping.

Test Against Multiple Org Shapes

  • Developer Edition (free): Standard schema, no customizations. Good for smoke tests only.
  • Sandbox (Developer / Developer Pro / Partial Copy / Full): Reflects a real customer's schema. Use Partial Copy or Full for realistic data volumes when possible. Sandboxes refresh from production on their own cadence, so schedule your regression suite to run after each refresh.
  • Scratch Org (Salesforce DX): Ephemeral orgs defined by a JSON config. Ideal for CI - spin one up with a specific edition, features, and prebuilt custom objects, run tests, tear it down. A project-scratch-def.json with "features": [...] and shape templates keeps CI reproducible.
  • Trial Enterprise Edition: Free 30-day org that matches the Enterprise Edition's governor limits. Useful for testing rate-limit behavior without paying.

Maintain at least two long-lived scratch orgs for CI: one with a "minimal" schema (standard objects only) and one with a "kitchen sink" schema (dozens of custom objects, restricted picklists, dependent picklists, formula fields, validation rules). Every code change should pass integration tests against both.

OAuth Scopes You Actually Need

Request the minimum scopes required. For most read/write integrations:

  • api - REST/SOAP API access. Non-negotiable.
  • refresh_token (or offline_access) - long-lived integration without user re-login.
  • id or openid - resolve the user's identity, needed for user-context queries.

Avoid full unless your product genuinely needs it. Enterprise infosec reviews will reject full requests without a strong justification.

Profiles, Permission Sets, and API-Only Users

Customers frequently create a dedicated integration user with a custom profile. Verify your integration works when the connected user has:

  • No standard tab visibility: The API doesn't care about tabs, but some admins remove object access along with tab visibility. Test with objects hidden from the profile.
  • Field-level security applied: Hide half the custom fields from the integration user. Confirm your describe cache is filtered correctly and your queries don't reference hidden fields.
  • Restrictive sharing rules: The integration user should only see records shared to them. Test that your pagination and totals reflect the sharing-visible subset, not the org-wide dataset.
  • API-only user (API Only User permission): Prevents UI logins. Your OAuth flow must complete without a browser round-trip through the standard login UI - use the JWT bearer flow or client credentials flow instead.

Mock the Describe Response

Unit tests that hit a live Salesforce sandbox are slow and flaky. For fast tests, capture a real describe response as a JSON fixture and feed it to your mapping logic. Store one fixture per test scenario:

  • describe-account-standard.json - default schema, no customizations
  • describe-account-with-custom-fields.json - 20 __c fields, mixed types
  • describe-account-restricted-picklist.json - includes a restricted picklist with inactive values
  • describe-account-fls-restricted.json - simulates fields hidden by FLS (omitted from the array)

Run your mapping and SOQL-construction code against each fixture and assert the output. This catches regressions in seconds instead of the 30-90 seconds a real API round-trip takes.

Test the Failure Modes

Every integration should have automated tests for the following error scenarios:

  • INVALID_FIELD after a field is removed - assert your fallback logic retries with a reduced field list.
  • REQUEST_LIMIT_EXCEEDED - assert you back off and don't retry-storm the org.
  • INVALID_SESSION_ID - assert you refresh the token and retry once.
  • FIELD_CUSTOM_VALIDATION_EXCEPTION - assert the admin's error message propagates to your UI unmodified.
  • Empty response page with nextRecordsUrl still present - some paginated queries return zero records on a page; your loop must not hang.

Rate-Limit Testing

Sandboxes share their API limit with production in some configurations. Don't run a hammer test against a customer's sandbox unless they've explicitly agreed. For rate-limit testing, use a scratch org or a dedicated Trial Enterprise org and observe the Sforce-Limit-Info header to confirm your throttle triggers at 80% usage.

How Legacy Unified APIs and Code-First Platforms Handle Custom Fields

The integration market has attempted to solve the __c problem in a few different ways. Most of them shift the burden back onto your engineering team.

1. The Rigid Schema + Passthrough Approach

Legacy unified APIs force third-party data into a rigid, lowest-common-denominator schema. They map the standard FirstName and LastName, but they strip away the custom fields that actually matter to the business.

When you inevitably need to access Churn_Risk_Score__c, these platforms tell you to use "Passthrough Requests". This is a polite way of saying the abstraction has failed. You are forced to write raw Salesforce API requests, construct your own SOQL queries, and handle your own pagination and rate limiting. You pay for a unified API, but you still have to build and maintain a native Salesforce integration. See Your Unified APIs Are Lying to You: The Hidden Cost of Rigid Schemas for a deeper breakdown.

2. The Code-First Approach

Code-first platforms position themselves as developer-friendly by giving you a blank canvas. They require you to write custom TypeScript logic to handle custom fields and per-customer schema variations.

If Customer A needs Industry__c and Customer B needs Vertical__c, you write conditional logic in your sync scripts. This works if you have three customers. It collapses at 50, and it becomes a full-time job at 200. Your engineering team spends more time maintaining customer-specific integration code than building product features. That's the tax you pay for code-first flexibility.

3. The Raw Suffix Approach

Some platforms try to split the difference by requiring developers to append specific query parameters (like ?fields=raw) to their requests. They dump the unmapped custom fields into a nested raw JSON object. While better than dropping the data entirely, this still forces your application layer to parse through unnormalized payloads. Your backend code has to check if the raw object exists, search for the __c suffix, and handle the type conversions manually.

4. The Declarative Deployment Approach

Other platforms require developers to define custom object mappings in declarative YAML files, version-controlled and deployed via CI/CD pipelines. This keeps the configuration out of your application code, but introduces massive friction. If a customer's Salesforce admin adds a new custom field on a Tuesday, your engineering team has to update a YAML file, open a pull request, wait for CI/CD checks, and deploy to production before the integration can recognize the new field. If you have 100 enterprise customers with active Salesforce admins, you're merging YAML pull requests weekly.

The Architectural Solution: Data-Driven Mapping (No Custom Code)

The only scalable way to build native CRM integrations and handle Salesforce custom fields across thousands of tenants is to eliminate integration-specific code entirely. Instead of writing TypeScript or hardcoding SOQL queries, you treat API mapping as configuration data evaluated at runtime.

flowchart LR
    A["Your App<br>(Unified API Call)"] --> B["Generic<br>Execution Engine"]
    B --> C{"Load Config<br>from DB"}
    C --> D["Integration Config<br>(URLs, auth, pagination)"]
    C --> E["Mapping Expressions<br>(JSONata / transforms)"]
    C --> F["Per-Account Overrides<br>(customer-specific)"]
    D --> G["Salesforce API"]
    E --> G
    F --> G
    G --> H["Transform Response<br>via Mapping"]
    H --> I["Unified Response"]

The key insight: the mapping between Salesforce's __c fields and your unified schema is a data expression, not application code. A single generic execution engine evaluates these expressions at runtime for every integration and every customer.

The Generic Execution Pipeline

When a request comes into a data-driven integration engine, the system does not branch based on the integration name. It follows a generic, five-step pipeline:

  1. Resolve Configuration: The system queries the database for the integrated account credentials and the integration configuration (base URL, auth scheme, pagination style).
  2. Extract Mapping Expressions: The system loads JSONata expressions that define how to translate the request and response.
  3. Transform the Request: The system evaluates the request mapping expression to convert the unified request into the provider's native format (e.g., generating a SOQL query).
  4. Execute the API Call: The proxy layer makes the HTTP request using the generic configuration.
  5. Transform the Response: The system evaluates the response mapping expression to normalize the native payload back into the unified format.

Mapping Custom Fields Dynamically with JSONata

Because the mapping layer relies on JSONata (a lightweight query and transformation language for JSON), you can extract custom fields dynamically without knowing their exact names in advance:

{
  "id": Id,
  "first_name": FirstName,
  "last_name": LastName,
  "email_addresses": [{ "email": Email }],
  "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
}

Look closely at the custom_fields line. It uses the JSONata $sift function to iterate over every key in the Salesforce response. It applies a regular expression (/__c$/i) to find any key ending in __c. If the key matches and the value is not null, it extracts the key-value pair and drops it into a clean custom_fields object.

This single line of configuration handles infinite custom fields. If a Salesforce admin adds fifty new __c fields tomorrow, this expression will automatically extract them and pass them to your application. No YAML deployments. No TypeScript updates. No pull requests.

This is how Truto handles Salesforce custom fields. The mapping expressions are stored as configuration data, and the execution engine evaluates them generically. The same engine that processes Salesforce Contact mappings also processes HubSpot, Pipedrive, and every other CRM — because it doesn't contain integration-specific code. It just evaluates whatever mapping expression the config provides.

Constructing SOQL Dynamically

The same concept applies to building requests. Instead of hardcoding SOQL strings, you use a generic mapping function to translate unified query parameters into a valid SOQL WHERE clause:

{
  "q": query.search_term
    ? "FIND {" & query.search_term & "} RETURNING Contact(Id, FirstName, LastName)"
    : null,
  "where": $whereClause ? "WHERE " & $whereClause : null
}

The mapping engine evaluates the incoming parameters, constructs the SOQL syntax, and passes it to the generic HTTP executor. The core engine never knows it is talking to Salesforce.

Handling Per-Customer Salesforce Customizations at Scale

Extracting all custom fields into a custom_fields object solves the discovery problem, but it does not solve the mapping problem—a classic schema normalization challenge.

Your application expects a field called billing_id. Customer A uses Billing_ID__c. Customer B uses Invoice_System_Ref__c. If you use a code-first platform, you have to write tenant-specific logic to handle this routing. The correct architectural approach is a Config Override Hierarchy.

The Config Override Hierarchy

In a data-driven architecture, mapping configurations are just JSON stored in a database. This means you can override them at the account level without touching the base integration logic.

When the execution pipeline resolves the configuration, it checks for overrides in a specific order:

  1. Base Mapping — The default Salesforce-to-unified-model mapping that works for most customers. Handles standard fields and captures all __c fields into a generic custom_fields bucket.
  2. Integration-Level Override — Adjustments that apply to all Salesforce accounts (e.g., always extracting Company_Size__c into a specific unified field).
  3. Account-Level Override — Per-customer overrides that handle Customer A's specific notes__cdescription mapping without touching the base configuration.

To solve the billing_id discrepancy, you simply pass an override payload for Customer B's specific integrated_account_id:

{
  "response_mapping": "$merge([$base_mapping, { 'billing_id': Invoice_System_Ref__c }])"
}

The engine merges this override with the base mapping at runtime. Customer B's Invoice_System_Ref__c is cleanly mapped to your application's billing_id property.

You can expose this configuration via your own UI, allowing your customer success team (or the customers themselves) to define their own field mappings. You save the mapping as a JSON override via API, and the integration adapts instantly. No code changes. No deploys. No CI/CD pipeline for a field mapping change.

Salesforce admins change things constantly. A renamed field or a new validation rule can silently break your integration. Always build defensive error handling around field access, and consider re-running describe calls on a schedule to detect schema drift before your customers report broken syncs.

Why Real-Time Pass-Through Beats Syncing Custom Objects

Many integration platforms attempt to solve the custom object problem by acting as an ETL pipeline — syncing the customer's entire Salesforce instance into a managed database, normalizing it, and letting you query their database instead of Salesforce.

With custom objects and fields, syncing introduces serious problems.

Schema Explosion and Drift

You are storing data whose shape you cannot predict. Customer A has 12 custom objects with 200+ fields total. Customer B has 8 completely different custom objects. Your database schema either becomes infinitely flexible (hello, JSON blobs) or infinitely complex (hello, per-tenant tables).

Worse, when a Salesforce admin changes the data type of a custom field from String to Picklist, or deletes a field entirely, the sync pipeline will often crash or throw silent validation errors. In a real-time pass-through architecture, there is no database schema to maintain. The JSONata expression simply evaluates whatever payload Salesforce returns at that exact moment. If a field is missing, it evaluates to null. If a new field appears, the $sift function catches it automatically.

Data Residency and Compliance Risk

Enterprise Salesforce instances contain highly sensitive data: PII, financial records, healthcare information, and proprietary business metrics. If you use a syncing platform, you are copying all of that into a third-party vendor's database.

If your customer requires data to remain in the EU (GDPR) or requires strict HIPAA compliance, mirroring their custom objects into a multi-tenant sync database will instantly fail their infosec review.

A real-time pass-through architecture acts as a proxy. It fetches the data from Salesforce, evaluates the JSONata mapping in memory, returns the normalized JSON to your application, and drops the payload. The data is never stored at rest in the integration layer.

Stale Data and Webhook Unreliability

Traditional Salesforce integrations rely on polling — checking for changes on a fixed interval. This introduces 15-60 seconds of staleness at best. Salesforce does support Outbound Messages and Change Data Capture (CDC), but configuring these for custom objects requires administrative setup inside the customer's org. It is not plug-and-play. If a webhook fails to fire, your synced database becomes stale, and your application makes decisions based on outdated custom field values.

By querying the Salesforce API in real-time through a proxy layer, you guarantee that your application always reads the absolute source of truth.

The trade-off is real: pass-through means your app's latency depends on Salesforce's response time. For most read operations, that's 200-800ms. If you need sub-50ms reads on large datasets, a sync architecture may be necessary. But for the majority of B2B SaaS use cases — displaying a customer's contacts, creating a deal, updating a record — pass-through is the simpler, more secure, and more maintainable choice.

Putting It All Together: A Reference Architecture

Here's what a production-grade Salesforce custom field integration looks like when you combine these patterns:

sequenceDiagram
    participant App as Your SaaS App
    participant Engine as Unified API Engine
    participant Config as Config Store
    participant SF as Salesforce API

    App->>Engine: GET /unified/crm/contacts?integrated_account_id=abc123
    Engine->>Config: Load integration config + mapping + overrides
    Config-->>Engine: Base mapping + account-level overrides
    Engine->>Engine: Build SOQL query with custom field selection
    Engine->>SF: GET /services/data/v59.0/query?q=SELECT Id,FirstName,...,NPS_Score__c
    SF-->>Engine: Raw Salesforce response
    Engine->>Engine: Evaluate mapping expressions (including __c capture)
    Engine->>Engine: Apply account-level overrides
    Engine-->>App: Unified response with custom_fields populated

The core steps:

  1. Connection time: Run describe on each object, store the schema, let the customer (or your team) configure field mappings.
  2. Request time: Load the base mapping plus any per-account overrides from your config store. Build the appropriate SOQL query dynamically.
  3. Response time: Evaluate the mapping expression against the raw response. Custom fields matching __c are captured automatically. Specific overrides are applied on top.
  4. Ongoing: Re-run describe periodically. Surface schema changes. Let non-engineering teams adjust overrides without code changes.

This architecture handles the full spectrum: standard objects with default fields, custom fields on standard objects, fully custom objects, and per-customer schema variations — all without writing integration-specific code.

If you want to see the baseline Salesforce connection setup before tackling custom objects, check out our step-by-step Salesforce integration guide.

Stop Hardcoding Your Integrations

The __c suffix is the ultimate test of an integration architecture. If your system requires an engineer to write code every time a customer introduces a new custom field, your integration strategy will not scale.

The three things to get right:

  • Dynamic schema discovery: Use the describe API. Never hardcode field lists. Treat each Salesforce org as a unique snowflake, because it is.
  • Data-driven mapping, not code-driven: Keep your field translation logic in configuration, not in application code. Extract custom fields dynamically using JSONata. The maintenance cost difference over 50+ customers is staggering.
  • Per-account overrides without deploys: Your enterprise customers' Salesforce admins will change things. Your integration needs to adapt without an engineering sprint. Implement a config override hierarchy for per-tenant routing, and let the mapping layer do the heavy lifting.

FAQ

What does the __c suffix mean in the Salesforce API?
The __c suffix is Salesforce's naming convention to identify custom fields and custom objects created by users or admins. It differentiates them from standard, platform-provided fields (like FirstName or Email) and is required when writing SOQL queries or using API integrations.
How do I query Salesforce custom objects via REST API?
You query custom objects the same way you query standard objects, but append __c to the object name. For example: GET /services/data/v59.0/sobjects/Invoice__c/ for REST, or use SOQL: SELECT Id, Amount__c FROM Invoice__c. You can also use the sObject Describe endpoint to discover all available custom fields first.
How many custom fields can a Salesforce object have?
A single Salesforce object can have up to 800 custom fields created in the org, plus up to 100 from managed packages, for a total ceiling of 900. The org-wide limit for custom objects is 3,000.
Why do unified APIs struggle with Salesforce custom fields?
Legacy unified APIs force data into rigid, pre-defined schemas, stripping away custom fields. To access them, developers are often forced to use passthrough requests — essentially bypassing the unified API entirely and managing Salesforce pagination, rate limits, and SOQL themselves.
How can I handle different custom field names across different customers?
The most scalable approach is using a Config Override Hierarchy with data-driven mapping. You apply account-level JSON mapping overrides (e.g., mapping Billing_ID__c for Customer A and Invoice_System_Ref__c for Customer B) to your unified schema without changing any integration code.

More from our Blog