Mapping Custom Objects with JSONata: A Step-by-Step Developer Guide
Learn how to replace hardcoded API integration scripts with declarative JSONata configuration to handle enterprise custom objects and fields at scale.
If you are building B2B software, you will eventually hit a wall where standard API integrations are no longer enough. Your technical evaluation goes perfectly, the demo is flawless, and the enterprise prospect is ready to sign. Then their Salesforce administrator sends over their organization's schema.
It contains 147 custom fields on the Contact object, a highly modified custom object with nested relationships that drives their partner pipeline, and a rollup field that powers their quarterly board decks. If you want the contract, your software needs to read and write to all of it.
This is the exact moment where traditional integration strategies break down. You are forced to choose between abandoning your standardized data model or losing a six-figure deal. If you're integrating with enterprise CRMs and need to handle custom fields like Salesforce's __c or HubSpot's arbitrary properties without writing per-customer code, JSONata is the most practical transformation language available today.
This article is a step-by-step developer guide: mapping custom objects with JSONata to replace hardcoded integration scripts with declarative configuration. We will explore how to handle enterprise edge cases without draining developer resources, why rigid API schemas fail, and how to architect a system that scales to hundreds of custom integrations.
The Enterprise Integration Trap: Why Custom Objects Break APIs
API schema normalization is the process of translating disparate data models from different third-party APIs into a single, canonical JSON format. It is arguably the hardest problem in B2B product integrations because software vendors fundamentally disagree on how to model reality.
Custom objects are the default state of enterprise SaaS deployments, not the exception. The moment you integrate with Salesforce, you hit the wall of custom fields and custom objects, which is exactly why unified data models break on custom Salesforce 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.
To understand the difficulty of standardizing API data mapping, look at how different platforms define a simple "Contact".
Every custom object you create in Salesforce gets the __c suffix automatically - that's how Salesforce distinguishes them from standard objects. In SOQL and Apex you must use the API names (with __c) to reference custom fields and objects. Integrations and metadata tooling rely on these suffixes to programmatically detect and process custom elements.
HubSpot is just as messy, but in a different way. Custom properties live in a flat properties object alongside standard ones, with no naming convention to distinguish them. Zoho, Pipedrive, and Dynamics 365 each have their own idiosyncratic approaches.
When you build direct integrations, your codebase absorbs this domain complexity. If your integration code hardcodes field names, you're signing up for one of two painful outcomes:
- The YAML deployment treadmill: Defining custom object mappings in declarative YAML files, version-controlled and deployed via CI/CD pipelines, 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
if/elsespaghetti: You add conditional branches per customer or per CRM directly into your application. You end up with brittle conditional logic scattered across your application. Adding support for a new custom field requires a pull request, code review, and a production deployment.
Industry data shows that medium complexity SaaS MVPs with third-party integrations cost between $50,000 and $150,000 to build, covering both engineering efforts and customer success management. Annual maintenance typically runs 10% to 20% of that initial development cost - meaning $5,000 to $10,000 per integration per year in pure upkeep. The bulk of this cost comes from maintaining custom objects and handling edge cases that break lowest-common-denominator unified API data models.
The fix isn't to build a bigger common data model, but rather to adopt a unified API that doesn't force standardized data models on custom objects. It's to treat your mapping logic as data that can be changed at runtime without touching your codebase.
What is JSONata? (And Why It Beats jq for API Mapping)
JSONata is a declarative, Turing-complete query and transformation language designed specifically for JSON data. Created by Andrew Coleman at IBM, JSONata is an open-source query language that lets you extract, transform, and map data from JSON documents using concise syntax. Inspired by the 'location path' semantics of XPath 3.1, it allows sophisticated queries to be expressed in a compact and intuitive notation.
Many developers default to command-line tools like jq for JSON manipulation. While jq is excellent for local bash scripts, JSONata is vastly superior for production API mapping in backend services:
| Feature | JSONata | jq |
|---|---|---|
| Runtime | JavaScript (browser + Node.js) | C (CLI-first) |
| Array handling | Implicit iteration; arrays and single values treated uniformly | Explicit iteration required |
| Embeddability | Ships as an NPM package, embeds directly in your app | Requires shelling out or FFI binding |
| Expression storage | Pure string - store in a DB column, evaluate at runtime | Typically piped via CLI |
| Ecosystem | Implementations in JS, Go, Rust, Java, Python, .NET | Primarily C with Go port |
JSONata is mostly used as an NPM package for browser- and Node.js-based integration applications. From a language point of view, jq and JSONata are quite similar, but they were inspired with different use cases in mind. For jq, it was having a JSON-aware command line tool. For JSONata, it was integrating RESTful applications.
Here is why JSONata is the industry standard for API mapping:
- Native JavaScript Integration: JSONata is implemented in JavaScript and ships via NPM. It embeds directly into Node.js, edge runtimes, and web browsers.
- Lenient Array Handling: JSONata is more lenient in terms of how arrays are treated. When writing an expression, JSONata intuitively does the right thing when it encounters an array vs a simple type. jq requires you to explicitly iterate over arrays, otherwise an error is raised. This matters when dealing with CRM data where a contact might have one email, five emails, or none.
- Functional Programming Paradigm: JSONata embraces functional programming concepts like map, filter, and reduce, enabling developers to write concise, declarative code to dynamically extract custom fields without knowing their names in advance.
- Side-Effect Free: Expressions are pure functions. They transform input to output without modifying application state, making them completely safe to store in a database and execute dynamically.
- Industry Adoption: JSONata has over 750,000 weekly NPM downloads. Platforms dealing with complex B2B data routing - like AWS Step Functions, Stedi for EDI mappings, Notehub for IoT payloads, and Kestra for workflow orchestration - have adopted JSONata as their standard transformation engine.
JSONata Syntax Quick Primer
If you're seeing JSONata for the first time, here's a fast walkthrough of the syntax patterns used throughout this guide.
Path expressions navigate JSON structures. response.FirstName extracts the FirstName field. Nested paths chain naturally: response.properties.email.
String concatenation uses the & operator:
FirstName & " " & LastNameConditionals use ternary ? : syntax. Omitting the else branch returns undefined, which JSONata silently drops from output:
Email ? { "email": Email, "is_primary": true }Variable binding uses := inside parenthesized blocks:
(
$knownFields := ["firstname", "lastname", "email"];
properties ~> $sift(function($v, $k) {
$not($k in $knownFields)
})
)Object construction uses { } with quoted keys. When applied to an array, it maps over each element automatically - no explicit loop required:
response.{
"id": Id,
"name": FirstName & " " & LastName
}The chain operator ~> pipes a value into a function. These two are equivalent:
$sift($, fn)
$ ~> $sift(fn)Regex matching uses ~> with a regex pattern. This tests if a key name ends with __c:
$k ~> /__c$/iFunctions you'll use most:
| Function | Purpose | Example |
|---|---|---|
$string(v) |
Coerce to string | $string(Id) |
$number(v) |
Coerce to number | $number("87") returns 87 |
$join(arr, sep) |
Join strings | $join(["a","b"], " ") returns "a b" |
$filter(arr, fn) |
Filter array elements | $filter(phones, function($v) { $v.number }) |
$sift(obj, fn) |
Filter object keys | $sift($, function($v,$k) { $k ~> /__c$/ }) |
$map(arr, fn) |
Transform each element | $map(items, function($i) { $i.name }) |
$merge(arr) |
Merge objects into one | $merge([{"a":1}, {"b":2}]) returns {"a":1,"b":2} |
$lookup(obj, key) |
Look up value by key | $lookup($statusMap, "Web") |
$split(str, sep) |
Split string into array | $split("a;b;c", ";") returns ["a","b","c"] |
$boolean(v) |
Truthy check | Filters out null, "", 0 |
$exists(v) |
Check if defined | Returns true/false |
With these building blocks, you can handle any field mapping pattern you'll encounter in CRM integrations.
By moving transformation logic out of your Node.js application code and into JSONata expressions, you turn integration maintenance into a data operation rather than a code deployment.
Step-by-Step Developer Guide: Mapping Custom Objects with JSONata
Let's work through a real-world scenario. You're building a product that reads contact data from your customers' CRMs. Customer A uses Salesforce. Customer B uses HubSpot. Both have heavily customized their schemas.
Step 1: Analyze the Raw Input Payloads
Here is a simplified version of what Salesforce returns:
{
"Id": "003Dn00000F1ABCXYZ",
"FirstName": "Sarah",
"LastName": "Chen",
"Email": "sarah@acmecorp.com",
"Phone": "+1-415-555-0142",
"MobilePhone": "+1-415-555-0199",
"CreatedDate": "2024-03-15T10:30:00.000+0000",
"LastModifiedDate": "2025-11-20T14:15:00.000+0000",
"Industry_Vertical__c": "Financial Services",
"Lead_Score__c": 87,
"Preferred_Language__c": "en-US"
}And here is HubSpot's version of the same person:
{
"id": "551",
"properties": {
"firstname": "Sarah",
"lastname": "Chen",
"email": "sarah@acmecorp.com",
"phone": "+1-415-555-0142",
"mobilephone": "+1-415-555-0199",
"createdate": "2024-03-15T10:30:00.000Z",
"hs_lastmodifieddate": "2025-11-20T14:15:00.000Z",
"industry_vertical": "Financial Services",
"lead_score": "87",
"preferred_language": "en-US"
}
}Notice the differences: different field names, different nesting depth, different casing conventions, different types (HubSpot stores lead_score as a string). Your unified schema needs to normalize all of this.
Step 2: Define Your Target Unified Schema
We want our application to consume a predictable format, regardless of whether the data came from Salesforce, HubSpot, or Pipedrive. We also need a dedicated object to hold any dynamic custom fields the enterprise customer has created, as we cannot know their keys at build time.
{
"id": "string",
"first_name": "string",
"last_name": "string",
"name": "string",
"email_addresses": [{ "email": "string", "is_primary": "boolean" }],
"phone_numbers": [{ "number": "string", "type": "string" }],
"created_at": "ISO 8601 string",
"updated_at": "ISO 8601 string",
"custom_fields": "object (dynamic)"
}Step 3: Write the Salesforce JSONata Mapping
Hardcoding Industry_Vertical__c is an anti-pattern. If the customer adds a new custom field tomorrow, our integration will drop it. Instead, we use JSONata's $sift function to dynamically extract any field ending in __c.
response.{
"id": $string(Id),
"first_name": FirstName,
"last_name": LastName,
"name": $join([FirstName, LastName], " "),
"email_addresses": [
Email ? { "email": Email, "is_primary": true }
],
"phone_numbers": $filter([
{ "number": Phone, "type": "work" },
{ "number": MobilePhone, "type": "mobile" },
{ "number": HomePhone, "type": "home" }
], function($v) { $v.number }),
"created_at": CreatedDate,
"updated_at": LastModifiedDate,
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}A few things to unpack:
$string(Id)coerces the ID to a string, ensuring type consistency regardless of the source.$join([FirstName, LastName], " ")safely concatenates the first and last name.$filter([...], function($v) { $v.number })removes phone entries where the number is null or undefined. No more empty objects polluting your arrays.$sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })- this is the key line. It dynamically captures every custom field without knowing any of their names upfront.
Step 4: Write the HubSpot JSONata Mapping
HubSpot doesn't have a __c suffix convention, so we define the known standard properties explicitly and capture everything else with $sift.
(
$standardProps := ["firstname", "lastname", "email", "phone",
"mobilephone", "createdate", "hs_lastmodifieddate"];
response.{
"id": $string(id),
"first_name": properties.firstname,
"last_name": properties.lastname,
"name": $join([properties.firstname, properties.lastname], " "),
"email_addresses": [
properties.email
? { "email": properties.email, "is_primary": true }
],
"phone_numbers": $filter([
{ "number": properties.phone, "type": "work" },
{ "number": properties.mobilephone, "type": "mobile" }
], function($v) { $v.number }),
"created_at": properties.createdate,
"updated_at": properties.hs_lastmodifieddate,
"custom_fields": properties ~> $sift(function($v, $k) {
$not($k in $standardProps) and $boolean($v)
})
}
)The output from both expressions is identical:
{
"id": "003Dn00000F1ABCXYZ",
"first_name": "Sarah",
"last_name": "Chen",
"name": "Sarah Chen",
"email_addresses": [{ "email": "sarah@acmecorp.com", "is_primary": true }],
"phone_numbers": [
{ "number": "+1-415-555-0142", "type": "work" },
{ "number": "+1-415-555-0199", "type": "mobile" }
],
"created_at": "2024-03-15T10:30:00.000+0000",
"updated_at": "2025-11-20T14:15:00.000+0000",
"custom_fields": {
"Industry_Vertical__c": "Financial Services",
"Lead_Score__c": 87,
"Preferred_Language__c": "en-US"
}
}Two completely different API response shapes. Two different JSONata expressions. One unified output.
Step 5: Execute in Node.js
The mapping expression is just a string. You can store it in a database, evaluate it at request time, and change it without redeploying anything:
const jsonata = require('jsonata');
async function mapResponse(rawResponse, mappingExpression) {
const expression = jsonata(mappingExpression);
return expression.evaluate({ response: rawResponse });
}
// mappingExpression comes from config, not from code
const result = await mapResponse(salesforcePayload, savedMappingString);This is the architectural insight that separates declarative mapping from hardcoded integration scripts. The mapping is data. The runtime engine is generic.
Handling Custom Salesforce Fields and Objects: A Complete Before/After Example
The step-by-step guide above covers the basics with a simplified payload. Real enterprise Salesforce orgs are messier. They have picklist fields returning API names as plain strings, standard relationship lookups nested inline, and custom child relationships (__r) with their own subquery result format. Let's map all of these in a single expression.
The Raw Salesforce Payload
This is what Salesforce returns when you query a Contact with related objects using SOQL like SELECT Id, FirstName, LastName, Email, Phone, MobilePhone, AccountId, Account.Name, Account.Industry, LeadSource, Subscription_Tier__c, Industry_Vertical__c, Lead_Score__c, Preferred_Language__c, (SELECT Id, Name, Status__c, Partner_Tier__c, Registration_Date__c FROM Partner_Registrations__r) FROM Contact:
{
"Id": "003Dn00000F1ABCXYZ",
"FirstName": "Sarah",
"LastName": "Chen",
"Email": "sarah@acmecorp.com",
"Phone": "+1-415-555-0142",
"MobilePhone": "+1-415-555-0199",
"AccountId": "001Dn00000G2DEFXYZ",
"Account": {
"attributes": { "type": "Account", "url": "/services/data/v62.0/sobjects/Account/001Dn00000G2DEFXYZ" },
"Id": "001Dn00000G2DEFXYZ",
"Name": "Acme Corp",
"Industry": "Financial Services"
},
"LeadSource": "Web",
"Subscription_Tier__c": "Enterprise",
"Industry_Vertical__c": "Financial Services",
"Lead_Score__c": 87,
"Preferred_Language__c": "en-US",
"Partner_Registrations__r": {
"totalSize": 1,
"done": true,
"records": [
{
"attributes": { "type": "Partner_Registration__c" },
"Id": "a01Dn00000H3GHIXYZ",
"Name": "PR-2025-0142",
"Status__c": "Approved",
"Partner_Tier__c": "Gold",
"Registration_Date__c": "2025-01-10"
}
]
},
"CreatedDate": "2024-03-15T10:30:00.000+0000",
"LastModifiedDate": "2025-11-20T14:15:00.000+0000"
}Three things to notice: the Account relationship is inline (a standard lookup - you just navigate into it with dot notation), Partner_Registrations__r is a custom child relationship subquery with its own totalSize, done, and records array, and LeadSource is a picklist returning the API name as a plain string.
The JSONata Mapping Expression
(
$sourceMap := {
"Web": "inbound_web",
"Phone Inquiry": "inbound_phone",
"Partner Referral": "partner",
"Trade Show": "event",
"Other": "other"
};
response.{
"id": $string(Id),
"first_name": FirstName,
"last_name": LastName,
"name": $join([FirstName, LastName], " "),
"email_addresses": [
Email ? { "email": Email, "is_primary": true }
],
"phone_numbers": $filter([
{ "number": Phone, "type": "work" },
{ "number": MobilePhone, "type": "mobile" }
], function($v) { $v.number }),
"account": Account ? {
"id": $string(Account.Id),
"name": Account.Name,
"industry": Account.Industry
},
"lead_source": $lookup($sourceMap, LeadSource)
? $lookup($sourceMap, LeadSource)
: LeadSource,
"partner_registrations": Partner_Registrations__r.records.{
"id": $string(Id),
"name": Name,
"status": Status__c,
"partner_tier": Partner_Tier__c,
"registered_at": Registration_Date__c
},
"created_at": CreatedDate,
"updated_at": LastModifiedDate,
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}
)Key patterns in this expression:
- Picklist normalization:
$lookup($sourceMap, LeadSource)maps Salesforce picklist API names to your internal enum values. The fallback? ... : LeadSourcepasses through unmapped values rather than silently dropping them. - Standard relationship flattening:
Account.Namenavigates directly into the inlineAccountobject that Salesforce returns for parent lookups. No special handling needed. - Custom child relationship extraction:
Partner_Registrations__r.records.{ ... }iterates over therecordsarray inside the__rsubquery result and maps each record into a clean object. - Dynamic custom field capture: The
$siftcall catchesSubscription_Tier__c,Industry_Vertical__c,Lead_Score__c, andPreferred_Language__cwithout naming any of them.
The Normalized Output
{
"id": "003Dn00000F1ABCXYZ",
"first_name": "Sarah",
"last_name": "Chen",
"name": "Sarah Chen",
"email_addresses": [{ "email": "sarah@acmecorp.com", "is_primary": true }],
"phone_numbers": [
{ "number": "+1-415-555-0142", "type": "work" },
{ "number": "+1-415-555-0199", "type": "mobile" }
],
"account": {
"id": "001Dn00000G2DEFXYZ",
"name": "Acme Corp",
"industry": "Financial Services"
},
"lead_source": "inbound_web",
"partner_registrations": [
{
"id": "a01Dn00000H3GHIXYZ",
"name": "PR-2025-0142",
"status": "Approved",
"partner_tier": "Gold",
"registered_at": "2025-01-10"
}
],
"created_at": "2024-03-15T10:30:00.000+0000",
"updated_at": "2025-11-20T14:15:00.000+0000",
"custom_fields": {
"Subscription_Tier__c": "Enterprise",
"Industry_Vertical__c": "Financial Services",
"Lead_Score__c": 87,
"Preferred_Language__c": "en-US"
}
}The raw Salesforce payload with its attributes metadata, __c suffixes, __r subqueries, and picklist strings is now a clean, predictable structure your application can consume without any Salesforce-specific logic.
Salesforce REST API Custom Fields: What You Need to Know
The Salesforce REST API exposes custom fields through the same endpoints as standard fields. There is no separate custom-field API - custom fields appear as additional properties on the sObject, identified by an __c suffix on their API name. If your integration is going to survive contact with real enterprise orgs, these are the details that matter.
Field discovery via Describe. Call GET /services/data/v62.0/sobjects/Contact/describe to retrieve every field on an object, including custom ones. Each descriptor carries name, label, type, custom (boolean), nillable, updateable, createable, picklistValues (for picklists), and referenceTo (for lookups). The custom: true flag is a more reliable signal than string-matching __c, especially when managed packages introduce namespace prefixes like mynamespace__Region__c.
Suffix conventions.
- Custom fields:
Industry_Vertical__c - Custom objects:
Partner_Registration__c - Custom child relationships in SOQL subqueries:
Partner_Registrations__r(plural,__r) - Managed package namespace:
acme__Partner_Registration__c - External IDs: any custom field can be flagged as an external ID and used to upsert by that field via
PATCH /services/data/v62.0/sobjects/Contact/External_Id__c/<value>
SOQL is the query workhorse. Anything beyond a single-record fetch goes through GET /services/data/v62.0/query?q=SELECT+.... SOQL supports parent lookups (SELECT Account.Name FROM Contact) and child subqueries (SELECT (SELECT Id FROM Partner_Registrations__r) FROM Contact), which flatten into JSONata-friendly shapes with dot notation and a .records array respectively. SOQL requires every field to be named explicitly - there is no SELECT *.
Dynamic field selection. To grab all custom fields without hardcoding names, hit describe first, filter for custom === true, and build the SOQL field list from the response. Cache the describe result per org because schemas change infrequently. Every mapping example in this guide assumes the raw payload already includes the fields you need. The describe-then-query pattern is how you get there without maintaining a hand-written field list per customer.
Field-Level Security is invisible to your code. A custom field can exist on the sObject but be hidden from your connected app's user profile. If you expected a field and it isn't in the response, FLS is the first thing to check in the customer's org (Setup > Object Manager > [Object] > Fields & Relationships > [Field] > View Field Accessibility). Truto preserves the raw upstream response under remote_data, so you can quickly confirm whether the field is missing at the Salesforce layer or dropped somewhere in the mapping.
Composite requests for batching. The /composite and /composite/batch endpoints bundle up to 25 subrequests into a single HTTP call. Useful when a unified "get contact with related data" operation would otherwise fan out into several sequential Salesforce calls and eat into the org's daily API allocation.
Advanced JSONata Techniques: $sift, $map, and Dynamic Resolution
Once you move beyond basic field mapping, JSONata offers powerful functional tools for handling complex API quirks.
Catching All Custom Fields with $sift
The $sift(object, function) function filters an object's key/value pairs, keeping only those where the predicate function returns true. It's the object-level equivalent of $filter for arrays.
For Salesforce, the regex-based approach works perfectly because of the __c convention:
$sift($, function($v, $k) {
$k ~> /__c$/i
})This captures Revenue_Forecast__c, Deal_Registration__c, and any other custom field or object - including ones the customer's admin created yesterday - without you knowing about them in advance.
For platforms without a naming convention (HubSpot, Pipedrive), you define an exclusion list as demonstrated in Step 4.
Reshaping Nested Structures with $map
Some APIs return custom fields as arrays of key-value pairs rather than flat dictionaries. Dynamics 365 or a ticketing system, for example, might return something like:
{
"customAttributes": [
{ "key": "region", "value": "APAC" },
{ "key": "tier", "value": "Enterprise" }
]
}You can reshape this into a clean dictionary using $merge and $map:
$merge(
$map(customAttributes, function($item) {
{ $item.key: $item.value }
})
)Result: { "region": "APAC", "tier": "Enterprise" }
Safely Falling Back with $firstNonEmpty
Different integrations store the same conceptual data in different places. A contact's primary phone number might be under phone, mobilephone, or hs_whatsapp_phone_number. Using a custom function like $firstNonEmpty (or chaining ternary operators) allows you to build resilient fallbacks.
{
"primary_phone": $firstNonEmpty([mobilephone, phone, company_phone])
}Dynamic Resource Resolution
Sometimes the mapping challenge isn't just about fields - it's about which API endpoint to call in the first place. Consider a unified "list contacts" operation. HubSpot has three different endpoints depending on what the caller needs:
/crm/v3/objects/contactsfor basic listing/crm/v3/objects/contacts/searchwhen filters are applied/marketing/v1/contact-lists/{id}/contactswhen a specific list (view) is requested
This can be expressed as a JSONata routing rule stored alongside your field mappings:
rawQuery.view.id ? 'contact-list-results'
: rawQuery.search_term ? 'contacts-search'
: 'contacts'The runtime evaluates this expression, gets back the resource identifier, and uses it to look up the endpoint configuration. No switch statement. No provider-specific branching in your codebase.
Handling Picklists and Type Conversions
Salesforce picklists return the API name as a plain string. A LeadSource picklist with display label "Phone Inquiry" returns "Phone Inquiry" in the API response. This is fine until your application needs standardized enum values across multiple CRMs.
**Single-
Declarative Mapping and the Config-Override Hierarchy
Storing mappings as data only pays off if there is a clean way to layer default behavior, environment-wide behavior, and per-customer behavior on top of each other. Truto expresses that split as three deep-merged levels:
| Layer | What lives here | Who owns it |
|---|---|---|
| Platform base | The default mapping shipped for every customer using an integration (Salesforce, HubSpot, etc.) | Truto product updates |
| Environment override | Mappings that apply to every account inside one environment (production, staging, or a per-tenant workspace) | Your platform engineers |
| Account override | Mappings that apply only to a single connected account | CS, SEs, or the customer's admin |
Because each layer deep-merges onto the previous one, an account override only has to specify the fields it changes. If Acme Corp needs a renewal_risk field surfaced from Renewal_Risk__c, the override is a two-line expression. The base mapping continues to supply first_name, email_addresses, custom_fields, and every other field it already handled.
The hierarchy applies to more than just response mapping. Query translation, request body construction, resource routing, error handling, and pre/post request steps can all be overridden at any of the three layers. This is what lets a customer with a heavily customized Salesforce org route to a different SOQL endpoint or add an extra field to a create call without your engineering team touching code.
A few properties fall out of this design that are worth naming explicitly:
- Additive by default. An override that only defines
custom_fieldsinherits every other field from the base. There is no need to copy-paste a 200-line expression to change one line. - Reversible in one click. Deleting an account-level override immediately restores the base mapping. Rollbacks do not require a deploy.
- Isolated blast radius. A misconfigured account override affects exactly one account. Other tenants continue running on the base or environment mapping.
- Auditable as data. Since every layer is a stored string, you can diff versions, attribute changes to users, and reproduce past behavior from a snapshot.
No-Code Per-Customer Mapping: What It Looks Like
Everything covered so far assumes you're comfortable writing JSONata by hand. In practice, most per-customer mapping changes shouldn't require an engineer. Once mapping logic is stored as configuration rather than code, the same expressions can be edited through a UI by customer success managers, solutions engineers, or the customer's own admin.
Here's the concrete picture of no-code per-customer API schemas:
- A shared base mapping ships with every integration (Salesforce, HubSpot, Pipedrive, etc.) and covers the majority of use cases out of the box.
- When a specific customer needs a custom field, a different picklist translation, or a new derived attribute, you edit their mapping in the UI - not the codebase.
- Changes apply only to that customer's connected account. Everyone else keeps running the base mapping.
- There's no PR, no CI, no build. Save the change and the next API call uses it.
This model works because Truto exposes a three-level override hierarchy: platform base -> environment override -> account override. Each level deep-merges on top of the previous one, so a per-customer tweak only touches the fields it changes. The rest of the mapping continues to inherit from the base, which means you never have to copy-paste a 200-line expression just to change one field.
Why this matters for the "how do I customize a unified API per customer without code" question: the answer is that the unified API's behavior is expressed as data, and that data has a per-account layer that anyone with UI access can edit. That's the whole mechanism.
JSONata Primer for Non-Developers
If you're a CS manager or solutions engineer, you don't need to become a JSONata expert. You need to recognize five patterns.
1. Field mapping. Left side is the unified field name, right side is where the value lives in the raw response.
{
"first_name": FirstName,
"email": Email
}2. Adding a custom field. Just add a new key/value pair. The mapping engine merges it on top of the base.
{
"priority_tier": Priority_Tier__c
}3. Concatenating fields. Use & between strings.
{
"full_name": FirstName & " " & LastName
}4. Conditional value. Ternary condition ? value returns the value when true and drops the key entirely when false.
{
"is_vip": Lead_Score__c > 80 ? true
}5. Translating picklist values. Use $lookup on a small dictionary to convert the customer's internal labels to your standard enums.
{
"stage": $lookup({
"Qualified": "qualified",
"Proposal Sent": "proposal",
"Closed Won": "won"
}, StageName)
}If you can read those five patterns, you can edit the vast majority of real customer mappings without ever opening an IDE.
Step-by-Step: Create a Mapping in the UI
Here's a walkthrough of adding a customer-specific field mapping using the Truto dashboard. The scenario: a customer's Salesforce org has a Renewal_Risk__c field they want surfaced as renewal_risk on every contact response.
Step 1: Open the customer's integrated account.
Navigate to the customer's integrated account in the dashboard. Each account has a Mappings tab that lists every resource available for their integration (contacts, deals, accounts, and so on).
Screenshot: The Integrated Account detail page with the Mappings tab highlighted, showing a list of resources and their current override status.
Step 2: Pick the resource and method.
Click the resource you want to customize - in this case, crm/contacts. You'll see the read methods (list, get) and write methods (create, update), each with its own mapping panel.
Step 3: Open the response mapping editor.
The editor shows two panes: the base mapping (read-only, inherited from platform defaults) and the override (empty by default, where your changes go). At runtime the override is deep-merged onto the base, so you only need to specify what changes.
GIF: Split-pane editor showing the platform base mapping on the left and an empty override editor on the right. The user clicks into the override pane and starts typing.
Step 4: Add the override expression.
For a single new field, you don't need to rewrite the whole mapping. Add just the field you want:
response.{
"renewal_risk": Renewal_Risk__c
}The engine merges this with the base response mapping, so first_name, email, custom_fields, and every other standard field keep working exactly as before.
Step 5: Save.
Hitting save writes the override to that account's configuration. The change is live on the next API call. No restart, no deploy, no cache warmup.
Screenshot: Confirmation banner reading "Mapping override saved. Applied to 1 connected account."
Preview and Test Mappings Against Sample or Live Data
Before saving a mapping, you can test it against real data pulled from the customer's account. This is the "does my expression actually work" step that catches the vast majority of bugs before they hit production.
Option A: Test against sample data.
Paste any JSON payload into the sample input pane and see the mapping output update in real time. This is useful when you're drafting a mapping before the customer's account is even connected, or when you want to test edge cases like null fields or missing objects.
{
"Id": "003xxxABC",
"FirstName": "Sarah",
"LastName": "Chen",
"Renewal_Risk__c": "high"
}The output pane shows the unified response as it would be returned to the caller of the unified API.
Option B: Test against live data.
Click "Fetch live sample" to pull an actual record from the customer's connected account through the same authenticated proxy call the runtime would use. The editor evaluates your current override against that record and shows the result. This catches issues that sample data misses - unexpected null values, unusual field types, unicode edge cases in customer names, and picklist values you didn't know existed.
Always preview against live data before applying a mapping to a customer with production traffic. Sample data is convenient but rarely captures the shapes real APIs return in the wild.
Copy-Paste JSONata Snippets for Common Use Cases
A reusable library CS and admins can grab from:
Add one custom field:
response.{ "renewal_risk": Renewal_Risk__c }Add multiple custom fields at once:
response.{
"renewal_risk": Renewal_Risk__c,
"account_owner_email": Owner.Email,
"health_score": Health_Score__c
}Translate a picklist to a standard enum:
response.{
"tier": $lookup({
"Enterprise": "tier_1",
"Mid-Market": "tier_2",
"SMB": "tier_3"
}, Customer_Tier__c)
}Capture every custom field automatically (Salesforce):
response.{
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}Only surface a field when it has a value:
response.{
"secondary_email": Secondary_Email__c ? Secondary_Email__c
}Concatenate two fields into one:
response.{
"territory": Region__c & " - " & Sub_Region__c
}Mapping Templates for Common Salesforce Cases
The snippets above cover the smallest, most-frequent overrides. The templates below go a step further and handle the recurring shapes that come up in real Salesforce integrations. Each one is designed to drop straight into an account or environment override and deep-merge with the base mapping.
1. Flatten a parent lookup relationship.
response.{
"account": Account ? {
"id": $string(Account.Id),
"name": Account.Name,
"industry": Account.Industry,
"owner_email": Account.Owner.Email
}
}2. Extract a custom child relationship subquery.
response.{
"partner_registrations": Partner_Registrations__r.records.{
"id": $string(Id),
"name": Name,
"status": Status__c,
"registered_at": Registration_Date__c
}
}3. Handle multi-select picklists (semicolon-separated strings).
response.{
"regions": Regions__c ? $split(Regions__c, ";")
}4. Normalize Salesforce's timezone-suffixed dates to strict ISO 8601.
response.{
"created_at": CreatedDate ? $replace(CreatedDate, /\.000\+0000$/, "Z"),
"updated_at": LastModifiedDate ? $replace(LastModifiedDate, /\.000\+0000$/, "Z")
}5. Build a Lightning deep link to the record.
response.{
"urls": [{
"url": "https://" & %.context.subdomain
& ".lightning.force.com/lightning/r/Contact/" & Id & "/view",
"type": "self"
}]
}6. Preserve a Salesforce currency field with its ISO code.
response.{
"annual_revenue": AnnualRevenue ? {
"amount": AnnualRevenue,
"currency": CurrencyIsoCode ? CurrencyIsoCode : "USD"
}
}7. Coerce Salesforce boolean-as-string values.
response.{
"is_qualified": Is_Qualified__c = true or Is_Qualified__c = "true" ? true : false
}8. Route a unified list operation across three SOQL endpoints.
rawQuery.view.id ? 'contact-list-results'
: rawQuery.search_term ? 'search'
: 'contacts'9. Build a SOQL WHERE clause for a unified query.
(
$clauses := $filter([
query.email_addresses ? "Email = '" & query.email_addresses.email & "'",
query.updated_at ? "LastModifiedDate >= " & query.updated_at,
query.account.id ? "AccountId = '" & query.account.id & "'"
], function($v) { $v });
{
"where": $count($clauses) > 0 ? "WHERE " & $join($clauses, " AND ")
}
)10. Extract only customer-defined custom fields, excluding managed packages.
(
$ignoredNamespaces := ["npsp", "sbqq", "pmdm"];
response.{
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i
and $not($k ~> /^[a-z0-9]+__[a-z0-9_]+__c$/i)
and $boolean($v)
}),
"managed_package_fields": $sift($, function($v, $k) {
$k ~> /^[a-z0-9]+__[a-z0-9_]+__c$/i and $boolean($v)
})
}
)Drop these into an override, adjust the field names to match the customer's org, and preview against a live record before saving.
Apply a Mapping to a Specific Tenant (No Deploy Required)
Because mappings are stored as data, "deploying" is just clicking save. But it's worth understanding which layer your change lands on, because that determines who it affects.
| Level | Scope | Who Edits |
|---|---|---|
| Platform base | Every customer on every environment | Truto (via product updates) |
| Environment override | All accounts inside one environment (e.g., every account on your production environment) | Your platform engineers |
| Account override | A single connected account | CS, solutions engineers, or the customer's admin |
The rule of thumb: use account overrides for anything customer-specific. If you're mapping a custom field that only Acme Corp has, put it on Acme Corp's account. If you're mapping a field that every customer on your production environment needs, put it at the environment level.
Applying a mapping to an account takes effect immediately for that account only. Other customers keep running the base or environment mapping untouched. This is what makes per-customer schema customization operationally safe - a mistake on one account can't cascade to others, and rolling back is a one-click delete of the override.
Common Troubleshooting for CS and Admins
Problem: The custom field isn't showing up in the response.
Check three things in order:
- Is the field actually being returned by the source API? Open the raw response preview in the editor. If the field isn't in the raw payload, the CRM didn't return it - usually a permissions or field-level security issue on the CRM side.
- Is the field name spelled correctly? Salesforce field names are case-sensitive.
renewal_risk__candRenewal_Risk__care different fields. - Is the override actually saved on the right account? Overrides at the account level don't apply to any other account.
Problem: The whole response is empty or missing standard fields after adding an override.
You probably overrode the entire response mapping instead of merging into it. Make sure your override only contains the new fields you want to add. The base mapping supplies the rest.
Problem: The mapping preview works but production doesn't.
The preview uses whatever expression you're currently editing. Production uses the last saved version. Save your change and re-run against a live record to confirm.
Problem: Picklist values come through as raw API names.
Salesforce and other CRMs return picklist API names, not display labels. Add a $lookup translation table in the override to convert them to your standard enum values (see the JSONata primer above).
Problem: A field works for one customer and returns null for another.
Different customers have different custom fields. That's the reason per-account overrides exist. Either add the field to the account where it's missing, or fall back to $sift to catch every custom field automatically:
{
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}Problem: An OAuth error appears when you click "Fetch live sample".
The customer's connection token has probably lapsed. Truto refreshes OAuth tokens shortly before they expire, but if the customer revoked access from the CRM side, no refresh will succeed. Ask the customer to reconnect the account.
Who Should Make These Changes?
Not every mapping change needs to go through engineering. Here's a practical breakdown:
- Customer Success: Can safely add or remove custom field mappings, adjust picklist translations, and toggle fields on and off for specific accounts. Additive changes with low blast radius.
- Solutions Engineer: Can write more involved expressions - conditional logic,
$map/$siftpatterns, cross-field derivations, and dynamic resource resolution. - Customer's Admin: With the right role granted, can adjust their own mappings when their org changes. Common when the customer has a mature internal integration team.
- Your Engineering Team: Owns the platform base and environment mappings. Reviews any account override that touches core business logic before it goes live for a high-value account.
The point isn't to route every change through engineering. The point is to give each role the tools they need to move without blocking on a deploy queue.
Operational Checklist: Limits, Timeouts, Retries, and Rate Limits
Mapping is only half the problem. The other half is running these expressions against a live Salesforce org that has API limits, unpredictable latency, and occasional outages. Run through this checklist before any mapping goes into production traffic.
Salesforce API allocation. Salesforce enforces a per-24-hour API request limit based on org edition and license count. Enterprise Edition orgs get a base allocation plus additional calls per licensed user, with a hard cap that varies by tier. Every list, get, create, update, and describe counts against it. Bulk API v2 has its own separate limits. Monitor consumption with GET /services/data/v62.0/limits and alert well before you hit 80% of the daily allocation - once the limit is exceeded, every subsequent call returns REQUEST_LIMIT_EXCEEDED until the 24-hour window rolls over.
SOQL query limits worth remembering.
- 50,000 rows per synchronous SOQL query result (use
queryMoreor Bulk API beyond this) - 100 relationship queries per SOQL statement
- 20,000 characters per SOQL statement
- 2-minute execution timeout on synchronous queries
Request timeouts. Salesforce's HTTP endpoints have a hard 2-minute execution timeout for synchronous operations. Long-running exports must use Bulk API v2, which returns a job ID immediately and lets you poll for completion. Set your client-side timeout to around 60 seconds for standard REST calls so you fail fast on slow queries instead of holding a socket open until the server gives up.
Retry policy. Retries should follow exponential backoff with jitter. A workable default:
429(rate limited): honor theRetry-Afterheader if present, otherwise start at 5 seconds and double up to a 60-second cap503(service unavailable): back off 10, 20, 40 seconds500or transport-level errors: retry up to 3 times with 1, 2, 4 second delays- Never retry a
400or404- the request is malformed or the record does not exist
For writes, upsert by external ID (PATCH /sobjects/Contact/External_Id__c/<value>) whenever possible. Salesforce does not natively accept Idempotency-Key headers, and upserts on a stable external ID are the closest thing to idempotency you get.
Concurrent request limits. Salesforce enforces a per-org concurrent long-running request limit. Requests that exceed it return CONCURRENT_REQUESTS_LIMIT_EXCEEDED. Serialize expensive SOQL calls or introduce a client-side semaphore for queries that scan large objects.
Bulk API for scale. For syncing more than a few thousand records, switch from REST to Bulk API v2. Create a job with POST /services/data/v62.0/jobs/query, poll GET /services/data/v62.0/jobs/query/<id> for status, then fetch results via GET /services/data/v62.0/jobs/query/<id>/results. Bulk API has separate, higher daily row limits than the REST allocation.
Circuit breakers. Wrap Salesforce calls in a circuit breaker so a partial outage does not cascade. Trip the breaker after N consecutive 5xx responses in a rolling window and fail fast for a cooldown period. This is especially important when a single customer's org is unhealthy and would otherwise consume all your worker capacity waiting on timeouts.
Token refresh cadence. OAuth access tokens expire on a schedule set by the customer's connected app session policy (often 15 minutes to 24 hours). Refresh proactively before expiry rather than waiting for a 401. Truto refreshes OAuth tokens shortly before they expire, which avoids failed requests and unnecessary reauth flows.
Error normalization. Salesforce returns errors as an array with an errorCode string. Map codes like REQUEST_LIMIT_EXCEEDED to 429, INVALID_SESSION_ID to 401, INSUFFICIENT_ACCESS_OR_READONLY to 403, and MALFORMED_QUERY to 400. This normalization is expressible as a JSONata error expression so your application code sees consistent HTTP semantics.
Monitoring and Debugging Tips
Once mappings are live, most incidents come from one of three sources: schema drift in the customer's org, silent field-level security changes, or an upstream API returning a shape your mapping did not expect. These are the habits and instruments that catch them fast.
Preserve the raw upstream response. Every mapped response should include a copy of the raw third-party payload alongside the unified fields. In Truto this ships automatically as remote_data on every record. When a customer reports "the contact is missing my custom field", diff the raw response against the mapped output and you instantly know whether the field was returned by Salesforce or dropped by the mapping.
Log the mapping expression version. When an account override is changed, log the version or hash of the expression that was active at request time. If a bug report arrives three days after a change, the log tells you which mapping was live when the bug happened.
Sample real payloads before saving. Test the mapping against a live record from the customer's connected account, not against invented sample data. Real orgs have unexpected null values, unicode edge cases in names, picklist values that no one documented, and custom fields with the same label as standard ones.
Alert on mapping evaluation errors. A malformed JSONata expression throws at evaluation time. Route those errors to your on-call channel with the account ID, resource, and method attached so you know exactly which override to roll back.
Track custom field cardinality per account. If the number of keys inside custom_fields suddenly drops for one customer, either their admin removed fields or their FLS changed. Both are worth surfacing to the customer proactively.
Watch API consumption per account. Salesforce API calls are per-org, not per-connected-app. If one customer's usage spikes, other users of the same org will start seeing 429s. Alert on daily consumption above 70% of the org's allocation so you can throttle before Salesforce does it for you.
Instrument response times. SOQL performance depends heavily on query selectivity and the customer's data volume. A query that runs in 200ms in one org may take 45 seconds in another. Track p50, p95, and p99 per resource per account so you can spot orgs that need query tuning, custom indexes, or a switch to Bulk API.
Use Salesforce's Setup Audit Trail. When a mapping stops working overnight, the customer's Setup Audit Trail shows exactly what changed - field deletions, picklist edits, permission changes, FLS updates. Asking the customer to share the last 7 days of audit trail entries usually resolves the incident faster than digging through logs.
Watch for mapping_version: 2 gotchas. If your integration uses versioned mapping references (where one method reuses another method's mapping by CRUD name), a rename or delete on the referenced method silently breaks the reference. Include the resolved expression in evaluation logs so you can tell whether a mapping was executed directly or via reference.
FAQ
- What is JSONata and how is it used for API mapping?
- JSONata is a declarative query and transformation language for JSON. For API mapping, it lets you write concise expressions that reshape vendor-specific JSON responses into a unified schema. Because expressions are just strings, they can be stored in a database and evaluated at runtime without code deployments.
- How do you handle Salesforce custom fields (__c) dynamically?
- You can use JSONata's $sift function with a regex predicate to filter the incoming JSON payload and automatically extract any keys ending in the __c suffix: $sift($, function($v, $k) { $k ~> /__c$/i }). This catches every custom field without knowing their names in advance.
- Why is JSONata better than jq for backend API mapping?
- JSONata is natively implemented in JavaScript, making it easy to embed in Node.js backends. It handles arrays implicitly, is side-effect free, and its expressions are pure strings that can be stored in databases, making it better suited for runtime API mapping than the CLI-first jq.
- What is a 3-level override architecture for integrations?
- It is a configuration pattern where API mapping rules are stored in a database at the Platform, Environment, and Account levels. Each level's JSONata expression is deep-merged at runtime, allowing per-customer schema customizations without deploying new code.