Skip to content

Developer Guide: JSONata Mapping Examples for API Integration (2026)

Concrete JSONata mapping examples for unified API integration: split names, normalize enums, flatten nested objects, map reads and writes, and test.

Uday Gajavalli Uday Gajavalli · · 25 min read
Developer Guide: JSONata Mapping Examples for API Integration (2026)

If you need to normalize API schemas across dozens of SaaS providers without writing a dedicated Python or JavaScript adapter for each one, you need a declarative transformation layer. If you're searching for JSONata mapping examples to normalize API data, you're probably maintaining a growing pile of per-provider transformation scripts and feeling the pain.

This guide provides concrete, working code samples you can adapt today—extracting nested fields, mapping polymorphic arrays, handling conditional custom fields, and normalizing error responses—all with declarative JSONata expressions instead of brittle, hardcoded scripts.

Most unified API platforms only handle pre-defined standard objects - contacts, deals, companies. The moment your customer connects a Salesforce org with Invoice__c or Project_Milestone__c, you're stuck. The standardized data model doesn't cover it, and you're back to writing per-customer code. This guide includes a full end-to-end walkthrough of mapping a real Salesforce custom object - both GET and POST flows - so you can see exactly how a declarative approach handles objects that no fixed schema anticipates.

Building a single API connector is straightforward. Maintaining 50 of them is an engineering nightmare. Every time a vendor deprecates an endpoint, changes a pagination strategy, or introduces a new custom object, your team has to open the codebase, update the specific API adapter, write new tests, and deploy a fix.

By treating API integration as a data transformation problem rather than a software engineering problem, you can ship connectors faster, eliminate integration-specific code, and empower your organization to handle custom enterprise requirements on the fly.

Why Code-First API Mapping Fails at Scale

Most engineering teams start building integrations using the strategy pattern. They define a common interface in their application and write a separate adapter class for every third-party API. Every B2B SaaS integration starts innocently. You write a mapHubSpotContact() function. It works. Then you add Salesforce, and you write mapSalesforceContact(). Then Pipedrive, Zoho, Close, Dynamics 365. Each function handles that provider's specific field names, nesting patterns, date formats, and phone number layouts.

Behind the scenes, this architecture relies on brute force. The codebase becomes littered with conditional logic: if (provider === 'hubspot') { ... } else if (provider === 'salesforce') { ... }. You end up maintaining integration-specific database columns, dedicated handler functions, and hardcoded business logic that must be updated every time an upstream API changes.

Within two years, you're maintaining dozens of transformation scripts with nearly identical logic but completely different implementations. Industry research indicates that API integrations can range from $2,000 for simple setups to more than $30,000, with ongoing annual costs of $50,000 to $150,000 for staffing and maintenance. Most of that ongoing cost is not building new features. It's updating existing mapping code when vendors alter their response payloads.

The code-first approach breaks in three specific ways:

  • Linear maintenance burden: Each new integration adds a new code path that must be independently tested, reviewed, and deployed. Bug fixes don't propagate across providers.
  • Deployment coupling: A mapping change for one provider requires a full CI/CD cycle, even if the fix is simply changing properties.firstname to properties.first_name.
  • No customization path for customers: When an enterprise prospect has 147 custom fields on their Salesforce Contact object, your engineering team must write per-customer code. That doesn't scale past a handful of accounts.

This is why schema normalization is the hardest problem in SaaS integrations—and it's the single biggest bottleneck for engineering teams. The moment you deploy your code, an enterprise customer will connect a Salesforce instance containing custom fields that your static types do not understand.

What Is JSONata? The Declarative Alternative

To escape the maintenance trap of code-first integrations, modern architectures use an interpreter pattern. Instead of writing code to translate data, you write a configuration that describes the translation, and a generic engine executes it.

JSONata is a functional query and transformation language designed specifically for JSON data.

Created by Andrew Coleman and his colleagues at IBM in 2016, JSONata is an open-source query language inspired by the location path semantics of XPath 3.1. It lets you express complex data reshaping in compact, readable expressions rather than procedural code. Think of it as a Turing-complete expression language purpose-built for reshaping JSON objects.

Instead of writing 20 lines of JavaScript with .map(), .filter(), optional chaining, and null checks, you write a single JSONata expression that declares the shape of the output. The runtime handles traversal, null safety, and type coercion. Unlike jq, which is primarily designed for filtering data in command-line environments, JSONata is designed to construct entirely new JSON structures.

It is widely adopted in enterprise orchestration and IoT platforms. IBM App Connect Enterprise uses a JSONata Mapping node in message flows to build messages in JSON format. Node-RED, a flow-based low-code development tool originally developed by IBM, embeds JSONata natively for data transformation. Orchestration platforms like Kestra call JSONata the "Swiss Army Knife" for JSON transformation, and Blues Wireless embeds it to manipulate data in real-time edge computing workflows.

The key properties that make JSONata ideal for API integration include:

Property Why It Matters for Integration
Declarative Describe the output shape, not the step-by-step procedure to build it.
Null-safe Missing fields return undefined silently without throwing null reference errors or crashing the application.
Turing-complete Supports conditionals, recursion, custom functions, and lambdas.
Side-effect free Expressions are pure functions. They transform input to output without modifying external application state.
Storable as data An expression is just a string. It can be stored in a database column, versioned, overridden, and hot-swapped without restarting the application.

Because a JSONata expression is just a string, it can be stored as configuration data rather than compiled code. This opens the door to hot-swappable API integrations that do not require code deploys.

Common Transform Patterns

Before working through full examples, here's a quick reference for the JSONata patterns you'll use most often when mapping fields between a unified API and provider-specific schemas. Each pattern is a pure function - no side effects, no state - which makes it safe to store as configuration and hot-swap at runtime.

Pattern JSONata Snippet Use Case
Rename a field { "first_name": FirstName } PascalCase to snake_case
Split a string $split(full_name, " ") Full name into first and last
Join values $join([first, last], " ") Compose display name
Concatenate first & " " & last String templating
Conditional include phone ? {"number": phone} Skip null values in arrays
Enum lookup $lookup($map, status) Provider enum to unified enum
Regex match $match(field, /pattern/i) Detect field types by pattern
Filter array $filter(arr, function($v){$v.field}) Drop empty entries
Sift keys $sift($, function($v,$k){$k~>/__c$/}) Capture custom fields dynamically
Type coercion $number(str), $string(num) Normalize types across providers
Date normalization $fromMillis($number(ts) * 1000) Unix seconds to ISO 8601
Null coalescing $firstNonEmpty(a, b, c) Fallback across candidate fields
Recursive descent $**.value Walk every descendant at any depth
Deep merge $merge([defaults, overrides]) Layer configurations

Bookmark this table. Roughly 80% of real-world API mapping work is some combination of the patterns above.

JSONata Mapping Examples for API Integration

The following JSONata mapping examples demonstrate how to solve the most common and painful API integration challenges without writing backend code. All examples can be tested in the JSONata Exerciser online.

Example 1: Extracting and Flattening Nested CRM Contact Fields

Many APIs nest core data inside arbitrary wrapper objects. HubSpot, for instance, places contact data inside a properties object. If you want to map this into a clean, flat unified schema, JSONata handles it natively.

HubSpot API response (input):

{
  "id": "501",
  "properties": {
    "firstname": "Jane",
    "lastname": "Martinez",
    "jobtitle": "VP Engineering",
    "email": "jane@acme.com",
    "phone": "+1-555-0199",
    "mobilephone": "+1-555-0200"
  },
  "createdAt": "2024-03-15T10:30:00Z",
  "updatedAt": "2025-11-20T14:15:00Z"
}

JSONata expression:

{
  "id": id,
  "first_name": properties.firstname,
  "last_name": properties.lastname,
  "title": properties.jobtitle,
  "email_addresses": [
    properties.email ? {"email": properties.email, "is_primary": true}
  ],
  "phone_numbers": [
    properties.phone ? {"number": properties.phone, "type": "work"},
    properties.mobilephone ? {"number": properties.mobilephone, "type": "mobile"}
  ],
  "created_at": createdAt,
  "updated_at": updatedAt
}

Output:

{
  "id": "501",
  "first_name": "Jane",
  "last_name": "Martinez",
  "title": "VP Engineering",
  "email_addresses": [{"email": "jane@acme.com", "is_primary": true}],
  "phone_numbers": [
    {"number": "+1-555-0199", "type": "work"},
    {"number": "+1-555-0200", "type": "mobile"}
  ],
  "created_at": "2024-03-15T10:30:00Z",
  "updated_at": "2025-11-20T14:15:00Z"
}

Notice the ternary pattern: properties.phone ? {"number": properties.phone, "type": "work"}. If phone is absent, the entire object is omitted from the array. No if statements, no null checks, and no application crashes.

Example 2: Mapping Polymorphic Arrays (Multiple Phone Number Types)

Salesforce takes the opposite approach to HubSpot. Instead of a nested object, it returns flat PascalCase fields. Worse, it returns up to six separate phone fields (Phone, MobilePhone, Fax, etc.). You need them in a single phone_numbers array, but only the ones that actually have values.

Salesforce API response (input):

{
  "Id": "003xx000004TmiY",
  "FirstName": "John",
  "LastName": "Doe",
  "Phone": "+1-555-0101",
  "Fax": null,
  "MobilePhone": "+1-555-0102",
  "HomePhone": null,
  "OtherPhone": "+1-555-0103",
  "AssistantPhone": null
}

JSONata expression:

{
  "id": Id,
  "first_name": FirstName,
  "last_name": LastName,
  "name": $join([FirstName, LastName], " "),
  "phone_numbers": $filter([
    {"number": Phone, "type": "work"},
    {"number": Fax, "type": "fax"},
    {"number": MobilePhone, "type": "mobile"},
    {"number": HomePhone, "type": "home"},
    {"number": OtherPhone, "type": "other"},
    {"number": AssistantPhone, "type": "assistant"}
  ], function($v) { $v.number })
}

The $filter function with a predicate function($v) { $v.number } drops any entry where number is null or undefined. This handles the variability of Salesforce's six phone fields in a single expression—no loops, no conditionals.

Example 3: Conditional Custom Field Detection

Enterprise software is highly customized. Your unified schema might cover 20 standard fields, but the customer's Salesforce instance has 50 custom fields ending in __c. You need to capture these dynamically without knowing them in advance.

JSONata expression:

{
  "id": Id,
  "first_name": FirstName,
  "last_name": LastName,
  "standard_fields": {"email": Email, "phone": Phone},
  "custom_fields": $sift($, function($v, $k) {
    $k ~> /__c$/i and $boolean($v)
  })
}

Given an input with arbitrary custom fields, the $sift function scans every key in the root object ($). The regex /__c$/i matches Salesforce custom field names. The $boolean($v) check skips null or empty values. All matching key-value pairs are swept into the custom_fields object dynamically.

For APIs like HubSpot that don't use a specific suffix, you can use the $difference function to subtract your known default fields from the total list of keys, placing whatever remains into the custom fields object. This is the exact mechanism that unlocks mapping custom objects without per-customer code.

Example 4: Enum Normalization Across Providers

Different providers use different strings for the same concept. Mapping enums declaratively is where JSONata really shines over handwritten switch statements.

JSONata expression:

(
  $statusMap := {
    "ACTIVE": "active",
    "Active": "active",
    "active": "active",
    "INACTIVE": "inactive",
    "Inactive": "inactive",
    "Disabled": "inactive",
    "SUSPENDED": "suspended",
    "On Leave": "on_leave",
    "ON_LEAVE": "on_leave"
  };
 
  {
    "id": employee.id,
    "name": employee.display_name,
    "status": $lookup($statusMap, employee.status)
  }
)

The lookup table is data, not logic. When a new HRIS provider uses "TERMINATED" instead of "INACTIVE", you add one line to the map object. No code change, no deployment.

Example 5: Remapping HTTP Status Codes and Errors

APIs return errors in wildly different formats. Slack, for example, returns a 200 OK HTTP status for almost everything, signaling errors inside the JSON body ({"ok": false, "error": "invalid_auth"}). If your system relies on HTTP status codes, Slack errors will silently pass through as successes.

We can use JSONata to inspect the response body and remap the HTTP status code before it reaches the application logic.

JSONata expression (Error Mapping):

$not(data.ok) ? {
  "status": $lookup({
    "invalid_auth": 401,
    "token_expired": 401,
    "missing_scope": 403,
    "ratelimited": 429,
    "channel_not_found": 404,
    "internal_error": 500
  }, data.error),
  "message": data.error
}

This expression only fires when data.ok is false. The expression uses $lookup to map the specific Slack error string to the correct standard HTTP status code. If data.ok is true, the ternary evaluates to undefined, and the system falls through to normal processing.

Example 6: Splitting a Full Name into First and Last

Many APIs return a single full_name or name field. Your unified schema separates first and last names. JSONata handles this cleanly with $split.

Provider response (input):

{
  "id": "usr_881",
  "full_name": "Maria Elena Rodriguez",
  "email": "maria@example.com"
}

JSONata expression:

(
  $parts := $split(full_name, " ");
  $n := $count($parts);
  {
    "id": id,
    "first_name": $parts[0],
    "last_name": $n > 1 ? $parts[$n - 1] : null,
    "middle_name": $n > 2 ? $join($parts[[1..$n-2]], " ") : null,
    "email": email
  }
)

Unified response (output):

{
  "id": "usr_881",
  "first_name": "Maria",
  "last_name": "Rodriguez",
  "middle_name": "Elena",
  "email": "maria@example.com"
}

This single expression handles mononyms ("Cher"), two-part names ("Jane Doe"), and multi-part names ("Maria Elena Rodriguez") without conditional application code. The [[1..$n-2]] range slice captures anything between the first and last token as the middle name.

For the reverse direction (unified to provider), concatenate the parts:

{
  "full_name": $join(
    $filter([body.first_name, body.middle_name, body.last_name], function($v){ $v }),
    " "
  ),
  "email": body.email
}

Example 7: Numeric Status Code to String Enum

Legacy APIs often use integer status codes. Your unified schema exposes readable strings. This is a two-way mapping problem where the same lookup table serves reads and writes with inverted keys and values.

Provider response (input):

{
  "user_id": 4471,
  "name": "Alex Chen",
  "status_code": 2,
  "role_id": 3
}

JSONata expression (read: provider to unified):

(
  $statusMap := {"1": "active", "2": "inactive", "3": "suspended", "4": "pending"};
  $roleMap := {"1": "admin", "2": "manager", "3": "member", "4": "guest"};
  {
    "id": $string(user_id),
    "name": name,
    "status": $lookup($statusMap, $string(status_code)),
    "role": $lookup($roleMap, $string(role_id))
  }
)

Notice $string(status_code) before the lookup. JSONata object keys are always strings, but the provider returns integers. Coerce first, then look up. Missing keys return undefined, which serializes cleanly to null in most JSON serializers.

Unified response (output):

{
  "id": "4471",
  "name": "Alex Chen",
  "status": "inactive",
  "role": "member"
}

JSONata expression (write: unified to provider):

(
  $statusMap := {"active": 1, "inactive": 2, "suspended": 3, "pending": 4};
  $roleMap := {"admin": 1, "manager": 2, "member": 3, "guest": 4};
  {
    "user_id": $number(body.id),
    "name": body.name,
    "status_code": $lookup($statusMap, body.status),
    "role_id": $lookup($roleMap, body.role)
  }
)

The read and write expressions are mirror images. That symmetry makes them easy to review together and validates that no mapping is lossy.

Example 8: Flattening Deeply Nested Custom Objects

Some APIs bury custom fields in deeply nested structures. NetSuite wraps custom fields inside customFieldList.customField [] arrays. Zoho uses customFields.field_name.value. You need these at the top level of your unified response.

Provider response (input):

{
  "recordId": "10023",
  "name": "Q3 Renewal Deal",
  "customFieldList": {
    "customField": [
      { "scriptId": "custentity_deal_source", "value": "Referral" },
      { "scriptId": "custentity_close_probability", "value": 0.75 },
      { "scriptId": "custentity_owner_region", "value": "APAC" }
    ]
  },
  "metadata": {
    "audit": {
      "created": {
        "timestamp": 1728000000,
        "actor": { "id": "u_42", "email": "sales@acme.com" }
      }
    }
  }
}

JSONata expression:

{
  "id": recordId,
  "name": name,
  "custom_fields": $merge(
    customFieldList.customField.{ scriptId: value }
  ),
  "created_at": $fromMillis(metadata.audit.created.timestamp * 1000),
  "created_by": {
    "id": metadata.audit.created.actor.id,
    "email": metadata.audit.created.actor.email
  }
}

Unified response (output):

{
  "id": "10023",
  "name": "Q3 Renewal Deal",
  "custom_fields": {
    "custentity_deal_source": "Referral",
    "custentity_close_probability": 0.75,
    "custentity_owner_region": "APAC"
  },
  "created_at": "2024-10-04T00:00:00.000Z",
  "created_by": {
    "id": "u_42",
    "email": "sales@acme.com"
  }
}

Two techniques worth calling out:

  1. Array-to-object collapse: customFieldList.customField.{ scriptId: value } produces an array of single-key objects, and $merge combines them into one object with all custom fields hoisted to the top level.
  2. Path flattening with type conversion: metadata.audit.created.timestamp traverses arbitrary nesting depth in a single dotted path. $fromMillis converts Unix seconds to ISO 8601 in one call.

For truly recursive descent - when you don't know the depth in advance - JSONata's ** operator walks every descendant. $**.email returns every email value anywhere in the object, at any depth.

End-to-End: Mapping a Salesforce Custom Object (Invoice__c)

The examples above cover individual mapping patterns in isolation. Now let's put them together in a complete custom object mapping - both reading and writing - for an object that no pre-built unified API schema covers.

This is the scenario that breaks most unified API platforms. Standard models define contacts, deals, and companies. But your customer's Salesforce org has Invoice__c, Project_Milestone__c, or Compliance_Record__c. These custom objects don't exist in any vendor's standardized data model. A unified API for custom objects needs to handle them without requiring code changes per object or per customer.

With JSONata mapping stored as configuration, you define the translation rules once and the generic engine handles the custom object identically to any standard resource. No new code paths, no conditional branches.

The Scenario

Your customer tracks invoices in a Salesforce custom object Invoice__c. Your application needs to list and create invoices through your unified API. The Salesforce object has these fields:

Salesforce Field Type Description
Id String Standard Salesforce record ID
Name String Auto-number (e.g., INV-00042)
Invoice_Number__c String Human-readable invoice number
Customer__c Lookup Reference to Account
Customer__r.Name String Related Account name (via relationship)
Invoice_Date__c Date Invoice issue date
Due_Date__c Date Payment due date
Total_Amount__c Currency Invoice total
Currency_Code__c String ISO currency code
Status__c Picklist Draft, Sent, Paid, Overdue
Notes__c Text Free-text notes
Payment_Terms__c String e.g., "Net 30"

Your unified API needs to expose these as GET /unified/accounting/invoices and POST /unified/accounting/invoices.

GET Flow: Reading Invoices

Salesforce Response (Before Mapping)

This is the raw JSON that Salesforce returns for a single Invoice__c record:

{
  "Id": "a01B000000ABCDE",
  "Name": "INV-00042",
  "Invoice_Number__c": "INV-2025-0042",
  "Customer__c": "001B000001XYZ99",
  "Customer__r": {
    "Id": "001B000001XYZ99",
    "Name": "Acme Corp"
  },
  "Invoice_Date__c": "2025-06-15",
  "Due_Date__c": "2025-07-15",
  "Total_Amount__c": 15750.00,
  "Currency_Code__c": "USD",
  "Status__c": "Sent",
  "Notes__c": "Q2 consulting services",
  "Payment_Terms__c": "Net 30",
  "CreatedDate": "2025-06-10T08:00:00Z",
  "LastModifiedDate": "2025-06-15T14:30:00Z"
}

PascalCase fields, lookup relationships via __r, custom fields ending in __c, and standard audit timestamps. Every Salesforce custom object follows this pattern.

Response Mapping Expression

(
  $knownCustom := ["Invoice_Number__c", "Customer__c",
    "Invoice_Date__c", "Due_Date__c", "Total_Amount__c",
    "Currency_Code__c", "Status__c", "Notes__c"];
 
  response.{
    "id": Id,
    "invoice_number": Invoice_Number__c,
    "customer": {
      "id": Customer__c,
      "name": Customer__r.Name
    },
    "invoice_date": Invoice_Date__c,
    "due_date": Due_Date__c,
    "total_amount": Total_Amount__c,
    "currency": Currency_Code__c,
    "status": $lowercase(Status__c),
    "notes": Notes__c,
    "created_at": CreatedDate,
    "updated_at": LastModifiedDate,
    "custom_fields": $sift($, function($v, $k) {
      $k ~> /__c$/i
      and $not($k in $knownCustom)
      and $boolean($v)
    })
  }
)

Three things worth noting:

  1. Lookup flattening: Customer__r.Name pulls the related Account name through Salesforce's relationship field, collapsing a nested lookup into a simple customer object.
  2. Enum normalization: $lowercase(Status__c) converts Salesforce's title-case picklist values ("Sent") to lowercase ("sent") for consistency across providers.
  3. Dynamic custom field capture: The $sift block collects every __c field not in the $knownCustom list. When this customer later adds Tax_Rate__c or PO_Number__c to their org, those fields automatically appear in custom_fields without any mapping change.

Unified Response (After Mapping)

{
  "id": "a01B000000ABCDE",
  "invoice_number": "INV-2025-0042",
  "customer": {
    "id": "001B000001XYZ99",
    "name": "Acme Corp"
  },
  "invoice_date": "2025-06-15",
  "due_date": "2025-07-15",
  "total_amount": 15750.00,
  "currency": "USD",
  "status": "sent",
  "notes": "Q2 consulting services",
  "created_at": "2025-06-10T08:00:00Z",
  "updated_at": "2025-06-15T14:30:00Z",
  "custom_fields": {
    "Payment_Terms__c": "Net 30"
  }
}

Payment_Terms__c wasn't in the known fields list, so it landed in custom_fields automatically. The caller gets a clean, predictable structure regardless of how many custom fields exist in the customer's Salesforce org.

POST Flow: Creating an Invoice

Unified Request Body

Your application sends a normalized create request:

{
  "invoice_number": "INV-2025-0099",
  "customer_id": "001B000001XYZ99",
  "invoice_date": "2025-08-01",
  "due_date": "2025-09-01",
  "total_amount": 8500.00,
  "currency": "USD",
  "status": "draft",
  "notes": "August retainer"
}

Request Body Mapping Expression

{
  "Invoice_Number__c": body.invoice_number,
  "Customer__c": body.customer_id,
  "Invoice_Date__c": body.invoice_date,
  "Due_Date__c": body.due_date,
  "Total_Amount__c": body.total_amount,
  "Currency_Code__c": body.currency,
  "Status__c": $lookup(
    {"draft": "Draft", "sent": "Sent", "paid": "Paid", "overdue": "Overdue"},
    body.status
  ),
  "Notes__c": body.notes
}

The reverse enum mapping uses $lookup to convert your normalized lowercase status back to Salesforce's expected picklist value. "draft" becomes "Draft", matching the picklist definition in the customer's org.

Salesforce Request Body (After Mapping)

The generic engine evaluates the expression and sends this to Salesforce's REST API:

{
  "Invoice_Number__c": "INV-2025-0099",
  "Customer__c": "001B000001XYZ99",
  "Invoice_Date__c": "2025-08-01",
  "Due_Date__c": "2025-09-01",
  "Total_Amount__c": 8500.00,
  "Currency_Code__c": "USD",
  "Status__c": "Draft",
  "Notes__c": "August retainer"
}

Salesforce returns the created record's Id, which the response mapping then normalizes back into the unified format using the same GET response mapping expression.

Verification and Testing Checklist

Before deploying a custom object mapping to production:

  1. Test expressions in isolation. Paste the JSONata expression and sample input into the JSONata Exerciser. Verify the output matches your expected unified schema.
  2. Test with null and missing fields. Remove optional fields (Notes__c, Payment_Terms__c) from the sample input. Confirm the expression returns undefined for those fields rather than throwing an error.
  3. Verify custom field capture. Add an unexpected __c field to the sample input (e.g., "Tax_Rate__c": 0.08). Confirm it appears in custom_fields and not in the mapped fields.
  4. Round-trip test. Take the GET response output, use its values to construct a POST request body, run it through the request body mapping, and verify the result is valid Salesforce JSON.
  5. Validate enum completeness. Check that your $lookup maps cover every possible picklist value in the customer's Salesforce org. A missing value produces undefined, which Salesforce may reject.
  6. Test against a sandbox first. Use the Proxy API to make a raw GET call to the Salesforce org and inspect the actual response shape. Compare it against your sample input to catch fields you didn't anticipate.

When to Prefer Proxy API Over Unified Mapping

Not every custom object needs a mapping layer. The Proxy API passes requests directly to the third-party API using the same authentication and connection management, but skips all JSONata transformation. The caller sends integration-native field names and receives the raw response.

Use Proxy API when:

  • Single-provider usage. The custom object exists in only one integration. There's no normalization benefit if you'll never map the same concept across multiple providers.
  • Your app already speaks the provider's format. If your frontend or backend already handles Salesforce field names, adding a mapping layer is overhead with no payoff.
  • The object schema is too volatile. If the customer's admin adds and removes fields weekly, maintaining a stable mapping becomes a burden. Raw pass-through avoids the problem.
  • You're exploring the API. Before designing a mapping, use the Proxy API to inspect the real response shape. This is faster than guessing at field names from documentation.

Use unified mapping when:

  • Multiple providers expose the same concept. If three CRMs all have invoice-like objects with different field names, a unified mapping lets your app code stay provider-agnostic.
  • You need per-customer overrides. The 3-level override hierarchy only works with unified mappings. Proxy API has no override mechanism.
  • You want schema stability. A unified mapping insulates your app from upstream API changes. If Salesforce renames a field, you update one mapping expression instead of every callsite in your codebase.

Read vs Write Mappings: Bidirectional Field Translation

Most integration tutorials focus on reading data. Writing is the mirror problem, and it's where many teams get stuck. If your unified schema uses first_name and the provider expects FirstName, you need two mappings: one for GET and one for POST/PATCH.

The good news: JSONata handles both directions with identical syntax. What changes is the input variable the expression evaluates against.

Direction Input Binding Purpose
Read (provider to unified) response, headers, query Normalize outgoing response into your unified schema
Write (unified to provider) body, context, query Build a provider-native payload from a unified request

A Compact Bidirectional Example

Suppose your unified schema exposes { id, name, status, owner } and the provider expects { Id, Name, Status__c, OwnerId }.

Read mapping (GET response):

response.{
  "id": Id,
  "name": Name,
  "status": $lowercase(Status__c),
  "owner": { "id": OwnerId }
}

Write mapping (POST request):

{
  "Name": body.name,
  "Status__c": $lookup(
    {"active": "Active", "inactive": "Inactive"},
    body.status
  ),
  "OwnerId": body.owner.id
}

Note what's absent from the write mapping: Id. Provider-generated identifiers are never sent in create requests - the provider assigns them. Read mappings surface identifiers; write mappings omit them.

Handling Partial Updates (PATCH)

PATCH is trickier than POST. You only want to send fields the caller explicitly provided, not every field in your schema. Use $exists to conditionally include fields:

$merge([
  $exists(body.name) ? { "Name": body.name },
  $exists(body.status) ? {
    "Status__c": $lookup({"active": "Active", "inactive": "Inactive"}, body.status)
  },
  $exists(body.owner.id) ? { "OwnerId": body.owner.id }
])

Each ternary evaluates to either an object or undefined. $merge combines the truthy results into a single payload. Fields the caller didn't send stay out of the request entirely - the provider doesn't overwrite them with nulls.

Round-Trip Consistency

The best sanity check for a read/write mapping pair: take a GET response, feed its values into your create mapping, and verify the result matches what the provider would accept. If your unified schema exposes status: "active" but your read mapping produces status: "Active" while your write mapping expects status: "active", you have an inconsistency that will bite you eventually.

Testing JSONata Expressions: Unit Tests and Verification

Because JSONata expressions are pure functions from JSON input to JSON output, they're straightforward to unit-test. Every mapping you deploy to production should have coverage for the happy path, missing fields, and edge cases.

The Fast Feedback Loop: JSONata Exerciser

For quick iteration, paste your expression and sample input into the JSONata Exerciser. It renders output in real time as you type. Use this to validate the shape of your mapping before committing anything to code.

Unit Testing with Vitest or Jest

The jsonata npm package works out of the box in any Node.js test runner. A typical test suite for a mapping expression:

import { describe, test, expect } from 'vitest'
import jsonata from 'jsonata'
 
const contactMapping = `
  response.{
    "id": Id,
    "first_name": FirstName,
    "last_name": LastName,
    "email": Email
  }
`
 
describe('Salesforce contact mapping', () => {
  test('maps a fully populated contact', async () => {
    const input = {
      response: {
        Id: '003xx000004',
        FirstName: 'Jane',
        LastName: 'Doe',
        Email: 'jane@acme.com'
      }
    }
    const result = await jsonata(contactMapping).evaluate(input)
    expect(result).toEqual({
      id: '003xx000004',
      first_name: 'Jane',
      last_name: 'Doe',
      email: 'jane@acme.com'
    })
  })
 
  test('omits missing optional fields', async () => {
    const input = {
      response: { Id: '003xx000005', FirstName: 'John' }
    }
    const result = await jsonata(contactMapping).evaluate(input)
    expect(result.first_name).toBe('John')
    expect(result.last_name).toBeUndefined()
    expect(result.email).toBeUndefined()
  })
 
  test('handles null values without throwing', async () => {
    const input = {
      response: {
        Id: '003xx000006',
        FirstName: null,
        LastName: 'Smith'
      }
    }
    const result = await jsonata(contactMapping).evaluate(input)
    expect(result).toEqual({
      id: '003xx000006',
      last_name: 'Smith'
    })
  })
})

For every mapping expression you deploy, cover these cases:

  1. Happy path - fully populated input produces the expected unified shape
  2. Missing optional fields - fields that can legitimately be absent from the payload
  3. Null values - explicit null in the payload (semantically different from missing)
  4. Empty arrays - [] where an array is expected
  5. Deeply nested absence - properties.address when properties itself is missing
  6. Round-trip - GET output fed into a POST mapping produces valid provider JSON
  7. Enum boundary - every picklist value maps cleanly; unknown values fall back predictably
  8. Custom field capture - unexpected fields land in custom_fields, not silently dropped

Snapshot Testing for Complex Mappings

For mappings with 40+ fields (typical for CRM contacts), maintaining .toEqual assertions gets tedious. Snapshot tests keep the signal without the maintenance overhead:

test('salesforce contact snapshot', async () => {
  const result = await jsonata(contactMapping).evaluate(fixtureInput)
  expect(result).toMatchSnapshot()
})

Commit the snapshot file to git. Any accidental mapping change surfaces as a snapshot diff in code review, forcing the author to explicitly acknowledge the behavior change.

Troubleshooting Common Mapping Errors

JSONata's biggest strength - silent handling of missing data - is also its biggest debugging challenge. Here are the failure modes you'll hit most often and how to diagnose them.

Silent undefined from Typos

Symptom: Your mapping produces the correct shape for most fields, but one specific field is always undefined in production.

Cause: A typo in the field name. JSONata returns undefined for missing paths rather than throwing. response.Firstname (lowercase n) silently returns nothing when the provider actually returns response.FirstName.

Fix: Log the raw input alongside the mapped output for a handful of records. Compare field names character by character. Consider a defensive assertion for fields you expect to always be present:

{
  "id": Id ? Id : $error("Missing required Id field")
}

Field Names with Special Characters

Symptom: A valid-looking JSONata path returns undefined for a field that clearly exists in the input.

Cause: Field names containing hyphens, spaces, dots, or other special characters can't be used as bare identifiers. response.knowledge-base.pages is parsed as response.knowledge minus base.pages.

Fix: Wrap the identifier in backticks:

response.`knowledge-base`.pages

The same rule applies to keys starting with digits, containing spaces, or matching JSONata reserved words.

Array Context Confusion

Symptom: You expect a mapping to apply to each item in an array, but the result is a single object with array-of-arrays inside.

Cause: JSONata's implicit array flattening can surprise you. If the input is { "items": [{...}, {...}] } and you write items.{ "id": id }, JSONata iterates for you. But if the input is already [{...}, {...}] and you write { "id": id }, the expression applies once to the whole array, returning { "id": undefined }.

Fix: Know your input shape. When mapping list responses, most unified API engines evaluate the expression against each item individually. Check the surrounding context - if the engine iterates for you, don't add another iteration inside the expression.

Ternary Without Else Returns Undefined

Symptom: A mapped object is missing a field entirely for records where a condition is false.

Cause: condition ? value with no else branch evaluates to undefined when the condition is false. The key is then dropped from the parent object.

Fix: This is usually what you want (missing fields stay out of the output). If you need an explicit fallback, add the else branch:

status ? $lowercase(status) : "unknown"

Type Coercion Surprises

Symptom: A comparison that should match doesn't. Or a mapping produces the literal string "undefined" instead of an actual missing value.

Cause: JSONata is loosely typed but doesn't auto-coerce for comparisons. "1" = 1 is false. And string concatenation with & will happily convert undefined to the literal string "undefined".

Fix: Coerce explicitly:

$string(user_id) = "42"
first & (last ? " " & last : "")

$lookup Returns Undefined for Numeric Keys

Symptom: An enum lookup works in the JSONata Exerciser but fails at runtime.

Cause: The Exerciser converts numeric keys to strings automatically. At runtime, if the provider returns integer values, $lookup({"1": "active"}, 1) fails because the map key is the string "1" and the search value is the integer 1.

Fix: Always coerce the search value: $lookup($map, $string(status_code)).

Expression Compiles but Throws at Runtime

Symptom: Local testing passes, but production traffic triggers evaluation errors.

Cause: Real API responses have edge cases your fixtures didn't cover - null where you expected an object, an empty string where you expected a number, an array where you expected a single value.

Fix: Wrap risky operations in existence checks. Use $exists, $boolean, and $type guards before calling functions that assume a specific shape:

$type(created_at) = "string" ? $toMillis(created_at) : null

If a mapping expression itself throws, most unified API engines surface an internal error and halt the request rather than returning partial data. Silent failures corrupt downstream systems; loud failures at least tell you something's wrong.

Handling Edge Cases: Pagination, Errors, and Rate Limits

Mapping response fields is only half the integration story. When you move integration logic to a declarative configuration, you must also handle the operational realities of interacting with third-party APIs.

Rate Limit Transparency

Third-party rate limits are notoriously inconsistent. HubSpot uses X-HubSpot-RateLimit-Daily-Remaining. Salesforce uses custom headers. Some APIs don't expose rate limit info at all. A major interoperability issue in throttling is the lack of standard headers.

The IETF's draft-ietf-httpapi-ratelimit-headers specification defines standardized ratelimit-limit, ratelimit-remaining, and ratelimit-reset header fields to address this fragmentation. When building a unified API engine, it is critical to normalize this information.

At Truto, the engine extracts the provider-specific data and normalizes it into these standard IETF headers. When an upstream API returns an HTTP 429 error, the platform passes the 429 error directly to the caller alongside the normalized headers. This ensures the calling application maintains complete control over its own retry and backoff logic, rather than having the integration layer silently absorb and block requests.

Pagination Strategy as Configuration

Different APIs paginate differently—cursor-based, page-number, offset, Link headers. In a code-first approach, each pagination style needs its own implementation per provider. With a declarative model, the pagination strategy itself becomes a configuration field:

{
  "pagination": {
    "format": "cursor",
    "config": {
      "cursor_field": "paging.next.after",
      "cursor_param": "after"
    }
  }
}

The generic execution engine reads this config and applies the right strategy, completely eliminating per-integration pagination loops.

Multi-Step Orchestration

Sometimes a single unified request requires multiple API calls. For example, creating a contact and immediately associating it with a company. Declarative configurations handle this using pre-request and post-request step arrays. A before step can execute a JSONata expression to fetch a list of custom fields, store them in state, and then execute the main request. An after step can take the newly created entity ID and make a subsequent API call to establish a relationship. All of this orchestration happens in the generic execution pipeline without custom backend code.

Moving from Code to Configuration: The Architectural Shift

The pattern across all these examples is the same: move integration logic from compiled code to declarative data. JSONata expressions are strings. Strings can be stored in a database. Database records can be updated without a deployment.

flowchart LR
    A[Unified API Request] --> B[Generic Engine]
    B --> C{Read Config}
    C --> D[JSONata Query Mapping]
    C --> E[JSONata Response Mapping]
    C --> F[Pagination Strategy]
    D --> G[Third-Party API Call]
    G --> E
    E --> H[Normalized Response]

When integration behavior is entirely data-driven, adding a new connector is a data operation. You insert a JSON configuration describing the API endpoints and a set of JSONata expressions describing the data mapping. The exact same generic execution engine handles a HubSpot CRM contact listing and a Salesforce CRM contact listing. The differences are all captured in JSONata expressions stored as data.

This architecture also enables per-customer customization through a 3-level override hierarchy:

  1. Platform Base: The default JSONata mapping that works for most customers.
  2. Environment Override: Modifications applied to a specific customer environment.
  3. Account Override: Deeply specific tweaks applied to a single integrated account.

If an enterprise customer needs a specific custom field mapped to a non-standard location, a product manager can write a JSONata override for that specific account and save it to the database. The 3-level API mapping system deep-merges the configurations at runtime. The customer gets their custom integration immediately, and the engineering team never touches a pull request.

The Honest Trade-offs

JSONata is not without costs. That elegant syntax and power are expensive in terms of performance. Native JavaScript will always be faster for raw throughput. The richness of JSONata's expression language comes with a steeper learning curve, especially for users unfamiliar with functional programming concepts.

However, for most API integration workloads—mapping individual response objects, transforming query parameters, and normalizing small JSON payloads—the performance overhead is negligible. You are typically transforming single records or small pages, not processing millions of rows concurrently.

Debugging is the other real pain point. JSONata doesn't throw errors for missing fields; it returns undefined. A typo in a field name produces silence, not a stack trace. You must mitigate this by validating incoming data schemas and extensively unit-testing every expression.

What This Means for Your Integration Strategy

If your team is still writing if (provider === 'hubspot') branches, you're accumulating technical debt that compounds with every new integration. The shift from imperative mapping code to declarative JSONata expressions isn't just a style preference—it's an architectural decision that determines whether your integration layer scales linearly or exponentially with the number of providers.

Start small. Pick one mapping function in your codebase and rewrite it as a JSONata expression. Store that expression in your database instead of your source code. Measure how long the next mapping change takes: updating a database record versus going through a full PR, CI, and deployment cycle. The results will speak for themselves.

FAQ

What is JSONata and how is it used for API data mapping?
JSONata is a lightweight, functional query and transformation language for JSON data. In API integration, it replaces imperative mapping scripts (JavaScript/Python) with declarative expressions that describe the output shape. Because expressions are strings, they can be stored as configuration and updated without code deployments.
How does JSONata handle null or missing fields in API responses?
JSONata is null-safe by default. If you reference a field that doesn't exist, the expression returns undefined rather than throwing a null reference error. This eliminates the need for optional chaining or conditional null checks.
Can JSONata detect and map Salesforce custom fields dynamically?
Yes. Using JSONata's $sift function with a regex pattern like /__c$/i, you can dynamically extract all custom fields from a Salesforce response without knowing them in advance. This works for any organization regardless of how many custom fields they have created.
Is JSONata fast enough for production API integrations?
For typical API integration workloads (transforming individual records or small pages of results), JSONata's performance overhead is negligible. Native JavaScript is faster for raw throughput on massive datasets, but most API mappings transform small JSON payloads where the difference is immaterial.
How do you handle API rate limits with declarative mapping?
Declarative configurations can extract provider-specific rate limit data and normalize it into standard IETF headers (ratelimit-remaining). Best practice is to pass the HTTP 429 error and normalized headers back to the caller so they control their own retry and backoff logic.

More from our Blog