How to Map Standard Fields to Custom Fields Dynamically in a Unified API
Learn how to map standard fields to custom fields dynamically in a unified API using declarative JSONata expressions and a 3-level override hierarchy.
Your unified API just dropped 147 custom Salesforce fields into first_name, last_name, and email. The demo went perfectly. The technical evaluation for a six-figure deal, however, was a complete disaster.
B2B SaaS engineering teams inevitably hit a wall when moving upmarket: enterprise customers heavily mutate their CRM and HRIS schemas. A technical evaluation goes smoothly until the prospect's Salesforce administrator hands over their organization's schema. It contains dozens of custom fields on the Contact object, a bespoke Deal_Registration__c object with nested relationships, and a Revenue_Forecast__c rollup field that drives their entire quarterly planning process.
This is the question every B2B SaaS engineering lead eventually asks: how do you map standard fields to custom fields dynamically in a unified API without writing bespoke passthrough code for every enterprise tenant?
The answer is fundamentally architectural. If your integration infrastructure relies on a rigid, lowest-common-denominator data model, it will drop this critical bespoke data. Traditional unified APIs bake integration logic into code paths (e.g., if (provider === 'salesforce')), which forces every custom field into either a rigid schema or a raw passthrough endpoint. You are then forced to abandon your unified abstraction and write raw, integration-specific code just to close the deal.
This guide explains how to map standard fields to custom fields dynamically using declarative transformation languages and hierarchical overrides, answering the fundamental question of how unified APIs handle custom fields at scale. By treating integration logic as data rather than code, you can handle infinite enterprise schema variations from a single generic runtime, supporting 500 tenants with 500 different Salesforce schemas without maintaining a museum of provider-specific backend scripts.
The Enterprise Integration Reality: Why Standard Data Models Fail
Custom fields and custom objects are not edge cases. They are the default state of every enterprise SaaS deployment.
Walk into any Salesforce org above 50 seats and you will find custom fields suffixed with __c, custom objects like Deal_Registration__c, and rollup summary fields the CFO watches every quarter. Whenever a user creates a custom field in Salesforce, the system automatically appends __c at the end of the field name. This suffix is required when writing SOQL queries or using API integrations, as it ensures the system correctly identifies custom fields versus standard objects.
Similarly, platforms like HubSpot gate custom objects behind Enterprise-tier subscriptions. This means every customer paying for that tier is actively using them to map to their unique revenue operations. NetSuite exposes custom segments, custom lists, and SuiteScript-generated fields that vary wildly per subsidiary.
Traditional unified APIs are designed to abstract away the differences between third-party systems by forcing disparate data structures into a single, standardized schema. If Salesforce calls it FirstName and HubSpot calls it firstname, the unified API normalizes it to first_name.
This "unified" schema that only surfaces first_name, last_name, email, and phone is not truly unified—it is simply the intersection of what every vendor offers. That intersection is small. For an enterprise buyer, it is useless.
When applied to an enterprise schema, standard data models fail catastrophically. The unified API evaluates the payload, maps the five fields it recognizes, and silently discards the 73 custom __c fields that the enterprise actually cares about. This is exactly why unified data models break on custom Salesforce objects. Data mapping issues and API limitations are consistently cited as leading causes of integration project failure, and rigid schemas are the specific class of mapping problem that kills upmarket deals.
There is also a subtler failure mode. When engineering teams design a lowest-common-denominator object, they hide provider capabilities that the downstream application actually needs. A normalized Contact that flattens six phone number types into one loses the semantic distinction between MobilePhone and AssistantPhone. That distinction matters when the customer's routing engine depends on it.
The correct architectural stance is to separate the normalized model from provider-specific capabilities, giving both first-class support. The unified schema should be the default view, not the only view, which is the core philosophy behind unified APIs that don't force standardized data models.
The Problem with Passthrough APIs and Code-First Workarounds
Passthrough APIs and code-first integration platforms force engineering teams to write and maintain provider-specific logic, defeating the architectural purpose of a unified abstraction.
When the common model breaks and drops a custom field, unified API vendors typically point you to one of two workarounds. Both introduce severe technical debt and scale poorly.
Workaround 1: The Passthrough API Lie
Most unified API providers offer a "passthrough" endpoint. "Just call the raw Salesforce API through our proxy," they say, "we handle auth and pagination."
This is the exact moment the abstraction dies.
If you use the passthrough endpoint to query Salesforce custom fields, your application code must now construct raw SOQL queries. You must handle Salesforce's specific pagination cursors. You must parse Salesforce's specific error formats. A passthrough endpoint means you are back to:
- Reading the Salesforce SOQL reference for
WHEREclause syntax - Handling
filterGroupsandCONTAINS_TOKENoperators for HubSpot search - Parsing PascalCase versus camelCase field naming per provider
- Writing per-tenant field mapping logic in your application code
- Maintaining that logic when the provider deprecates a field
Suddenly, your codebase is littered with conditional logic. You paid for a unified API, but you are now writing SOQL inside if (customer_id === 'acme') branches, because Acme's admin created Revenue_Forecast__c but Beta Corp's admin created ARR_Projection__c for the exact same concept:
// The exact tech debt unified APIs are supposed to prevent
if (integration.provider === 'salesforce') {
const soql = `SELECT Id, FirstName, Revenue_Forecast__c FROM Contact WHERE AccountId = '${accountId}'`;
return await passthroughClient.post('/query', { q: soql });
} else if (integration.provider === 'hubspot') {
// Handle completely different HubSpot filterGroups logic
}Workaround 2: Code-First Sync Scripts
Some integration platforms advocate for a "code-first" approach, arguing that unified APIs are inherently flawed for the enterprise. Instead of a common model, they ship a TypeScript or Python SDK where you write custom sync scripts for every integration.
While this provides flexibility, it has the same fundamental problem in a different wrapper. You still maintain integration-specific code; it just lives in their sandbox instead of yours. When you write custom code for every integration, your codebase becomes a museum of provider-specific assumptions.
Adding a new integration requires writing new handler functions, creating new database schemas, writing integration-specific tests, and executing a full CI/CD deployment. When you have 40 integrations and 500 enterprise tenants, you have 20,000 potential code branches. Nobody staffs an integration team large enough to review that.
The root cause in both cases is identical: integration-specific behavior is expressed as code. Every new field, every new tenant customization, and every provider API change requires a CI/CD cycle. Code-first platforms scale linearly with the number of integrations times the number of customer variations. That is a hiring plan, not an architecture.
How Declarative Mapping Solves the Custom Field Problem
Declarative mapping replaces rigid schemas with functional transformation languages, allowing dynamic translation of API requests and responses at runtime without writing backend code.
To support enterprise custom fields without writing integration-specific code, the architecture must separate the normalized interface from the provider's specific constraints. This is achieved using the Interpreter Pattern at a platform scale.
A declarative mapping architecture treats every field translation as a pure function from a provider response to a unified shape. Instead of writing TypeScript handlers for each integration, integration behavior is defined entirely as data. A generic execution engine takes a declarative configuration describing how to talk to a third-party API, and a declarative mapping describing how to translate between unified and native formats.
JSONata: The Universal Transformation Engine
Modern integration platforms rely on JSONata as their transformation engine. JSONata is a Turing-complete functional query and transformation language purpose-built for reshaping JSON objects.
Because JSONata expressions are pure functions (they transform input to output without modifying state) and are stored as simple strings, they can be saved in a database column, versioned, overridden, and hot-swapped without restarting the application or touching source code.
Why JSONata? Unlike basic key-value mappers found in legacy iPaaS tools, JSONata supports conditionals, string manipulation, array transforms, date formatting, custom functions, and recursive expressions. It is powerful enough to handle dynamic SOQL query generation, conditional routing, array folding, and regex-driven custom field detection.
The Generic Execution Pipeline
When a request enters the system, the execution engine follows a strict, integration-agnostic pipeline.
flowchart TD
A["Unified Request<br>GET /crm/contacts"] --> B["Resolve Mapping Config<br>From Database"]
B --> C["Extract JSONata Expressions"]
C --> D["Evaluate Request Mapping<br>Transform Query/Body"]
D --> E["Proxy Layer<br>Execute HTTP Call"]
E --> F["Evaluate Response Mapping<br>Normalize Payload"]
F --> G["Return Unified Data"]Notice that nowhere in this pipeline does the engine check if the provider is Salesforce or HubSpot. It simply evaluates the JSONata expression provided by the configuration.
Here is what a declarative JSONata response mapping for Salesforce contacts looks like as pure data:
response.{
"id": Id,
"first_name": FirstName,
"last_name": LastName,
"name": $join($removeEmptyItems([FirstName, LastName]), " "),
"title": Title,
"account": { "id": AccountId },
"email_addresses": [{ "email": Email }],
"phone_numbers": $filter([
{ "number": Phone, "type": "phone" },
{ "number": MobilePhone, "type": "mobile" },
{ "number": AssistantPhone, "type": "assistant" }
], function($v) { $v.number }),
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}Read the last block carefully. The $sift function walks every key on the raw response, keeps the ones matching the __c suffix pattern, and hands them back as a custom_fields object. That single expression handles every custom field in every Salesforce org, forever. When a customer adds Revenue_Forecast__c in production tomorrow, it appears in custom_fields on the next API call. No code change. No deploy. No release notes. The underlying execution engine never needs to know what __c means.
The same pattern works for HubSpot, which stores custom fields inside a properties object:
(
$defaults := ["firstname", "lastname", "email", "phone"];
$diff := $difference($keys(response.properties), $defaults);
{
"id": response.id,
"first_name": response.properties.firstname,
"email_addresses": [{ "email": response.properties.email }],
"custom_fields": response.properties.$sift(function($v, $k) { $k in $diff })
}
)The runtime that evaluates these expressions has no idea it is talking to Salesforce or HubSpot. It loads the expression string, evaluates it against the raw response, and returns the result. The same code path serves both providers.
For a deeper dive into schema normalization techniques, read our API Schema Normalization Tutorial: End-to-End with JSONata.
Mechanics of Dynamic Schema Mapping
Dynamic schema mapping happens at request time: the runtime loads a declarative expression, binds it to the raw provider response, and produces a unified object with any customer-specific fields promoted into the schema alongside the standard ones.
Understanding the mechanics of dynamic field mapping in a unified API means understanding what happens between the HTTP request landing on your unified endpoint and the JSON response leaving it. The runtime executes six discrete steps, none of which know or care which integration is being called.
Step 1: Resolve the Mapping Bundle
When GET /unified/crm/contacts?integrated_account_id=abc123 arrives, the middleware looks up three pieces of state from the datastore: the integrated account (credentials and per-account overrides), the integration config (base URL, auth scheme, endpoints, pagination strategy), and the resource mapping (JSONata expressions for request and response, keyed by HTTP method).
None of these lookups branch on the integration name. They are standard reads that return configuration objects.
Step 2: Extract Mapping Fragments
The engine pulls out every mapping fragment it might need for this request:
const responseMapping = getResponseMapping(mapping, method)
const queryMapping = getQueryMapping(mapping, method)
const requestBodyMapping = getRequestBodyMapping(mapping, method)
const requestHeaderMapping = getRequestHeaderMapping(mapping, method)
const requestPathMapping = getPathMapping(mapping, method)
const errorMapping = getErrorMapping(mapping, method)Every extraction is a pure data lookup. The function does not know whether it is pulling Salesforce SOQL fragments or HubSpot filterGroups syntax.
Step 3: Evaluate the Request Mapping
The unified request is transformed into the provider's native format. For Salesforce, a unified filter like first_name=John becomes a SOQL WHERE clause. For HubSpot, the same filter becomes a filterGroups array with CONTAINS_TOKEN operators.
Both transformations run the same code path: compile the JSONata expression, evaluate it against a context containing query, body, context, and any pre-fetched data, and return the resulting object. The expression describes the shape of the outgoing request, not the procedure to build it.
Step 4: Call the Provider API
The proxy layer builds the URL from the config, applies authentication (bearer, basic, or header), applies the pagination strategy, executes the HTTP fetch, parses the response according to content type, and extracts the results array using a configured response_path.
Step 5: Evaluate the Response Mapping
This is the step that actually maps standard to custom fields. The JSONata expression receives a rich context:
response: the raw provider response (or one item, for list results)query: the mapped query parametersrawQuery: the original unified query parameterscontext: the integrated account's context (credentials, config)headers: response headers from the providerbody: the request body (for create/update operations)
The expression describes a shape, not a procedure. It says "the unified object should have these keys, computed from those keys on the raw response." Custom fields are handled by treating the raw response as an open bag and sifting out anything that matches a pattern:
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})The pattern is what varies per provider. The mechanism does not.
Step 6: Attach remote_data and Return
Every mapped object gets the original provider payload attached as remote_data, so callers can always reach into fields the unified schema does not cover. This turns the unified schema from a strict contract into a curated default view over the full payload, giving downstream consumers an escape hatch without a separate passthrough endpoint.
Two Directions, One Engine
The same JSONata evaluator runs in both directions. Response mapping translates provider payloads into the unified shape. Request mapping translates unified query and body inputs into the provider's native filter language, body schema, and header conventions. Header mapping and path mapping use the same primitive to interpolate values into HTTP request lines. A create request that carries a custom field ends up in a native provider payload the same way a response containing a custom field ends up in the unified shape: one expression, one evaluator, no per-provider code.
Dynamic Field Mapping Examples: Standard and Custom Side by Side
The fastest way to understand how to map standard fields to custom fields dynamically in a unified API is to trace a raw provider payload through a single JSONata expression and out to a unified response, with both standard and custom fields promoted in the same pass.
Each example below shows the raw provider payload, the declarative expression that transforms it, and the unified response the caller actually sees. The runtime is the same across all of them.
Example 1: Salesforce Contact With Custom Fields
A raw Salesforce Contact response for an enterprise tenant might look like this:
{
"Id": "003xx000004TmiQ",
"FirstName": "Jane",
"LastName": "Doe",
"Email": "jane@acme.com",
"Phone": "+1-415-555-0100",
"AccountId": "001xx000003DGb0",
"Revenue_Forecast__c": 125000,
"Territory_Assignment__c": "AMER-WEST",
"Partner_Tier__c": "Gold",
"Deal_Stage_Override__c": "Committed"
}A single JSONata expression maps standard fields to their unified names and folds every __c field into a custom_fields object in the same pass:
response.{
"id": Id,
"first_name": FirstName,
"last_name": LastName,
"email_addresses": [{ "email": Email }],
"phone_numbers": [{ "number": Phone, "type": "phone" }],
"account": { "id": AccountId },
"custom_fields": $sift($, function($v, $k) {
$k ~> /__c$/i and $boolean($v)
})
}The unified output preserves every custom field automatically, with no per-tenant configuration:
{
"id": "003xx000004TmiQ",
"first_name": "Jane",
"last_name": "Doe",
"email_addresses": [{ "email": "jane@acme.com" }],
"phone_numbers": [{ "number": "+1-415-555-0100", "type": "phone" }],
"account": { "id": "001xx000003DGb0" },
"custom_fields": {
"Revenue_Forecast__c": 125000,
"Territory_Assignment__c": "AMER-WEST",
"Partner_Tier__c": "Gold",
"Deal_Stage_Override__c": "Committed"
}
}When the tenant's admin adds a new Contract_Renewal_Date__c field next quarter, it appears in custom_fields on the next API call. No mapping edit required.
Example 2: HubSpot Contact With Custom Properties
HubSpot wraps every field, standard or custom, inside a properties bag. The mapping computes the delta between known standard properties and everything else, then sifts the rest into custom_fields:
{
"id": "51",
"properties": {
"firstname": "Jane",
"lastname": "Doe",
"email": "jane@acme.com",
"arr_projection": "125000",
"partner_tier": "Gold",
"renewal_risk_score": "0.42"
}
}The expression:
(
$defaults := ["firstname", "lastname", "email", "phone"];
$diff := $difference($keys(response.properties), $defaults);
{
"id": response.id,
"first_name": response.properties.firstname,
"last_name": response.properties.lastname,
"email_addresses": [{ "email": response.properties.email }],
"custom_fields": response.properties.$sift(function($v, $k) {
$k in $diff
})
}
)Same unified shape as the Salesforce example, driven by completely different provider quirks. The runtime does not know which one is executing.
Example 3: Promoting a Custom Field to a First-Class Unified Field
Sifting into custom_fields handles the common case, but sometimes a specific tenant needs a custom field promoted to a first-class key alongside first_name and email. When Acme Corp signs a contract and their operations team needs Revenue_Forecast__c to appear as a top-level projected_revenue field, you do not fork the mapping or push a code change. You attach a JSONata override to their integrated account record:
{
"response_mapping": "{ 'projected_revenue': Revenue_Forecast__c }"
}At request time, the runtime deep-merges this expression's output over the platform response mapping's output. Acme's unified response now carries both custom_fields.Revenue_Forecast__c (from the base sift) and a promoted projected_revenue key at the top level. Every other tenant continues to receive the unchanged base schema. No if customer_id === 'acme' branch. No forked mapping file. No deploy.
Example 4: Writing to Custom Fields Dynamically
Dynamic mapping runs in both directions. A unified create request that includes custom_fields gets translated back into the provider's native shape by a request body mapping. For Salesforce, custom fields sit at the top level of the record payload, so the mapping merges them onto the standard field object:
$merge([
{
"FirstName": body.first_name,
"LastName": body.last_name,
"Email": body.email_addresses[0].email
},
body.custom_fields
])A unified caller sending { "first_name": "Jane", "custom_fields": { "Revenue_Forecast__c": 125000 } } ends up POSTing this to Salesforce:
{
"FirstName": "Jane",
"LastName": "Doe",
"Email": "jane@acme.com",
"Revenue_Forecast__c": 125000
}HubSpot's mapping does the same thing but folds custom fields into properties instead:
{
"properties": $merge([
{
"firstname": body.first_name,
"lastname": body.last_name,
"email": body.email_addresses[0].email
},
body.custom_fields
])
}The unified caller sends the same request shape either way. The provider-specific packaging is data, not code.
Example 5: Discovering Field Names at Runtime
When a provider allows arbitrary custom field API names, hardcoding a suffix pattern is not enough. A before step can hit the provider's metadata endpoint, then hand the resulting list of custom field names to the main mapping expression:
before:
- name: field_metadata
type: request
resource: properties
method: list
response_mapping: >-
(
$customKeys := before.field_metadata[isCustom = true].name;
response.properties.{
"id": id,
"first_name": firstname,
"last_name": lastname,
"custom_fields": $sift(function($v, $k) { $k in $customKeys })
}
)The main call still happens once. The metadata result is available to the response mapping through the before context variable. The mapping picks up new custom fields the tenant creates in the future without any change to the expression.
Recap: Four Levers That Make Mapping Dynamic
Every example above uses one or more of the same four levers:
| Lever | What it does | When to reach for it |
|---|---|---|
Pattern-based sift ($sift over __c or a delta set) |
Auto-captures every custom field into a custom_fields bag |
Default handling for any tenant on the integration |
| Account-level override | Promotes a specific custom field to a first-class unified key for one tenant | Enterprise deals with named field requirements |
| Bidirectional body mapping | Translates unified custom_fields back into the provider's native write shape |
Any create or update operation that carries custom data |
before step for field metadata |
Discovers custom field names dynamically from a provider metadata endpoint | Providers where custom fields do not follow a naming convention |
These four primitives cover the full surface area of standard-to-custom field mapping across every enterprise integration we have seen.
The 3-Level Override Hierarchy: Platform, Environment, and Account
A multi-level override hierarchy allows engineering teams to customize API mappings at the platform level for all users, the environment level for specific deployments, or the account level for individual enterprise tenants.
Declarative mappings solve the default case, but dynamic mapping is only half the solution. They do not solve the bespoke tenant case, where Acme Corp wants their highly mutated Revenue_Forecast__c promoted to a first-class unified field, while Beta Corp wants the same field mapped to a differently named property.
If an enterprise customer has a highly mutated CRM schema, you need a way to configure field mappings per customer without forking code or affecting the rest of your user base. This is solved by implementing a three-level override hierarchy. The configuration system deep-merges mapping rules at request time.
Level 1: The Platform Base
The base mapping is the default configuration shipped with the integration. It translates standard fields like first_name and email between the unified model and the provider's native API. You author this once, test it against a live provider account, and it works for 80% of customers. This mapping is shared across all environments.
Level 2: Environment Override
Every SaaS application has one or more environments (dev, staging, prod, or per-workspace). An environment can override any mapping key for every integration used inside it. For example, a staging environment might need to map data to a sandbox-specific custom object, or a GRC vendor might extend the unified users mapping to always surface an mfa_enabled field pulled from the raw payload, applying that rule across every one of their tenants.
Level 3: Integrated Account Override
This is the critical layer for enterprise sales. Individual connected accounts—one specific customer's Salesforce org—can have their own mapping overrides attached to their specific tenant record.
If Acme Corp requires their Revenue_Forecast__c field to be mapped to a custom projected_revenue field in your application, you simply inject a JSONata override onto Acme Corp's integrated account record:
{
"response_mapping": "{ 'projected_revenue': Revenue_Forecast__c }"
}At runtime, the resolution is straightforward. The engine loads the base mapping, deep-merges the environment override, and then deep-merges the account override per mapping key. The merged expression is compiled and evaluated:
// Pseudocode - the actual merge happens per mapping key
const effective = deepMerge(
platformBase, // Level 1
environmentOverride, // Level 2
accountOverride // Level 3
)
const mapped = await jsonata(effective.response_mapping)
.evaluate({ response, query, context, headers })Acme Corp gets their custom field exactly where they want it, and your engineering team didn't have to write a single line of custom TypeScript. Every other tenant continues using the default.
What this enables in practice:
- A customer can add their own custom fields to the unified response without you shipping code.
- A customer can rewrite filter behavior for their specific setup.
- You can support conflicting tenant requirements without forking.
- Rollback is a config revert, not a hotfix deploy.
Architecture at a Glance
flowchart LR
A[Unified API Request] --> B[Generic Runtime]
B --> C[Load Platform Base Mapping]
B --> D[Load Environment Override]
B --> E[Load Account Override]
C --> F[Deep Merge]
D --> F
E --> F
F --> G[Evaluate JSONata Expression]
G --> H[Call Third-Party API]
H --> I[Evaluate Response JSONata]
I --> J[Unified Response + remote_data]Notice what is not in that diagram: a switch statement on the provider name. There is no SalesforceHandler class. The runtime is generic. The behavior is data.
For more details on implementing this architecture, see Per-Customer API Mappings: 3-Level Overrides for Enterprise SaaS.
Handling Custom Objects and Extensions
Custom objects require dynamic resource routing and multi-step orchestration, not just field-level transformation. The same declarative engine that maps custom fields handles custom objects by parameterizing which endpoint gets called and how its results are joined back into the unified response.
Custom fields are the easy case. Custom objects (Salesforce's Deal_Registration__c, HubSpot's Enterprise-tier custom objects, NetSuite's custom record types) require the runtime to hit different endpoints entirely, and sometimes to make multiple calls per unified request.
Dynamic Resource Routing
The same unified operation can route to different provider endpoints based on the incoming request. In Salesforce, a list contacts call routes to the SOQL search endpoint when a full-text search term is provided, and to the standard contacts endpoint otherwise. In HubSpot, the same unified call routes to the search endpoint when filter parameters are present, to the list endpoint by default, and to a saved-view endpoint when a view ID is provided.
The routing rule is a declarative expression:
resource:
resources:
- contacts
- contacts-search
- contact-list-results
expression: >
rawQuery.view.id ? 'contact-list-results'
: $firstNonEmpty(rawQuery.first_name, rawQuery.last_name) ? 'contacts-search'
: 'contacts'Or a simple conditional array matched by query parameter presence:
resource:
- resource: search
query_param: search_term
- resource: contactsThis means one unified endpoint can transparently fan out to any number of provider endpoints without any code branching on the provider name.
Custom Objects as First-Class Resources
An enterprise tenant with a Deal_Registration__c object does not need a bespoke integration. The unified model gets a deal_registrations resource authored once against the provider's custom object API. The tenant's integrated account carries an override that maps the specific __c field names to the unified fields the customer's app expects:
{
"response_mapping": "response.{ 'id': Id, 'partner_name': Partner_Name__c, 'stage': Stage__c, 'expected_close_date': Expected_Close__c, 'custom_fields': $sift($, function($v, $k) { $k ~> /__c$/i }) }"
}Because the runtime is generic, adding support for a new custom object at a specific tenant is a database write, not a deploy. The customer's integration continues to use the platform's base mappings for standard objects and their account override for the custom one.
Multi-Step Orchestration with Before, After, and Related Resources
Some unified operations cannot be satisfied by a single provider call. Fetching a Salesforce contact along with its custom Territory_Assignment__c requires two SOQL queries. Creating a HubSpot deal with an associated custom object requires resolving the association type first, then making the create call with the correct schema ID.
Declarative pipelines model this with three primitives:
beforesteps run before the main call. They can hit a metadata endpoint to discover a custom field's API name, fetch a schema ID for a custom object, or resolve an association type. Their output is available to the main mapping expressions via abeforecontext variable.aftersteps run against the mapped response. They can trigger a follow-up call to write a related record or transform the result set using more JSONata.related_resourcesjoin additional data onto the primary response. For each record in the primary result, the engine can hit a secondary endpoint (a custom object lookup, an owner detail, a territory assignment) and merge the result using an equality or containment join.
Each of these primitives is itself declarative. A related-resource entry, for example, specifies which endpoint to call, how to build its query from parent record fields, and how to join the results back:
{
"resource": "territory_assignments",
"method": "list",
"query": { "contact_id": "{{record.id}}" },
"related_by": ["id", "eq", "contact_id"]
}The engine handles the fan-out, joining, and error containment. By default, a failing related fetch is logged and its records get no related entry, so a flaky secondary endpoint never breaks the primary response. When the related data is mandatory, an opt-in list of HTTP status codes rethrows the failure instead.
Discovering Custom Fields at Runtime
Some providers expose custom field metadata through a separate endpoint. Salesforce has the describe API. HubSpot has the properties API. NetSuite exposes custom segments via SuiteQL. A before step can hit that metadata endpoint and pass the resulting field list into the main mapping expression, letting the response mapping adapt to the tenant's exact schema without hardcoding field names:
before:
- name: field_metadata
type: request
resource: properties
method: list
response_mapping: >-
(
$customKeys := before.field_metadata[isCustom = true].name;
response.properties.{
"id": id,
"custom_fields": $sift(function($v, $k) { $k in $customKeys })
}
)The main call still happens once. The metadata is cached per account when the provider supports it. And the mapping automatically picks up new custom fields the customer creates in the future.
Handling Rate Limits and API Errors During Dynamic Mapping
When performing dynamic field mapping, the integration infrastructure must transparently pass upstream HTTP 429 rate limits to the caller using normalized IETF headers, leaving retry and backoff logic to the consuming application.
A common architectural mistake in unified APIs is attempting to abstract away rate limits. Many platforms intercept HTTP 429 (Too Many Requests) errors and apply automatic exponential backoff under the hood.
This is a dangerous anti-pattern. Black-box retries cause unpredictable latency spikes, hold open network connections, amplify thundering herds, and hide architectural bottlenecks from the consuming application. If an AI agent or a high-volume sync job hits a rate limit, the caller needs to know immediately so it can pause its internal queues or circuit breakers. Explicit failure gives the caller a decision point.
Modern integration layers take a radically transparent approach to rate limits. The platform does not silently retry, throttle, or absorb the error. Instead, when an upstream API returns an HTTP 429, that error is passed straight through to the caller.
However, because every third-party API formats its rate limit data differently (Salesforce uses one shape, HubSpot another, NetSuite governance limits live in a completely different response envelope), the mapping layer collapses all provider-specific rate limit metadata into standardized IETF headers before passing the response back:
ratelimit-limit: The total request quota.ratelimit-remaining: The number of requests remaining in the current window.ratelimit-reset: The timestamp when the quota resets.
By normalizing the headers but passing the error, the integration layer remains stateless and predictable. The consuming application can accurately schedule its retry logic by reading one contract instead of fifteen.
Additionally, for field-level errors during mapping (e.g., a JSONata expression referencing a field the provider stopped returning), the platform should surface the raw error and preserve the original, unmapped response as remote_data on every unified object. This ensures debugging never requires reproducing the failing request against the provider directly, and gives callers an escape hatch for fields not covered by the unified schema.
Shipping Integrations as Data, Not Code
By abstracting integration logic into declarative configuration data, product teams can ship new connectors, support custom enterprise fields, and handle API changes without triggering CI/CD pipelines or deploying backend code.
The custom fields problem is fundamentally an architecture problem. The reason this architecture matters is operational, not aesthetic.
When integration behavior is code, every change is a deploy. Every deploy has a blast radius. Every blast radius requires review, CI runs, canary rollout, and rollback plans. A code-per-integration platform scales its release process linearly with the number of connectors it supports. Your maintenance burden will grow linearly with every enterprise customer you sign.
When integration behavior is data, adding a new provider is a JSON write. Adding a new custom field mapping for one tenant is a database update. Adding a new unified resource across every existing integration is a set of JSONata expressions authored against live provider responses. The generic runtime that handles 100 integrations today will handle the 101st without a single line of code being changed, compiled, or deployed.
This is what makes per-tenant customization economically viable. You can afford to accommodate Acme's schema quirks because accommodating them costs a config change, not an engineer-week. You can afford to onboard the enterprise deal that requires three custom fields promoted into the unified schema, because promoting them costs a mapping edit reviewable in a simple pull request.
The trade-offs are real and worth acknowledging. Declarative expressions are harder to grep than TypeScript. Debugging a JSONata expression that returns null requires a REPL and a sample payload. Complex conditional logic pushes the readability limit of any expression language. Good tooling—a validator, a sample-based mapping editor, and thorough test coverage against recorded provider responses—is what makes this architecture livable in production.
The payoff is that integration engineering stops looking like feature engineering and starts looking like content authoring. That is the right shape for a problem where the surface area is dictated by 200 vendors and 5,000 tenant variations, not by your own product roadmap.
Next Steps for Integration Architects
If you are picking apart your current integration architecture and want to move toward a declarative model to handle infinite enterprise schema variations, three concrete moves pay off first:
- Audit where integration-specific code lives. Grep your codebase for provider names in conditionals (
if provider === 'salesforce'). Every match is a place where a config change would beat a code change. - Model the override hierarchy explicitly. Even if you build it yourself, decide up front whether customization happens at the platform, environment, or account level. Mixing them without a clear precedence rule is worse than not supporting overrides at all.
- Preserve the raw provider payload on every unified object. A
remote_datafield is the escape hatch that saves the abstraction. Callers can reach into it when the unified schema does not cover something, without you needing to expose a passthrough endpoint.
FAQ
- Why do traditional unified APIs fail at custom field mapping?
- They express integration behavior as code with provider-specific branches, forcing every field into a lowest-common-denominator schema. Custom fields either get dropped or pushed to a passthrough endpoint that requires you to write raw provider-specific queries yourself.
- What is JSONata and why is it used for dynamic field mapping?
- JSONata is a Turing-complete functional query and transformation language for JSON. Because a JSONata mapping is just a string, it can be stored in a database, versioned, and hot-swapped without code deploys, allowing dynamic per-tenant mapping at runtime.
- How does a 3-level mapping override hierarchy work?
- It allows mappings to be defined at the base platform level, customized for specific deployment environments, and overridden for individual integrated accounts. At request time, the three levels are deep-merged per mapping key without modifying the underlying codebase.
- Does dynamic mapping handle upstream API rate limits?
- Yes, but not by absorbing them. The platform passes HTTP 429 errors through to the caller unchanged, while normalizing provider-specific rate limit metadata into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) so the caller can properly handle retries.
- How do you add a new integration in a declarative architecture?
- You author a JSON config describing the API and a set of JSONata expressions mapping unified resources to the provider's native format. Both are stored in the database. No code deploy is needed because the generic runtime executes the new configuration identically.