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.
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.
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.
Zero Integration-Specific Code: The 3-Level Override Architecture
Writing JSONata in your Node.js application is better than writing raw imperative mapping code, but it still requires code deployments to update.
The true power of this approach unlocks when you store these JSONata expressions in your database as configuration data. This pattern - storing integration behavior as data - enables zero integration-specific code in your application runtime.
When you decouple the mapping logic from the deployment lifecycle, you can implement a 3-level override hierarchy. This is how modern integration platforms handle infinite enterprise schema variations without touching the core codebase.
flowchart TD
A["Platform Base Mapping<br>(Default for all customers)"] -->|Deep Merge| B["Environment Override<br>(Per-tenant customization)"]
B -->|Deep Merge| C["Account Override<br>(Per-connected-account)"]
C --> D["Final Merged Mapping<br>(Evaluated at runtime)"]
style A fill:#f9f9f9,stroke:#333,stroke-width:2px
style B fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style C fill:#d9ead3,stroke:#38761d,stroke-width:2px
style D fill:#fce5cd,stroke:#b45f06,stroke-width:2pxLevel 1: Platform Base
The default JSONata mapping that handles 90% of use cases. This maps standard Salesforce or HubSpot fields to your unified model.
Level 2: Environment Override
Your staging environment might need different mappings than your production environment. By storing environment-specific JSONata overrides, you can safely test complex schema changes before rolling them out to customers. Or, if a specific tenant needs their Revenue_Forecast__c rollup mapped to a specific field in your unified schema, you store an override expression at the environment level.
Level 3: Account Override
This is how you win enterprise deals. If Customer X has two Salesforce orgs with different custom field structures, each connected account can have its own override. You write a JSONata override string and attach it directly to their specific connected account record in the database.
When the API request executes, the system deep-merges the account override on top of the base mapping. You have successfully implemented per-customer data model customization without code.
Preserving Raw Data
Even with the best JSONata expressions, you might miss a field. Always preserve the raw, unmapped third-party response in a remote_data object alongside the unified response. This ensures no custom field data is ever permanently lost during the transformation process and gives you a safety net when a mapping expression needs adjustment.
The Trade-offs: When JSONata Isn't the Right Tool
Being honest about limitations matters more than overselling any approach.
Performance at extreme scale. JSONata expressions are interpreted at runtime, which means they're slower than native code for the same transformation. That elegant syntax and power are expensive in terms of performance. For the vast majority of API integration workloads (request-response cycles in the hundreds-of-milliseconds range), this is irrelevant. If you're processing millions of events per second in a streaming pipeline, you'll want to compile your mappings to native code. Reco.ai used JSONata as their transformation language for detection rules and eventually rewrote their evaluator in a compiled language when they hit scale constraints.
Debugging opacity. The fact that there are no errors for null or undefined values means that evaluating an expression may return nothing - no errors, just nothing. Some specific skill sets are required to be effective when debugging JSONata expressions. The JSONata Exerciser is your best friend here. Always unit test your expressions against real API payloads before deploying them.
Learning curve. JSONata's syntax is compact, which cuts both ways. A complex Salesforce mapping expression can look dense to someone seeing JSONata for the first time. Invest in documenting your expressions with inline comments (JSONata supports C-style /* comments */).
These are real constraints. For the specific problem of API data mapping - taking vendor responses and reshaping them into your application's schema - the trade-offs are worth it. The alternative is maintaining per-customer code branches, and that doesn't scale.
A Note on Rate Limits and Infrastructure
When building these data pipelines, it is tempting to try and abstract away every API failure. However, standardizing schemas is entirely different from standardizing infrastructure behavior.
For example, modern unified API architectures normalize upstream rate limit information into standardized HTTP headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) per the IETF specification. But when a third-party API returns an HTTP 429 Too Many Requests error, the platform passes that error directly to the caller.
This is a deliberate architectural decision. The caller must remain responsible for retry and exponential backoff logic, because silently absorbing rate limits at the proxy layer inevitably leads to distributed deadlocks and masking underlying application bugs. You keep full control over your retry and backoff strategy.
Where to Go From Here
Custom objects and enterprise edge cases are not exceptions - they are the default state of B2B software. If your engineering team is writing if (provider === 'salesforce') statements in your core application, you are accumulating technical debt that will eventually halt your product velocity.
If you're currently maintaining per-customer integration code or fighting with rigid unified schemas that drop custom fields, here's a practical path forward:
- Start with one integration. Pick your messiest CRM integration (it's probably Salesforce) and rewrite the field mapping as a JSONata expression. Test it against 5-10 real customer payloads.
- Store expressions as data. Move the expression string out of your codebase and into a database or configuration store. Build a thin runtime wrapper that evaluates the expression at request time.
- Add the override layer. When a customer needs a custom field mapped differently, add an override expression that gets deep-merged with the base. Track these overrides as configuration records, not code changes.
- Extend to other integrations. The same runtime engine works for HubSpot, Pipedrive, Dynamics 365, and every other CRM. The only thing that changes is the expression string.
By adopting JSONata as a universal transformation layer and storing those expressions as database configuration, you isolate integration complexity. You empower product managers and implementation engineers to solve enterprise schema requirements instantly, without waiting for the next sprint. This isn't theoretical architecture. It's the pattern that lets teams ship enterprise integrations without proportionally scaling their engineering headcount.
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.