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.
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:
- 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.
- It composes beautifully. Conditionals, array transformations, string manipulation, and complex object reshaping all live inside a single, readable expression.
- 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: resultsNothing 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.
Example: A Complete API Blueprint
The skeleton above shows the minimum structure. A production-ready integration config stored in the database is a complete JSON object that fully describes how to communicate with a third-party API - without any code. Here is a complete example for a REST ticketing API with OAuth2 authentication and cursor-based pagination:
{
"base_url": "https://api.acmedesk.com/v2",
"label": "AcmeDesk",
"credentials": {
"format": "oauth2",
"config": {
"auth": {
"tokenHost": "https://auth.acmedesk.com",
"tokenPath": "/oauth/token",
"authorizeHost": "https://auth.acmedesk.com",
"authorizePath": "/oauth/authorize",
"refreshPath": "/oauth/token"
},
"scope": ["tickets:read", "tickets:write", "users:read"],
"tokenExpiryDuration": "3600"
}
},
"authorization": {
"format": "bearer",
"config": {
"path": "oauth.token.access_token"
}
},
"pagination": {
"format": "cursor",
"config": {
"cursor_field": "meta.pagination.next_cursor",
"limit_field": "per_page",
"default_limit": 50
}
},
"rate_limit": {
"status_code": 429,
"header": "retry-after"
},
"error_expression": "status >= 400 ? { \"status\": status, \"message\": data.error ? data.error.message : data.message }",
"resources": {
"tickets": {
"list": {
"method": "get",
"path": "/tickets",
"response_path": "tickets",
"description": "List all tickets",
"scopes": ["tickets:read"]
},
"get": {
"method": "get",
"path": "/tickets/{{id}}",
"response_path": "ticket",
"description": "Get a single ticket by ID",
"scopes": ["tickets:read"]
},
"create": {
"method": "post",
"path": "/tickets",
"body_format": "json",
"response_path": "ticket",
"description": "Create a new ticket",
"scopes": ["tickets:write"]
},
"update": {
"method": "patch",
"path": "/tickets/{{id}}",
"body_format": "json",
"response_path": "ticket",
"description": "Update an existing ticket",
"scopes": ["tickets:write"]
},
"delete": {
"method": "delete",
"path": "/tickets/{{id}}",
"description": "Delete a ticket",
"scopes": ["tickets:write"]
}
},
"users": {
"list": {
"method": "get",
"path": "/users",
"response_path": "users",
"description": "List all users",
"scopes": ["users:read"]
},
"get": {
"method": "get",
"path": "/users/{{id}}",
"response_path": "user",
"description": "Get a single user by ID",
"scopes": ["users:read"]
}
}
}
}Every field here is declarative data, not code. Here is what each top-level key controls:
| Key | Purpose |
|-----|---------||
| base_url | Root URL prepended to every resource path |
| label | Human-readable name shown in UIs and tool descriptions |
| credentials | How to authenticate - oauth2, api_key, oauth2_client_credentials, or oauth (OAuth 1.0) |
| authorization | How credentials are applied to HTTP requests - bearer, basic, or header (custom headers) |
| pagination | Default pagination strategy - cursor, page, offset, link_header, range, or dynamic (JSONata-driven) |
| rate_limit | How to detect rate limiting - by HTTP status code and/or response header |
| error_expression | A JSONata expression to normalize error responses into { status, message } |
| resources | A map of named API endpoints, each with method configs for list, get, create, update, delete |
Each resource method supports its own path (with {{id}} placeholder substitution), body_format (json, form, multipart, xml, raw), response_path (where to extract results from the response envelope), scopes (required OAuth scopes), and optional overrides for pagination, headers, and authentication. A method can even override the base_url if one endpoint lives on a different subdomain.
This entire config is stored as a single JSON column in the database. Adding a new integration means inserting a new row with a config like this. No code is compiled, no adapter file is written, no deployment is triggered.
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:
- We define an array of standard fields we expect to map directly.
- We use the
$differencefunction to compare all keys inresponse.propertiesagainst our standard list. Anything left over is dynamically flagged as a custom field. - We map the standard fields to our unified schema, using
$appendand$splitto cleanly handle HubSpot's semicolon-separated secondary emails. - We construct a
phone_numbersarray and use$filterto drop any null objects. - We use the
$siftfunction to dynamically construct acustom_fieldsobject 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:
- We map the flat PascalCase fields directly to our snake_case unified schema.
- We use
$joinand$removeEmptyItemsto safely concatenate the first and last name into a full name string, even if one is missing. - We build the
phone_numbersarray and filter out empty values just like we did for HubSpot. - We use
$siftwith a regular expression (/__c$/i) to automatically extract any key ending in__cas 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.
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.
Example: A Complete Declarative Mapping
The API blueprint tells the engine how to call the API. The mapping tells it what to do with the data. Here is a complete mapping for the AcmeDesk ticketing integration defined above, covering list, get, create, and update operations:
list:
resource: tickets
method: list
response_mapping: >-
response.{
"id": $string(id),
"subject": subject,
"description": description,
"status": $mapValues(status, {
"new": "open",
"open": "open",
"pending": "pending",
"solved": "resolved",
"closed": "closed"
}),
"priority": priority,
"assignee": assignee_id ? { "id": $string(assignee_id) },
"requester": requester ? {
"id": $string(requester.id),
"email": requester.email,
"name": requester.name
},
"tags": tags,
"created_at": created_at,
"updated_at": updated_at,
"custom_fields": custom_fields
}
query_mapping: >-
{
"status": query.status,
"assignee_id": query.assignee ? query.assignee.id,
"per_page": query.limit ? $number(query.limit) : 50,
"sort_by": query.sort_by ? query.sort_by : "updated_at",
"order": query.sort_order ? query.sort_order : "desc"
}
get:
resource: tickets
method: get
response_mapping: list
create:
resource: tickets
method: create
request_body_mapping: >-
{
"subject": body.subject,
"description": body.description,
"status": body.status ? body.status : "new",
"priority": body.priority ? body.priority : "normal",
"assignee_id": body.assignee ? body.assignee.id,
"tags": body.tags
}
response_mapping: list
update:
resource: tickets
method: update
request_body_mapping: >-
{
"subject": body.subject,
"description": body.description,
"status": body.status ? $mapValues(body.status, {
"open": "open",
"pending": "pending",
"resolved": "solved",
"closed": "closed"
}),
"priority": body.priority,
"assignee_id": body.assignee ? body.assignee.id,
"tags": body.tags
}
response_mapping: listThree things to notice:
- The
get,create, andupdatemethods reuse thelistresponse mapping. Settingresponse_mapping: listtells the engine to evaluate the list method's JSONata expression. This avoids duplicating the same field mapping across four operations. - The
query_mappingtranslates unified parameters into AcmeDesk's native format. The unifiedlimitbecomesper_page. The unifiedsort_orderbecomesorder. The caller never needs to know AcmeDesk's query parameter names. - The
updaterequest body mapping reverses the enum mapping. Where the response mapping translates AcmeDesk's"solved"into the unified"resolved", the request body mapping does the opposite - translating the unified"resolved"back to AcmeDesk's"solved".
Both the blueprint and the mapping are stored as data in the database. To add AcmeDesk support, you insert these two configuration objects. The generic engine handles the rest - no code deployment required.
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:
- Platform Base Mapping: The default JSONata expression that handles standard fields for 90% of use cases (like the examples above).
- Environment Override: A JSONata expression applied to a specific staging or production environment to tweak field names.
- 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.
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.
End-to-End: From Unified API Call to Upstream Provider
Let us trace a complete request through the system using the AcmeDesk integration defined earlier. The caller wants to list the five most recent open tickets.
The unified API request:
GET /unified/ticketing/tickets?integrated_account_id=ia_123&limit=5&status=open
The caller does not know or care that ia_123 is an AcmeDesk account. The request would be identical for Zendesk, Freshdesk, or any other ticketing provider.
sequenceDiagram
participant Client
participant Engine as Generic Engine
participant DB as Config Store
participant API as AcmeDesk API
Client->>Engine: GET /unified/ticketing/tickets<br>?limit=5&status=open
Engine->>DB: Load config + mapping for ia_123
DB-->>Engine: Blueprint JSON + JSONata mapping
Engine->>Engine: Evaluate query_mapping JSONata
Note right of Engine: { per_page: 5, status: "open",<br>sort_by: "updated_at", order: "desc" }
Engine->>Engine: Build URL: base_url + path
Engine->>Engine: Apply bearer token
Engine->>API: GET /v2/tickets?per_page=5<br>&status=open&sort_by=updated_at&order=desc
API-->>Engine: 200 OK with JSON body
Engine->>Engine: Extract via response_path "tickets"
Engine->>Engine: Evaluate response_mapping<br>JSONata per item
Engine-->>Client: Unified responseStep 1 - Resolve configuration. The engine loads the integrated account's credentials, the AcmeDesk integration config (the blueprint JSON), and the ticketing mapping (the JSONata expressions). These are standard database lookups that do not branch on the integration name.
Step 2 - Map the query. The engine evaluates the query_mapping JSONata expression with the incoming query as input. The unified limit=5 becomes AcmeDesk's per_page=5. The unified status=open passes through as-is. Default values for sort_by and order are applied by the expression.
Step 3 - Build the HTTP request. The engine constructs the URL from base_url + the list method's path: https://api.acmedesk.com/v2/tickets. It applies the bearer token from the account's stored OAuth credentials. The mapped query parameters are serialized into the query string.
Step 4 - Call the upstream API. The engine sends:
GET https://api.acmedesk.com/v2/tickets?per_page=5&status=open&sort_by=updated_at&order=desc
Authorization: Bearer eyJhbG...
Step 5 - Parse and extract. AcmeDesk responds with:
{
"tickets": [
{
"id": 9012,
"subject": "Login broken on mobile",
"status": "open",
"priority": "high",
"assignee_id": 42,
"requester": { "id": 88, "email": "alice@co.com", "name": "Alice" },
"tags": ["bug", "mobile"],
"created_at": "2025-06-10T14:00:00Z",
"updated_at": "2025-06-14T09:30:00Z"
}
],
"meta": { "pagination": { "next_cursor": "eyJpZCI6OTAxMn0=" } }
}The engine uses response_path: "tickets" to extract the array. The pagination layer reads meta.pagination.next_cursor (from the blueprint's cursor_field config) to capture the next page cursor.
Step 6 - Map the response. The engine evaluates the response_mapping JSONata expression against each ticket. AcmeDesk's "open" status maps to the unified "open". The raw assignee_id integer becomes a structured { "id": "42" } object. The original response is preserved as remote_data.
Step 7 - Return the unified response:
{
"result": [
{
"id": "9012",
"subject": "Login broken on mobile",
"status": "open",
"priority": "high",
"assignee": { "id": "42" },
"requester": { "id": "88", "email": "alice@co.com", "name": "Alice" },
"tags": ["bug", "mobile"],
"created_at": "2025-06-10T14:00:00Z",
"updated_at": "2025-06-14T09:30:00Z",
"remote_data": { "id": 9012, "subject": "Login broken on mobile", "status": "open" }
}
],
"next_cursor": "eyJpZCI6OTAxMn0=",
"result_count": 1
}The caller receives a predictable shape regardless of whether the underlying integration is AcmeDesk, Zendesk, Freshdesk, or any other ticketing provider. The original API response is preserved in remote_data for any fields not covered by the unified schema.
No adapter code was written. No deployment was triggered. The entire integration lives in two database records: one for the blueprint, one for the mapping.
Validation Rules and Common Errors
When you define an API blueprint and mapping as data instead of code, the most common failures are configuration mistakes - not bugs. Here are the errors that surface repeatedly, along with how to diagnose and fix each one.
| Error | Symptom | Fix |
|---|---|---|
Missing or wrong response_path |
The unified API returns the entire response envelope instead of just the results array | Set response_path to the JSON path where results live (e.g., "data", "results", "tickets") |
authorization.config.path does not match credential structure |
Every API call returns 401 Unauthorized even with valid tokens |
Verify the path matches where the token is stored in the account context - typically oauth.token.access_token for OAuth2 |
Pagination cursor_field points to wrong location |
First page works, but next_cursor is always null so pagination stops after one page |
Inspect the raw API response to find the actual cursor location (e.g., meta.pagination.next_cursor vs paging.next.after) |
JSONata expression references wrong scope in response_mapping |
All mapped fields evaluate to null | For per-item response mappings (the default), response is the individual item, not the full array. Use response.id, not response.results [0].id |
body_format not set on create/update methods |
The upstream API rejects the request with 415 Unsupported Media Type or silently ignores the body |
Set body_format to "json" (or "form", "multipart", "xml" depending on the API) |
| Request body mapping produces null for required upstream fields | The upstream API returns 422 Unprocessable Entity with a missing-field error |
Add fallback values in the JSONata expression: body.priority ? body.priority : "normal" |
| Error expression does not handle the API's specific error shape | Errors from the upstream API surface as generic 500s without useful messages | Inspect a real error response from the API and write an error expression that extracts status and message from the actual payload structure |
| Enum values in request mapping do not match upstream API's expected values | Create/update calls fail with validation errors about invalid field values | Make sure the request_body_mapping reverses any enum translation done in the response_mapping |
Debugging tip: When a mapping produces unexpected results, test the JSONata expression in isolation using the JSONata Exerciser with a sample of the real API response as input. This isolates expression logic from network and auth issues.
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>Normalizes 200 OK errors"]
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:
- 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.
- 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.
- 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.