Skip to content

Fixing Unified API Limits for Custom Salesforce Objects with JSONata

Standard unified APIs drop custom Salesforce fields, forcing developers into the passthrough trap. Learn how to map custom objects dynamically using JSONata.

Nidhi KN Nidhi KN · · 15 min read
Fixing Unified API Limits for Custom Salesforce Objects with JSONata

If you sell B2B SaaS to the enterprise, this scenario is familiar: your technical evaluation for a six-figure enterprise contract is going perfectly until the prospect's Salesforce administrator drops a schema export in the shared channel. It has 300+ custom fields on Account, a Partner_Deal__c object with deeply nested lookup relationships, and a Revenue_Forecast__c rollup field that drives their entire quarterly planning process.

Your unified API integration was built against Salesforce's standard objects. If your software cannot read and write to this exact schema, the deal is dead. All of that custom data is about to disappear into remote_data blobs - or worse, get dropped entirely. Engineering teams are forced to choose between abandoning their standardized data model entirely or losing the enterprise deal. Standard unified APIs drop custom fields by forcing data into a lowest-common-denominator schema, leaving you with raw passthrough endpoints as a fallback.

Overcoming the limitations of unified data models for custom Salesforce objects with JSONata mapping is an architectural problem, not a feature request. The fix is to stop treating integration behavior as compiled code and start treating it as configuration data - specifically, declarative JSONata expressions that can be overridden per environment and per connected account.

By treating integration logic as configuration rather than code, you can handle infinite enterprise schema variations from a single generic runtime, supporting hundreds of unique Salesforce environments without maintaining a museum of provider-specific backend scripts. This guide walks through why standardized schemas fail on enterprise Salesforce orgs, why the usual escape hatches don't scale, and how a three-level override hierarchy backed by JSONata handles arbitrary per-tenant schemas without a deploy.

The Enterprise Reality: Why Standardized Data Models Fail on Salesforce

API schema normalization - the process of translating disparate data models from different third-party APIs into a single, canonical JSON format - is notoriously difficult because software vendors fundamentally disagree on how to model reality. When you move upmarket, this problem multiplies exponentially.

Custom objects are the default state of enterprise SaaS deployments, not an edge case. Salesforce's official limits documentation specifies that Enterprise Edition allows up to 500 custom fields per object, while Unlimited and Performance Editions allow up to 800. Organizations use these fields to map their highly specific business processes directly into their CRM, and enterprise orgs routinely burn through most of that headroom on Account, Contact, Opportunity, and bespoke custom objects with the __c suffix. Orgs typically hit the limit on Account or Opportunity first, usually mid-deployment when it's least convenient to find out.

This is not a niche problem. Roughly 90% of Fortune 500 companies use Salesforce, and nearly half of Fortune 100 companies specifically use Salesforce's Data Cloud and AI products. If your SaaS is moving upmarket, you will inevitably encounter highly customized CRM schemas. A standard unified data model that only anticipates first_name, last_name, and email is entirely insufficient for an organization that tracks Partner_Tier_Level__c and Last_Security_Audit_Date__c on every contact record.

A standardized data model gives you predictable fields, which is genuinely useful for the 60% of integration work that is truly common across CRMs. It is also completely inadequate for the 40% that determines whether the deal closes: the customer's Renewal_Risk_Score__c, their Deal_Registration__c object, the rollup that powers their QBR deck.

When a unified API attempts to map a highly customized Salesforce response into a rigid schema, it simply drops the custom data. The unified abstraction, which was supposed to save your engineering team time, becomes a bottleneck that prevents you from delivering the features your enterprise customers actually need. If your integration silently drops those fields, your product looks broken to the buyer's admin - and they are the one signing off on the security and integration review. This limitation is exactly why unified data models break on custom Salesforce objects in production environments.

The Limitations of Unified Data Models and the Passthrough Trap

When standard data models fail, unified API vendors typically offer one of two workarounds: raw passthrough endpoints or code-first sync scripts. Both approaches defeat the purpose of using a unified API in the first place.

The standard response from most unified API vendors is a two-part escape hatch: a metadata endpoint that lists custom fields, and a raw passthrough endpoint that lets you make arbitrary Salesforce REST or SOQL calls. On paper this sounds complete. In practice, it undoes the entire reason you bought a unified API.

As we've explored in our analysis of why unified API data models break on custom Salesforce objects, competitors in the integration space often position their custom object support through a separate metadata API and raw passthrough requests. For example, some platforms force developers to bypass the unified schema and write provider-specific code to handle custom objects. Others claim to support custom objects via unified metadata APIs, but still require raw payload passthrough, which means the consumer must write integration-specific logic to make sense of the data. Some even argue that unified APIs fundamentally cannot handle custom objects at all, advocating instead for a code-first approach where developers write custom sync scripts for every single integration.

The passthrough trap works like this:

  1. You use the unified API to fetch standard contacts.
  2. You realize you need the Revenue_Forecast__c field.
  3. The unified API drops this field.
  4. You use the vendor's passthrough endpoint (POST /passthrough/salesforce/query) to execute a raw SOQL query.
  5. You receive a raw Salesforce API response.

At this point, you are no longer using a unified API. You are writing direct Salesforce integration code. You have to handle Salesforce's specific cursor pagination, parse their exact JSON structure, and manage their unique error codes. You have inherited all the maintenance burden that the unified API was supposed to abstract away.

Here is what the "metadata + passthrough" pattern actually costs you:

  • You write Salesforce-specific code again. The moment you drop into passthrough, you are constructing SOQL queries, handling PascalCase field names, parsing attributes.type, and dealing with polymorphic references. That is provider-specific logic in your codebase, and now you have it for every provider that has "custom" anything.
  • You lose the unified response shape. Custom fields come back in a different envelope than standard fields. Your downstream code has to merge them, which means integration-aware branching in what was supposed to be a provider-agnostic layer.
  • You inherit the vendor's rate limit for every extra call. Metadata lookups, describe calls, and passthrough queries all count against the same Salesforce API budget. A page of 100 contacts now costs you two or three API calls per record if you are hydrating custom fields serially.
  • Multi-tenant customization becomes a fork. Customer A wants Territory__c mapped to region. Customer B wants Region_Code__c mapped to the same field. You end up with per-tenant conditional logic sitting inside your generic connector code.
Warning

The Passthrough Reality Relying on passthrough endpoints for custom objects means you are maintaining two entirely separate integration architectures in your codebase: one for standard unified data, and one for raw provider-specific data. What looks like flexibility becomes a maintenance liability that compounds with every enterprise customer you sign.

Why Code-First Custom Object Integration Doesn't Scale

Faced with the passthrough trap, engineering teams often attempt to build their own custom object handling layer in code. The instinct of most engineering teams is to write a small adapter per customer. A file called acme-salesforce-mapper.ts that knows about Acme's custom objects. They define a common interface in their application and write a separate adapter class for every third-party API.

At first, this seems manageable. You write a handler for Salesforce:

async function handleSalesforceContact(rawResponse, tenantId) {
  const contact = {
    id: rawResponse.Id,
    name: rawResponse.Name,
    email: rawResponse.Email
  };
 
  if (tenantId === 'acme_corp') {
    contact.partner_tier = rawResponse.Partner_Tier__c;
    contact.audit_date = rawResponse.Last_Audit__c;
  }
 
  if (tenantId === 'globex') {
    contact.compliance_status = rawResponse.Compliance_Status__c;
  }
 
  return contact;
}

This architecture is a ticking time bomb. It works for the first three customers. By customer twenty, you have a directory of near-duplicate adapters, each with subtle variations, each requiring a code review, a deploy, and a rollback plan when the customer's admin renames a field.

The pain compounds along three dimensions:

  1. Schema drift. Salesforce admins add, rename, and deprecate fields constantly. Your code assumes Deal_Stage__c exists; two months later it is Pipeline_Stage__c. A field-level change now requires a code deploy.
  2. Cross-cutting bug fixes. You improve pagination in the base connector. Now you have to verify that every per-customer adapter still works. If they inherited old behavior via copy-paste, you fix the same bug 20 times.
  3. Onboarding latency. Every new enterprise customer becomes a two-week engineering project. Sales quotes "we can integrate in a sprint," and engineering knows that's a lie.

When you have 500 enterprise tenants, each with their own unique Salesforce schema, this code path becomes an unmaintainable monolith of conditional logic. As we've detailed in our guide on handling custom Salesforce fields across enterprise customers, every time a vendor deprecates an endpoint or a customer modifies their CRM schema, your team has to open the codebase, update the specific API adapter, and deploy a fix. The maintenance burden grows linearly with your customer base. This is the architecture that Salesforce won't raise the cap on - and neither will your engineering team's throughput.

Overcoming Limitations with JSONata API Transformation Mapping

To break out of the code-first trap, you must treat API integration as a data transformation problem rather than a software engineering problem. The way out is to make mapping a data problem, not a code problem.

JSONata is a declarative, Turing-complete transformation language for JSON. Think of it as a highly expressive language purpose-built for reshaping JSON objects. It lets you express arbitrary field transformations, conditional logic, string manipulation, array reshaping, custom functions, date formatting, and recursive expressions as a single expression string. Most importantly, these expressions can be stored in a database column, versioned, overridden, and evaluated at request time without requiring a code deployment.

By using JSONata, you can map standard fields to custom fields dynamically without writing a single line of backend logic.

Instead of if (provider === 'salesforce') { ... } in your codebase, here is an example of a JSONata expression that maps a raw Salesforce contact response - complete with flat PascalCase fields, multiple phone number types, and arbitrary custom fields - into a clean, unified schema:

response.{
  "id": Id,
  "first_name": FirstName,
  "last_name": LastName,
  "name": $join($removeEmptyItems([FirstName, LastName]), " "),
  "title": Title,
  "account": { "id": AccountId },
  "addresses": [{
    "street_1": MailingStreet,
    "city": MailingCity,
    "state": MailingState,
    "postal_code": MailingPostalCode,
    "country": MailingCountry
  }],
  "email_addresses": [{ "email": Email }],
  "phone_numbers": $filter([
    { "number": Phone, "type": "phone" },
    { "number": Fax, "type": "fax" },
    { "number": MobilePhone, "type": "mobile" },
    { "number": HomePhone, "type": "home" }
  ], function($v) { $v.number }),
  "last_activity_at": LastActivityDate,
  "created_at": CreatedDate,
  "updated_at": LastModifiedDate,
  "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
}

Notice the custom_fields mapping at the bottom. The $sift function walks every key on the entire raw Salesforce response, identifies any keys ending in __c (Salesforce's standard suffix for custom fields), and automatically emits them as a nested object under custom_fields in the unified response.

Every enterprise custom field is captured automatically. No metadata call. No passthrough. No per-customer code.

The runtime engine evaluating this expression has no idea what fields the response contains. It doesn't branch on the integration name. It simply evaluates the configuration provided. This means the intelligence of how to talk to the integration lives in compact, expressive strings stored in a database, not in sprawling code files.

For a specific custom object like Deal_Registration__c, the same pattern applies - you point the mapping at the SOQL endpoint and describe the shape you want:

response.records.{
  "id": Id,
  "name": Name,
  "partner": { "id": Partner_Account__c },
  "stage": Registration_Stage__c,
  "expires_at": Expiration_Date__c,
  "custom_fields": $sift($, function($v, $k) {
    $k ~> /__c$/i
  })
}

The unified request that drives this is the same shape regardless of which CRM is on the other end:

GET /unified/crm/custom-objects/deal-registrations?integrated_account_id=abc123

For a deeper walkthrough of writing these expressions, see the step-by-step guide to mapping custom objects with JSONata.

Translating Unified Queries to SOQL

The response side is only half of the problem. JSONata is equally capable of mapping inbound requests. When a user queries your unified API, that query must be translated into Salesforce's native Object Query Language (SOQL). Unified filters like updated_after or email = "x@y.com" need to become SOQL WHERE clauses.

Instead of writing string-concatenation scripts in Node.js or Python, you can define a query_mapping expression using JSONata:

(
  $whereClause := query
    ? $convertQueryToSql(
      query.{
        "created_at": created_at,
        "updated_at": updated_at,
        "email_addresses": email_addresses ? $firstNonEmpty(email_addresses.email, email_addresses),
        "name": $firstNonEmpty(name, first_name, last_name)
          ? { "LIKE": "%" & $firstNonEmpty(name, first_name, last_name) & "%" },
        "account": account.id
      },
      ["created_at", "updated_at", "email_addresses", "name", "account"],
      {
        "created_at": "CreatedDate",
        "updated_at": "LastModifiedDate",
        "email_addresses": "Email",
        "name": "Name",
        "account": "AccountId"
      }
    );
  {
    "q": query.search_term
      ? "FIND {" & query.search_term & "} RETURNING Contact(Id, FirstName, LastName, Email)"
      : "SELECT Id, FirstName, LastName, Email FROM Contact"
        & ($whereClause ? " WHERE " & $whereClause : "")
  }
)

This configuration dynamically constructs the correct SOQL WHERE clause based on the parameters passed to the unified API. Same unified query, different provider on the other end, zero branching in your application code. If a customer needs to filter by a custom field, you simply update the JSONata mapping to include that field in the SOQL generation logic.

Dynamic Resource Resolution for Custom Objects

Custom Salesforce objects live at endpoints like /services/data/v59.0/sobjects/Deal_Registration__c. A code-first architecture would need a new adapter per custom object. With declarative config, resource routing is itself a JSONata expression that picks an endpoint based on request parameters:

resource:
  resources:
    - contacts
    - deal-registrations
    - custom-object-query
  expression: >
    rawQuery.object_name = 'Deal_Registration__c' ? 'deal-registrations'
    : rawQuery.object_name ? 'custom-object-query'
    : 'contacts'

Adding a new custom object becomes a config change, not a code change.

Architecting a 3-Level Override Hierarchy for Custom Salesforce Objects

Having a declarative transformation language is only half the battle. JSONata expressions solve the transformation problem, but they do not solve the multi-tenant problem on their own. You still need somewhere to put per-customer variations without forking the base mapping.

If Acme Corp requires Partner_Tier__c and Globex requires Compliance_Status__c, you cannot put both into a single global mapping file without causing conflicts or exposing data structures across tenants.

The answer is a layered override system where every level can modify the level below it, and mappings are deep-merged at request time. Truto employs a 3-level override hierarchy that allows per-customer customization of the unified API behavior without affecting the underlying platform code.

The runtime resolves mappings through three levels, in order:

Level 1: Platform Base

The default mapping that ships with the integration. It works for most customers and handles the 80% that is common to every Salesforce org, like standard fields (Id, FirstName, LastName) and basic pagination logic. It is shared across all environments.

Level 2: Environment Override

A customer's specific environment (e.g., staging vs. production) can override any aspect of the mapping. This applies across every connected account in your production environment - useful when your entire customer base needs the same custom field added to the unified schema, or for testing new custom field mappings before rolling them out.

Level 3: Integrated Account Override

Individual connected accounts can have their own mapping overrides. Applies only to a single customer's Salesforce org. If one customer's Salesforce instance has a heavily modified custom object that needs special handling, only that specific account's mapping is affected.

The system applies these overrides by evaluating both the base expression and the override expression against the same input, and then deeply merging the results at runtime. The merge is field-level, not document-level. An account override that supplies only response_mapping does not wipe out the base query_mapping - the two are deep-merged.

sequenceDiagram
    participant Client as Client Application
    participant Engine as Unified API Engine
    participant DB as Configuration DB
    participant SFDC as Salesforce API

    Client->>Engine: GET /unified/crm/contacts (Tenant: Acme)
    Engine->>DB: Fetch Base Mapping (Platform)
    Engine->>DB: Fetch Environment Override (Optional)
    Engine->>DB: Fetch Account Override (Acme specific)
    Note over Engine: Deepmerge overrides into final JSONata
    Engine->>SFDC: Execute mapped SOQL query
    SFDC-->>Engine: Raw Salesforce Response
    Note over Engine: Evaluate merged JSONata mapping
    Engine-->>Client: Normalized Unified Response

Consider a concrete scenario where the base mapping outputs standard fields:

{
  "id": "003xxx",
  "name": "John Doe",
  "email": "john@example.com"
}

Your customer Acme Corp has a Partner_Tier__c field they want surfaced as a first-class tier field in the unified response (not buried under custom_fields), and they also need their Revenue_Forecast__c field. The account-level override expression for Acme Corp is configured as a JSONata fragment:

{
  "tier": response.Partner_Tier__c,
  "custom_fields": {
    "revenue_forecast": response.Revenue_Forecast__c
  }
}

The engine deep-merges these results automatically:

{
  "id": "003xxx",
  "name": "John Doe",
  "email": "john@example.com",
  "tier": "Gold",
  "custom_fields": {
    "revenue_forecast": 250000
  }
}

That fragment sits on Acme's connected account record. The platform base mapping and the environment mapping stay untouched. Every other customer's Salesforce org keeps working exactly as before. No fork, no deploy, no regression risk to the other 199 tenants.

This architecture allows a customer to add their own custom fields to the unified response, change how filtering works for their specific setup, or route to a completely different custom object endpoint - all through configuration, and all without a single deployment. For a detailed treatment of the override architecture, see how to customize unified API data models per customer without code.

Tip

Overrides are additive by default. If you need to remove a field the base mapping emits, use a JSONata expression that returns null for that key - the merge treats null as an explicit deletion signal.

Handling Rate Limits on Heavy Custom Object Queries

When dealing with highly customized Salesforce environments, queries inevitably become more complex. Extracting hundreds of custom fields, pulling a wide custom object with dozens of __c fields, or paging through a large Deal_Registration__c table consumes significant API quota. Salesforce enforces strict concurrency and rate limits on these operations. That is a physics problem no unified API can hide.

A common misconception is that a unified API should automatically retry, throttle, or apply backoff logic when an upstream API returns a rate limit error. This is an anti-pattern. If a unified platform absorbs rate limit errors silently, it hides critical infrastructure feedback from the consuming application, leading to cascading timeouts and opaque system failures. Automatic retry inside a unified API layer looks convenient until it turns a single burst into a thundering herd against Salesforce, or worse, into a silent latency spike that your customers experience as your product being slow.

Truto takes a radically honest approach to rate limiting: transparency, not silent absorption. We do not automatically retry or absorb HTTP 429 (Too Many Requests) errors. When Salesforce returns a rate limit error, Truto passes that error directly to your caller unchanged.

However, dealing with provider-specific rate limit headers is incredibly frustrating. Salesforce returns limits in one format, HubSpot in another, and Zendesk in a third. Truto solves this by normalizing upstream rate limit information into standardized headers according to the IETF specification:

  • ratelimit-limit: The maximum number of requests permitted in the current window.
  • ratelimit-remaining: The number of requests remaining in the current window.
  • ratelimit-reset: The time at which the current rate limit window resets.
HTTP/1.1 429 Too Many Requests
ratelimit-limit: 100000
ratelimit-remaining: 0
ratelimit-reset: 1719845000
Content-Type: application/json
 
{
  "error": "rate_limit_exceeded",
  "message": "Upstream provider rate limit exceeded."
}

By standardizing the telemetry, the caller is fully empowered to implement resilient mechanisms. The right architecture is:

  • Read the normalized rate limit headers on every response.
  • Implement exponential backoff with jitter in your application layer.
  • Use a job queue for bulk custom object extractions, not synchronous request loops.
  • Cache immutable-ish custom object metadata locally with a TTL.

You always know exactly how much quota your heavy custom object queries are consuming, allowing you to throttle background syncs before they impact user-facing features.

Rethinking Enterprise Integration Architecture & Where to Go From Here

The traditional approach to B2B SaaS integration forces a false dichotomy: either accept the limitations of a rigid unified data model, or maintain a sprawling codebase of custom passthrough scripts. Neither approach scales when selling to enterprise organizations that heavily mutate their CRM schemas.

If you are shipping to enterprise Salesforce customers, the choice is stark. You can keep writing per-tenant adapter code and accept that every new customer is an engineering project. Or you can move mapping into data - JSONata expressions, layered overrides, config-driven resource routing - and get the same generic runtime to handle 500 tenants with 500 different Salesforce schemas.

By leveraging JSONata as a universal transformation engine and implementing a multi-tiered override hierarchy, you can completely eliminate integration-specific code. Adding support for a new custom Salesforce object becomes a simple data entry operation rather than a software engineering project.

The concrete next steps:

  1. Audit your current integration for if (provider === ...) and per-customer conditional branches. These are the debt you are trying to retire.
  2. Pick one custom object your top enterprise customer cares about. Write a JSONata mapping for it. Deploy it as an account-level override without touching your base connector.
  3. Build a schema drift monitor. When a customer's admin renames Deal_Stage__c to Pipeline_Stage__c, you want to know before they open a support ticket.
  4. Standardize on the IETF rate limit headers in your retry logic so the same client code works across every integration you add.

Stop writing bespoke code to handle enterprise edge cases. Treat your integrations as configurations, rely on declarative mapping, and build a system that easily adapts to the reality of enterprise SaaS deployments.

FAQ

Why do unified APIs drop custom Salesforce fields?
Traditional unified APIs enforce a lowest-common-denominator schema that only captures fields shared across every CRM. Custom fields ending in __c and custom objects like Deal_Registration__c fall outside that schema, so they get dropped or shunted into a raw passthrough endpoint that the caller has to handle with provider-specific code.
What is JSONata and why use it for API mapping?
JSONata is a declarative, Turing-complete transformation language for JSON. It lets you express arbitrary field mappings, conditional logic, and array reshaping as a single expression string that can be stored in a database, versioned, and evaluated at request time. This means integration behavior becomes data, not compiled code.
How do you handle custom Salesforce objects in a unified API?
By using a declarative transformation language like JSONata and a 3-level override system (Platform, Environment, Account), you can map custom fields dynamically and deep-merge the results without altering the core API integration code or affecting other tenants.
Why shouldn't I use raw passthrough endpoints for custom objects?
Passthrough endpoints force you to maintain two separate integration architectures. You lose the abstraction of the unified API and inherit the burden of handling provider-specific pagination, errors, polymorphic references, and data structures in your own codebase.
Does Truto automatically retry Salesforce rate limits?
No. Truto normalizes upstream rate limit information into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) and passes HTTP 429 errors to the caller unchanged. Retry and backoff logic belong in your application layer so you can control the failure mode explicitly rather than absorbing it silently.

More from our Blog