---
title: Fixing Unified API Limits for Custom Salesforce Objects with JSONata
slug: fixing-unified-api-limits-for-custom-salesforce-objects-with-jsonata
date: 2026-09-01
author: Nidhi KN
categories: [Engineering, Guides]
excerpt: "Standard unified APIs drop custom Salesforce fields, forcing developers into the passthrough trap. Learn how to map custom objects dynamically using JSONata."
tldr: Traditional unified APIs fail on enterprise Salesforce schemas by dropping custom fields. You can fix this by using JSONata and a 3-level override hierarchy to map custom objects without code.
canonical: https://truto.one/blog/fixing-unified-api-limits-for-custom-salesforce-objects-with-jsonata/
---

# 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. <cite index="6-4">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.</cite> 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. <cite index="6-9,6-10">Orgs typically hit the limit on Account or Opportunity first, usually mid-deployment when it's least convenient to find out</cite>.

This is not a niche problem. <cite index="11-1,11-2">Roughly 90% of Fortune 500 companies use Salesforce, and nearly half of Fortune 100 companies specifically use Salesforce's Data Cloud and AI products</cite>. 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](https://truto.one/why-unified-data-models-break-on-custom-salesforce-objects-and-how-jsonata-transformations-solve-it/) 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](https://truto.one/why-unified-api-data-models-break-on-custom-salesforce-objects-and-how-to-fix-it/), 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:

```typescript
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](https://truto.one/whats-the-best-way-to-handle-custom-salesforce-fields-across-different-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 <cite index="6-2">Salesforce won't raise the cap on</cite> - 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](https://truto.one/how-to-map-standard-fields-to-custom-fields-dynamically-in-a-unified-api/) 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:

```jsonata
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:

```jsonata
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:

```http
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](https://truto.one/step-by-step-developer-guide-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:

```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:

```yaml
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.

```mermaid
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:

```json
{
  "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:

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

The engine deep-merges these results automatically:

```json
{
  "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](https://truto.one/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
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.

> Ready to stop writing custom integration scripts? Want to see a JSONata-based override applied to a live Salesforce sandbox with custom objects? We'll walk through your real customer schema and show what a data-driven mapping looks like end to end.
>
> [Talk to us](https://cal.com/truto/partner-with-truto)
