Skip to content

How to Publish JSONata Manifests and Mapping Examples for API Integrations

Replace brittle per-vendor API adapters with declarative JSONata manifests. Real code examples for mapping responses, custom objects, and error normalization.

Sidharth Verma Sidharth Verma · · 12 min read
How to Publish JSONata Manifests and Mapping Examples for API Integrations

If you are an engineering leader or product manager at a B2B SaaS company, you already know that building integrations is a massive financial drain. If you have ever maintained a mapHubSpotContact() function next to a mapSalesforceContact() function next to twenty more, you already understand the problem. Your team likely spends weeks writing custom code to connect with third-party APIs, only to spend the rest of the year maintaining those connections when vendors deprecate endpoints, change their pagination strategies, or introduce new OAuth scopes.

Hardcoded per-vendor adapters do not scale. If your engineering team is buried under integration logic, the fastest way out is to stop writing brittle API adapters in Node.js or Python and start treating API integration as a data transformation problem, as we explore in our developer tutorial on building JSONata mappings.

This guide walks through how to write production-grade JSONata manifests for response transformation, custom object handling, query translation, and error normalization. A manifest is a declarative configuration that pairs a JSON or YAML description of an API with JSONata expressions that translate between your unified schema and the vendor's native format. We will provide concrete, working code samples you can lift straight into an integration pipeline.

The Hidden Cost of Hardcoded API Adapters

Building a single production-grade integration is not a weekend project. Industry estimates from API tooling vendors like DreamFactory put the cost of a typical API build between $10,000 and $50,000 depending on complexity, auth scheme, and edge case handling. But the initial build is just the down payment.

Independent software maintenance benchmarks consistently put ongoing annual upkeep at 15% to 20% of the initial build cost. This factors in vendor API changes, OAuth scope drift, pagination quirks, and the steady drip of customer-reported edge cases. Multiply that across 30 integrations, and you have a permanent engineering tax that grows linearly with your connector count.

The deeper problem is architectural. Most teams start building integrations using the strategy pattern. They define a common Connector interface in their application and write a separate adapter class for every third-party API: HubSpotConnector, SalesforceConnector, PipedriveConnector.

The strategy pattern looks fine when you have three integrations. It rots once you cross ten. At that scale, you start noticing the breakdown:

  • A bug fix in pagination logic only helps the specific connector you fixed it in.
  • Adding a new unified field requires N pull requests, one per adapter.
  • Customer-specific custom fields force you to fork connectors or sprinkle endless conditional logic.
  • Onboarding a new engineer means teaching them N different codebases.

The strategy pattern organizes integration code; it does not reduce it. When you hardcode integrations, you are coupling your application's deployment lifecycle to the unpredictable whims of fifty different SaaS vendors. Schema normalization becomes an exercise in endless conditional logic. To actually shrink the surface area of your integration layer, you need to push integration-specific behavior out of code entirely and into data.

What is JSONata and Why Does it Matter for Integrations?

JSONata is a declarative, Turing-complete query and transformation language purpose-built for JSON. Think of it as XSLT for modern REST APIs, but designed for the way developers actually work with data today.

Expressions in JSONata are pure functions: they take a JSON input and produce a JSON output, with no side effects and no state. Three properties make it the perfect tool for integration manifests:

  1. It is data, not code. A JSONata expression is just a string. You can store it in a database column, version it, override it per customer, and hot-swap it at runtime without triggering a code deployment.
  2. It composes beautifully. Conditionals, array transformations, string manipulation, and complex object reshaping all live inside a single, readable expression.
  3. It is being adopted by serious infrastructure. Major cloud providers and orchestration platforms are actively adopting it to replace complex data transformation code. AWS recently added JSONata support to Step Functions to cut out intermediate data transformation states. Orchestration platforms like Kestra position it as the default "Swiss Army Knife" for declarative JSON reshaping. Stedi uses it in its Mappings API, and Epilot exposes JSONata-based mapping simulators for ERP integrations.

The trend is unambiguous: complex data transformation is moving from imperative code into declarative expression languages, and JSONata is winning that race for JSON. Instead of a hubspot_mapper.ts file, you store a JSONata expression in a database. A generic execution engine reads the expression and evaluates it against the incoming API payload. Adding a new integration becomes a data operation, not a code deployment.

Publishing JSONata Manifests: The Core Architecture

A modern integration manifest has two layers. The integration config describes how to talk to the API. The integration mapping describes what to do with the data.

Here is the skeleton of an integration config written in YAML:

base_url: https://api.hubspot.com
credentials:
  format: oauth2
authorization:
  format: bearer
  config:
    path: oauth.token.access_token
pagination:
  format: cursor
  config:
    cursor_field: paging.next.after
resources:
  contacts:
    list:
      method: get
      path: /crm/v3/objects/contacts
      response_path: results

Nothing in that file is procedural code. It is a description that a generic engine reads and executes. To add Salesforce, you write a different config; you do not write a different engine.

The mapping layer is where JSONata earns its keep. Let us look at how the same unified "list CRM contacts" operation works for two very different APIs—HubSpot and Salesforce—using JSONata manifests.

Mapping HubSpot's Nested Properties

HubSpot's contacts API returns data nested inside a properties object. It also includes an arbitrary number of custom fields that you need to preserve. Here is a JSONata expression that normalizes a HubSpot contact into a standard schema while dynamically catching any custom fields:

(
  $defaultProperties := ["firstname", "lastname", "jobtitle", "email", "phone", "hs_additional_emails", "mobilephone"];
  $diff := $difference($keys(response.properties), $defaultProperties);
  {
    "id": response.id.$string(),
    "first_name": response.properties.firstname,
    "last_name": response.properties.lastname,
    "title": response.properties.jobtitle,
    "email_addresses": $append(
      response.properties.email ? [{ "email": response.properties.email, "is_primary": true }] : [],
      response.properties.hs_additional_emails ? response.properties.hs_additional_emails.$split(";").{ "email": $, "is_primary": false } : []
    ),
    "phone_numbers": $filter([
      response.properties.phone ? { "number": response.properties.phone, "type": "phone" },
      response.properties.mobilephone ? { "number": response.properties.mobilephone, "type": "mobile" }
    ], function($v) { $boolean($v.number) }),
    "created_at": response.createdAt,
    "updated_at": response.updatedAt,
    "custom_fields": response.properties.$sift(function($v, $k) { $k in $diff })
  }
)

How it works:

  1. We define an array of standard fields we expect to map directly.
  2. We use the $difference function to compare all keys in response.properties against our standard list. Anything left over is dynamically flagged as a custom field.
  3. We map the standard fields to our unified schema, using $append and $split to cleanly handle HubSpot's semicolon-separated secondary emails.
  4. We construct a phone_numbers array and use $filter to drop any null objects.
  5. We use the $sift function to dynamically construct a custom_fields object containing only the non-standard properties.

Mapping Salesforce's Flat Schema

Salesforce returns flat, PascalCase fields. Custom fields are identified by a __c suffix. The JSONata expression for Salesforce looks completely different from HubSpot, yet it produces the exact same unified output:

response.{
  "id": Id,
  "first_name": FirstName,
  "last_name": LastName,
  "name": $join($removeEmptyItems([FirstName, LastName]), " "),
  "title": Title,
  "email_addresses": [{ "email": Email, "is_primary": true }],
  "phone_numbers": $filter([
    { "number": Phone, "type": "phone" },
    { "number": MobilePhone, "type": "mobile" },
    { "number": Fax, "type": "fax" },
    { "number": HomePhone, "type": "home" }
  ], function($v) { $boolean($v.number) }),
  "created_at": CreatedDate,
  "updated_at": LastModifiedDate,
  "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
}

How it works:

  1. We map the flat PascalCase fields directly to our snake_case unified schema.
  2. We use $join and $removeEmptyItems to safely concatenate the first and last name into a full name string, even if one is missing.
  3. We build the phone_numbers array and filter out empty values just like we did for HubSpot.
  4. We use $sift with a regular expression (/__c$/i) to automatically extract any key ending in __c as a custom field.

Developer guides for API mapping rely heavily on these functional array and object manipulation techniques to avoid writing brittle loops in application code.

Tip

Architectural Note: Keep your response mappings idempotent and side-effect free. If you find yourself wanting to make an HTTP call inside a mapping expression, that belongs in a separate pre- or post-execution step, not in the mapping itself. Mixing stateful HTTP requests into a data transformation layer turns debugging into archaeology.

Handling Custom Objects and Enterprise Edge Cases

Standard objects like Contacts and Deals are easy. The moment you integrate with enterprise CRMs, you hit the wall of custom objects. Your enterprise customer's Salesforce instance might have an Invoice__c or Project_Milestone__c object, alongside 147 custom fields on their standard Contact object.

Fixed, hardcoded API schemas fail here. You cannot anticipate every custom object an enterprise customer might create at design time. This is the part most unified-API approaches get wrong.

By treating API mappings as JSONata configuration stored in a database, you can implement an override hierarchy. This allows you to apply different JSONata mapping manifests at different levels of your architecture:

  1. Platform Base Mapping: The default JSONata expression that handles standard fields for 90% of use cases (like the examples above).
  2. Environment Override: A JSONata expression applied to a specific staging or production environment to tweak field names.
  3. Account Override: A JSONata expression tied directly to a single customer's connected account.

If a specific enterprise customer needs their highly customized Customer_Tier__c object mapped into a structured tier field on their unified response, you simply write a JSONata expression that extracts those specific fields and attach it to their account configuration. The core execution engine deep-merges this account-level JSONata over the base platform mapping at runtime.

You solve a complex enterprise requirement by mapping custom objects via configuration, without touching a single line of backend source code or triggering a deployment.

Normalizing API Errors with Declarative Expressions

Vendor error formats are an embarrassment to the industry. Some use standard HTTP status codes. Others return a 200 OK status with an error payload hidden in the body. If your application blindly trusts HTTP status codes, these body-based errors will silently corrupt your data pipelines.

Instead of writing custom error-handling logic for every API, you can use JSONata to evaluate the raw API response and produce a structured error object containing a normalized HTTP status code and a human-readable message.

Example: Catching Slack's 200 OK Errors

Slack's API is notorious for returning 200 OK for failed requests, signaling the error with a payload like { "ok": false, "error": "invalid_auth" }. A JSONata error manifest maps this into a structured { status, message } object before it reaches your application:

$not(data.ok) ? {
  "status": $mapValues(data.error, {
    "invalid_cursor": 400,
    "invalid_auth": 401,
    "token_expired": 401,
    "token_revoked": 401,
    "missing_scope": 403,
    "channel_not_found": 404,
    "ratelimited": 429,
    "internal_error": 500
  }),
  "message": $mapValues(data.error, {
    "invalid_cursor": "Value passed for cursor was not valid.",
    "invalid_auth": "Authentication cannot be validated.",
    "token_expired": "Authentication token has expired.",
    "ratelimited": "The request has been rate limited."
  })
}

If data.ok is true, the expression evaluates to undefined, and the system proceeds normally. If it is false, the expression looks up the specific Slack error string in a mapping object and outputs a standardized HTTP status.

Example: Correcting Semantically Wrong Status Codes

Sometimes APIs return the wrong HTTP status entirely. Freshdesk, for example, returns a 429 Too Many Requests when an account's billing plan does not include API access. Highlevel returns 401 Unauthorized for permission errors that should be 403 Forbidden.

We can use JSONata to detect Freshdesk's fake 429 (which lacks a retry header) and remap it to a 402 Payment Required:

status = 429 and $not($exists(headers.`retry-after`)) ? {
  "status": 402,
  "message": "API access is not available on the current plan."
}

This matters for AI agents and sync jobs especially, because they need a reliable signal to decide whether to retry, escalate, or stop. A 401 should trigger a re-authentication flow. A 429 should trigger exponential backoff. A 402 should surface to the customer. Without normalization, every consumer of your unified API has to reimplement vendor-specific error handling.

Warning

A Note on Handling Rate Limits Transparently: While JSONata handles error normalization, rate limits require specific architectural handling. A sane unified API should not silently swallow legitimate 429 responses. Truto's approach is to normalize upstream rate limit information into standardized headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) per the IETF specification. When an upstream API hits a limit, Truto passes that HTTP 429 error directly to the caller. We do not automatically retry or absorb rate limit errors. Retry, throttling, and exponential backoff stay in the caller's hands, where they belong, because only the caller knows the business priority of the request.

Query Translation and Request Body Mapping

Mapping responses is only half the battle. When a user makes a request to your application—say, searching for a contact by email—you have to translate that unified search query into the highly specific syntax required by the destination API.

HubSpot uses a complex JSON payload with filterGroups. Salesforce uses SOQL (Salesforce Object Query Language) in the query string. JSONata can generate both dynamically based on the incoming request.

Translating to HubSpot filterGroups

If your application sends ?email=alice@example.com, this JSONata request body mapping converts it into HubSpot's search syntax:

rawQuery.{
  "filterGroups": $firstNonEmpty(email, first_name, last_name) ? [{
    "filters": [
      email ? { "propertyName": "email", "operator": "EQ", "value": email },
      first_name ? { "propertyName": "firstname", "operator": "CONTAINS_TOKEN", "value": first_name }
    ]
  }]
}

Translating to Salesforce SOQL

For Salesforce, the same incoming query needs to become a SOQL WHERE clause. JSONata handles the string concatenation cleanly:

(
  $whereClause := query.email ? "Email = '" & query.email & "'" :
                  query.first_name ? "FirstName LIKE '%" & query.first_name & "%'";
  {
    "q": query.search_term 
      ? "FIND {" & query.search_term & "} RETURNING Contact(Id, FirstName, Email)",
    "where": $whereClause ? "WHERE " & $whereClause
  }
)

By executing these expressions at runtime, your application code never sees filterGroups or SOQL. It simply executes an HTTP request using a unified query object. When HubSpot ships a new search operator, you update the expression in the database. No deploy, no PR, no regression risk on the other 99 integrations.

Architecting a Code-Free Integration Pipeline

What we have described here is an instance of the interpreter pattern applied at platform scale.

In a traditional integration architecture, every new API requires new code. In a declarative architecture, the runtime engine is a generic interpreter that executes a Domain-Specific Language (DSL) comprised of JSON configuration and JSONata expressions.

graph TD
    A[Unified API Request] --> B[Generic Execution Engine]
    B --> C[(Database)]
    C -.->|Integration Config| B
    C -.->|JSONata Mappings| B
    C -.->|Account Overrides| B
    B --> D[Evaluate Request JSONata]
    D --> E[Execute HTTP Fetch]
    E --> F[Evaluate Error JSONata]<br><i>Normalizes 200 OK errors</i>
    F --> G[Evaluate Response JSONata]
    G --> H[Unified Response]

This is exactly how Truto operates. The entire platform handles hundreds of third-party integrations without a single line of integration-specific code in its database schema or runtime logic. The same code path that handles a HubSpot CRM contact listing also handles Salesforce, Pipedrive, and Zoho.

The trade-offs are real and worth naming. JSONata has a learning curve, especially for engineers who have never written XSLT or a functional transformation language. Debugging a malformed expression is harder than debugging an imperative function with stack traces. And for genuinely exotic APIs (like SOAP, gRPC, or proprietary binary protocols), you will still need some glue code to bridge to JSON before the expressions can take over.

However, none of that changes the underlying math: the maintenance curve of a manifest-driven system scales with the number of unique API patterns, not the number of integrations. That difference compounds massively.

Where to Take This Next

If you are evaluating whether to adopt this declarative pattern, here are three concrete next steps:

  1. Pick your two most painful integrations. Write the manifests for both by hand. Compare the line count, clarity, and flexibility against your existing adapter code.
  2. Build a thin generic runner that reads a manifest and executes one resource. You will discover quickly which parts of your current adapters are genuinely vendor-specific and which are accidentally so.
  3. Decide on your override model. Per-customer customization is the feature that turns this from a clever engineering choice into a sales weapon. Without it, you have just rebuilt a slightly nicer adapter pattern.

If your team is drowning in custom integration scripts, migrating to a declarative, JSONata-driven architecture will eliminate the vast majority of your maintenance burden. You stop writing integrations, and you start configuring them.

FAQ

What is a JSONata manifest for API integrations?
A JSONata manifest is a declarative configuration that pairs a JSON or YAML description of a third-party API (base URL, auth, pagination, endpoints) with JSONata expressions that translate between your unified schema and the vendor's native request and response formats. It replaces per-vendor adapter code with data that a generic runtime interprets.
How do you handle custom objects and custom fields with JSONata?
JSONata supports dynamic key matching through functions like $sift and regex patterns. For Salesforce, you can capture every field ending in __c into a custom_fields object dynamically. For HubSpot, you can diff response keys against a list of known defaults and route the rest into custom fields. You can also apply account-level overrides to map specific enterprise configurations.
Can JSONata normalize errors from APIs that return 200 OK for failures?
Yes. A JSONata error expression evaluates the response body and returns a structured object with the correct HTTP status and message. APIs like Slack, which return 200 OK with an error in the body, can be mapped so callers see a proper 401 for token expiry or 429 for rate limits.
What are the limitations of using JSONata for integrations?
JSONata has a learning curve for engineers unfamiliar with functional transformation languages, and debugging a complex expression is harder than stepping through imperative code. Additionally, for non-JSON protocols like SOAP or gRPC, you still need a thin layer that converts the payload to JSON before JSONata can transform it.

More from our Blog