Skip to content

Hot-Swappable API Integrations: Add Connectors Without Code Deploys

Hot-swappable API integrations move connector logic from compiled code to declarative config. Add providers and customize mappings without code deployments.

Sidharth Verma Sidharth Verma · · 24 min read
Hot-Swappable API Integrations: Add Connectors Without Code Deploys

You want to add a new CRM integration to unblock a six-figure enterprise deal, but your engineering team says it will take three sprints. When they finally ship it, an upstream provider deprecates an endpoint, and your team drops everything to push an emergency fix. This cycle destroys product roadmaps.

Hot-swappable API integrations solve this exact bottleneck. They represent an architectural pattern where all third-party integration logic—field mappings, authentication flows, pagination strategies, query translations—lives as declarative configuration data rather than compiled runtime code. When a vendor deprecates an endpoint or a customer demands a new connector, you update a configuration record in your database. No pull request. No CI/CD pipeline. No deployment window. The connector is live immediately.

This is not a theoretical pattern. It is the specific architectural difference between integration teams that ship connectors in hours and teams that stack them in a quarterly backlog. By moving API logic out of runtime code and into declarative data configurations, engineering teams can escape the integration maintenance trap and reduce technical debt from maintaining dozens of API integrations.

This guide breaks down the architectural shift required to scale B2B SaaS integrations, how declarative JSONata mapping works in practice, where the trade-offs are, and how to evaluate whether this approach fits your architecture.

The Engineering Bottleneck: Why Code-First Integrations Fail at Scale

Building integrations in-house usually starts with a simple, logical approach: you write a dedicated API client for each provider. A HubSpotAdapter class handles HubSpot's nested properties objects and filterGroups search syntax. A SalesforceAdapter class handles SOQL queries and PascalCase field names. Engineers use if (provider === 'hubspot') to format a request one way, and else if (provider === 'salesforce') to format it another. Each lives in its own file, has its own tests, and goes through your standard code review and deployment pipeline.

This is known as the strategy pattern. It works perfectly for the first three integrations. By the time you reach fifty integrations, your codebase becomes a brittle, unmaintainable mess of vendor-specific logic. It becomes an engineering tax that quietly eats your roadmap.

Every new connector request means a new code module, a new set of unit tests, and a deployment. Third-party APIs are hostile environments. Endpoints change. Authentication flows expire. Providers introduce undocumented edge cases. Every API deprecation—and these happen constantly across the SaaS ecosystem—means finding the right adapter file, updating the affected code paths, testing in staging, and deploying to production. When HubSpot deprecates their v1 Contact Lists API in favor of v3, your team has to drop feature work to rewrite the adapter. Every customer who needs a custom field mapped differently creates a one-off branch or a feature flag.

Yet, you cannot simply stop building integrations. Your buyers know that no product sits in a silo. It needs integrating into their architecture, ensuring that users, data, and processes flow as expected from one system to another. The ability to support this integration process is the number one sales-related factor in driving a software decision, according to analysis of Gartner's 2024 Global Software Buying Trends report. In the Gartner 2023 Global Software Buying Trends Report, 39% of buyers identified integration with existing software as the most important factor when choosing a software provider, outranking both price and ease of use.

The math is brutal: integration capability is what closes enterprise deals, but code-first integration architectures tie those capabilities directly to engineering deployment cycles. Every integration request competes with core product work for the same CI/CD pipeline and the same engineering hours. This approach turns your product engineers into an IT maintenance desk. Truto vs Nango: Why Code-First Integration Platforms Don't Scale highlights why relying on hardcoded scripts to manage integrations is fundamentally unscalable for growing SaaS companies.

What Are Hot-Swappable API Integrations?

A hot-swappable API integration is one where the entire behavior of a connector—how it authenticates, what endpoints it calls, how it paginates, and how it maps fields—is defined as data (configuration) rather than code.

The runtime engine that executes integrations is generic; it reads configuration at request time and acts accordingly. Changing an integration means changing a database record, not recompiling and redeploying an application. Instead of writing an integration, you define it. When you need to update a connector or fix a broken field mapping, you update a JSON configuration record in your database. The underlying integration engine reads that new configuration on the very next HTTP request and executes the updated behavior instantly.

The analogy is a music player versus a music box. A music box plays one song because the song is physically encoded in its mechanism. A music player is a generic engine that reads and plays any song file you give it. Hot-swappable integrations work the same way: the engine stays constant, and the "songs" (integration configs) are swapped freely.

Concretely, this means:

  • Adding a new connector = inserting a JSON configuration record describing the API's base URL, auth scheme, endpoints, and pagination strategy, plus a set of mapping expressions that translate between your data model and the provider's native format.
  • Handling an API deprecation = updating the affected configuration record's endpoint paths or field mappings. No code change, no deployment.
  • Customizing behavior for a specific customer = applying an override configuration scoped to that customer's account, without touching the base connector or any other customer's setup.

This pattern decouples integration maintenance from core application deployment cycles. Product managers, integration specialists, or solutions engineers can update mappings, add custom fields, or swap deprecated endpoints without waiting for an engineering release window. This is distinct from low-code or visual workflow builders. Those tools give business users a drag-and-drop interface for building automations. Hot-swappable integrations are an engineering architecture pattern—the integration logic lives outside your codebase entirely, expressed as structured data that a generic runtime interprets. Industry reports from embedded iPaaS providers show that shifting integration logic out of core codebases can result in up to a 95% reduction in engineering time spent on integrations.

The Declarative Architecture: JSONata and Zero-Code Runtimes

To achieve a truly hot-swappable architecture, you have to abandon the strategy pattern and adopt the interpreter pattern. The enabling technology behind this is a declarative transformation language paired with a generic execution engine. The engine handles the mechanical parts of calling APIs (HTTP requests, authentication, pagination, error handling), while the transformation language handles the semantic parts (mapping properties.firstname to first_name, translating unified filter parameters into provider-specific query syntax).

Why JSONata Is Winning This Category

JSONata is a Turing-complete functional query and transformation language purpose-built for reshaping JSON. Think of it as jq with lambda functions, or a lightweight XPath for JSON. It has been gaining serious traction as the declarative mapping language of choice across enterprise integration platforms to replace brittle Python or Java scripts:

  • AWS Step Functions added JSONata support, giving developers "a powerful open source query and expression language to select and transform data" in workflows.
  • IBM App Connect uses JSONata functions to "transform your data" between source and target applications.
  • Kestra, the open-source orchestration platform, adopted JSONata as what they call "the missing declarative language for engineers looking for an intuitive and efficient way to transform their JSON data."
  • Zendesk uses JSONata in its advanced AI agents as "an open-source query and transformation language designed for JSON data."
  • Reco, a SaaS security company, built their entire policy engine on JSONata expressions evaluated against billions of events.

What makes JSONata the right fit for integration mapping specifically:

  1. It's declarative - expressions describe what the output should look like, not how to produce it.
  2. It's Turing-complete - supports conditionals, string manipulation, array transforms, custom functions, and recursive expressions.
  3. It's storable as data - a single expression string can be stored in a database column, versioned, overridden, and hot-swapped without restarting anything.
  4. It's side-effect free - pure transformations with no state mutation, making them safe to evaluate in any context.

The Interpreter Pattern vs. The Strategy Pattern

This architecture is an instance of the interpreter pattern at platform scale. When a problem occurs very often, it could be considered to represent it as a sentence in a simple language (Domain Specific Languages) so that an interpreter can solve the problem by interpreting the sentence.

In a traditional code-first setup (the strategy pattern), your application knows it is talking to Salesforce. It has a dedicated Salesforce file containing specific logic. In a declarative architecture (the interpreter pattern), the runtime engine is completely ignorant of the vendor. It simply acts as a generic execution pipeline that reads a JSON blueprint and follows the instructions.

While both patterns can handle varying behaviors, the Interpreter pattern is specifically designed for parsing and evaluating expressions, whereas the Strategy pattern focuses on defining and encapsulating interchangeable algorithms.

Here is the practical difference for integration architectures:

graph LR
    subgraph Strategy Pattern
        A[Unified API] --> B[HubSpotAdapter<br>code]
        A --> C[SalesforceAdapter<br>code]
        A --> D[PipedriveAdapter<br>code]
        A --> E[...N more files<br>of code]
    end
graph LR
    subgraph Interpreter Pattern
        F[Unified API] --> G[Generic Engine<br>one code path]
        G --> H[Integration Config<br>data]
        G --> I[Field Mappings<br>data]
        G --> J[Customer Overrides<br>data]
    end

With the strategy pattern, adding the 101st integration means writing and deploying more code. With the interpreter pattern, it means inserting more data.

How to Add and Update Connectors Without Code Deploys

Adding a new integration in a hot-swappable architecture is a data entry exercise. Let's walk through what the practical workflow looks like when a team needs to ship a new CRM connector. The process follows a strict configuration schema that standardizes how you talk to any REST or GraphQL API.

Step 1: Define the API Blueprint

Create a JSON configuration record that completely describes how to communicate with the third-party API. It defines the base URL, the authentication scheme (OAuth 2.0, API key, basic auth), the available endpoints, and the pagination strategy.

{
  "base_url": "https://api.newcrm.com/v2",
  "credentials": {
    "format": "oauth2",
    "config": {
      "auth": {
        "tokenHost": "https://auth.newcrm.com",
        "tokenPath": "/oauth/token",
        "authorizePath": "/oauth/authorize"
      },
      "scope": ["contacts.read", "contacts.write"]
    }
  },
  "authorization": { "format": "bearer" },
  "pagination": {
    "format": "cursor",
    "config": { "cursor_field": "meta.next_cursor" }
  },
  "resources": {
    "contacts": {
      "list": {
        "method": "get",
        "path": "/contacts",
        "response_path": "data"
      },
      "get": {
        "method": "get",
        "path": "/contacts/{{id}}"
      },
      "create": {
        "method": "post",
        "path": "/contacts"
      }
    }
  }
}

Because this is just data, adding a new HRIS or CRM connector means inserting a new row into your database. The generic HTTP client reads this configuration, applies the correct headers, and formats the URL dynamically. The same config schema works for every integration—REST, GraphQL-backed, SOAP-wrapped, it doesn't matter.

Step 2: Map the Request and Response

Next, you define the integration mapping. Instead of writing a JavaScript function to map a Salesforce contact to your unified schema, you write JSONata expressions that describe how to translate between your unified schema and the integration's native format. You define how unified query parameters become integration-specific query parameters, and how the integration's response fields map back to your unified fields.

Here is what a declarative response mapping looks like for Salesforce:

response_mapping: >-
  response.{
    "id": Id,
    "first_name": FirstName,
    "last_name": LastName,
    "email_addresses": [{ "email": Email }],
    "created_at": CreatedDate,
    "custom_fields": $sift($, function($v, $k) { $k ~> /__c$/i and $boolean($v) })
  }

And here is the exact same operation mapped for HubSpot:

response_mapping: >-
  {
    "id": response.id.$string(),
    "first_name": response.properties.firstname,
    "last_name": response.properties.lastname,
    "email_addresses": [
      response.properties.email ? { "email": response.properties.email, "is_primary": true }
    ],
    "created_at": response.createdAt
  }

You can also map inbound queries. The query mapping translates unified filter parameters into whatever query format the provider expects:

query_mapping: >-
  {
    "page_size": query.limit ? $number(query.limit) : 50,
    "email_filter": query.email_addresses
      ? $firstNonEmpty(query.email_addresses.email, query.email_addresses)
  }

Step 3: Implement the Override Hierarchy

This is where hot-swappable architectures become mandatory for enterprise sales. The Best Integration Strategy for SaaS Moving Upmarket to Enterprise explains that enterprise buyers demand deep, highly customized integrations. A rigid, hardcoded integration cannot adapt to an enterprise customer who uses heavily modified custom objects in Salesforce.

A hot-swappable architecture handles this natively through a three-level override hierarchy:

  1. Platform Base: The default JSONata mapping that works for 80% of your customers.
  2. Environment Override: Customizations scoped to a specific customer environment (e.g., staging vs. production OAuth apps, additional field mappings) without affecting other environments.
  3. Account Override: Individual connected accounts can have their own mapping overrides. If one specific customer needs their Custom_Industry__c or Deal_Stage_V2__c field mapped to your industry field, you apply an account-level JSONata override directly to their connection.

Each level deep-merges on top of the previous. All of this happens through configuration data. You never touch the source code, and you never deploy.

Step 4: Live Execution

That's it. No git commit. No CI pipeline. No staging deploy. The generic execution engine picks up the new config on the next request. The runtime engine evaluates the JSONata expressions against the raw API response. If Salesforce changes a field name tomorrow, you update this text string in your database. No code is compiled. The engine executes the new mapping on the next request.

graph TD
    A[Unified API Request] --> B[Generic Execution Engine]
    B --> C[(Database)]
    C -.->|Returns JSON Config| B
    C -.->|Returns JSONata Mapping| B
    B --> D[Evaluate JSONata Expression]
    D --> E[Execute HTTP Request]
    E --> F[Evaluate Response JSONata]
    F --> G[Unified Response]
    style B fill:#f9f,stroke:#333,stroke-width:2px
    style C fill:#bbf,stroke:#333,stroke-width:2px

When this provider inevitably changes their API (renames a field, moves to a new endpoint version, changes their pagination format), you update the config record and the mapping expression. The change takes effect immediately.

Full API Blueprint: JSON Schema and Field Definitions

The walkthrough above showed the simplified version. Here is the complete configuration schema - every field a generic execution engine needs to communicate with any third-party API, whether REST or GraphQL. This is the exact schema Truto uses in production to drive 200+ integrations from a single code path.

Top-Level Integration Config

Field Type Purpose
base_url string Root URL for all API requests (e.g., https://api.hubspot.com). Individual resource methods can override this.
label string Human-readable name shown in UIs (e.g., "HubSpot").
headers Record<string, string> Default HTTP headers applied to every outgoing request. Methods can add to or override these.
query Record<string, string> Default query parameters applied to every request.
query_array_format 'comma' | 'brackets' | 'indices' | 'repeat' How array query parameters are serialized (e.g., ids=1,2,3 vs ids []=1&ids []=2).
credentials object Defines how users authenticate. format is oauth2, oauth2_client_credentials, api_key, oauth (1.0), or a record containing multiple formats. config holds format-specific settings: token endpoints, scopes, API key field definitions, PKCE method.
authorization object Defines how credentials become HTTP headers. format is bearer, basic, or header. For bearer, config.path specifies where the access token lives in the account context (e.g., oauth.token.access_token).
pagination object Default pagination strategy for list operations. format is cursor, page, offset, link_header, range, or dynamic. config holds format-specific settings. Methods can override or set to null to disable.
rate_limit object Rate limit detection. is_rate_limited: JSONata expression returning true when rate-limited. retry_after: extracts retry delay. rate_limit_headers: maps vendor headers to standard limit, remaining, reset values.
error_expression string (JSONata) Evaluated against every API response. Receives status, headers, data, and response as bindings. Returns { status, message } to remap errors, or undefined to pass through. Handles APIs that return 200 with error payloads or return incorrect HTTP status codes.
resources Record<string, Record<string, MethodConfig>> The core of the blueprint. Top-level keys are resource names (e.g., contacts, deals). Second-level keys are method names (list, get, create, update, delete, or custom). Each method config defines how to make one type of API call.
webhook object Webhook signature verification config for inbound webhooks from this provider.
actions object Post-install validation actions, scheduled actions, and setup steps.
tool_tags Record<string, string []> Tags for categorizing resources when generating AI tool definitions.

Resource Method Config

Each method within a resource defines a single API operation:

Field Type Purpose
method 'get' | 'post' | 'put' | 'patch' | 'delete' HTTP method.
path string URL path appended to base_url. Supports {{placeholder}} syntax: {{id}} for path params, {{query.field}} for query values, {{context.subdomain}} for account-specific values.
body object Default request body template. For GraphQL integrations, this contains the query/mutation string and variables with placeholders.
body_format 'json' | 'form' | 'multipart' | 'raw' | 'xml' How the request body is serialized.
response_path string Dot-notation path to extract data from the API response (e.g., data.results, data.issues.nodes). Supports ** wildcards for flexible matching through nested objects.
headers Record<string, any> Method-specific headers merged with integration-level defaults.
query Record<string, any> Default query parameters for this method.
pagination object | null Override integration-level pagination, or set to null to disable for this method.
query_schema object (JSON Schema) Describes accepted query parameters. Used for validation and AI tool generation.
body_schema object (JSON Schema) Describes the expected request body. Used for validation and AI tool generation.
response_schema object (JSON Schema) Describes the expected response structure.
description string Human-readable description. Used for documentation and AI tool definitions.
scopes string [] OAuth scopes required for this operation.
error_expression string (JSONata) Method-level error handler that overrides the integration-level one.
no_auth boolean Skip authentication for this method (e.g., public health-check endpoints).
base_url string Override the integration-level base URL for this specific method.

Placeholder Syntax in Paths and Bodies

Placeholders use {{path}} syntax with optional type coercion and defaults. This is especially important for GraphQL APIs where variable types must be exact:

Syntax Behavior Use Case
{{id}} Raw value from request context Path parameter injection
{{body.title:str:null}} Coerced to string, defaults to null GraphQL String variables
{{query.limit:int:null}} Coerced to integer, defaults to null GraphQL Int variables
{{query.filter:json:null}} Parsed as JSON object, defaults to null GraphQL filter input objects
{{pagination.cursor:str:null}} Cursor from pagination engine context Cursor-based pagination
{{context.subdomain}} Value from connected account's context Tenant-specific URL construction

The :int and :json coercions are the key detail for GraphQL - without them, "10" (a string) gets sent where 10 (an integer) is expected, and GraphQL rejects the request.

Example 1 - REST API Blueprint (Contacts Endpoint)

Here is a complete, copy-pasteable blueprint for a typical REST CRM API. It covers OAuth 2.0 authentication, cursor-based pagination, rate limit detection, error handling, and full CRUD endpoints. Replace the URLs and field names with your target API's values and the generic engine will execute it on the next request.

{
  "base_url": "https://api.acmecrm.com/v2",
  "label": "Acme CRM",
  "headers": {
    "Accept": "application/json",
    "Content-Type": "application/json"
  },
  "credentials": {
    "format": "oauth2",
    "config": {
      "auth": {
        "tokenHost": "https://auth.acmecrm.com",
        "tokenPath": "/oauth2/token",
        "authorizeHost": "https://auth.acmecrm.com",
        "authorizePath": "/oauth2/authorize"
      },
      "scope": ["contacts.read", "contacts.write", "deals.read"],
      "options": {
        "scopeSeparator": " ",
        "authorizationMethod": "header",
        "bodyFormat": "form"
      }
    }
  },
  "authorization": {
    "format": "bearer",
    "config": {
      "path": "oauth.token.access_token"
    }
  },
  "pagination": {
    "format": "cursor",
    "config": {
      "cursor_field": "meta.next_cursor",
      "cursor_query_param": "cursor"
    }
  },
  "rate_limit": {
    "is_rate_limited": "status = 429",
    "retry_after": "headers.`retry-after`",
    "rate_limit_headers": {
      "limit": "headers.`x-ratelimit-limit`",
      "remaining": "headers.`x-ratelimit-remaining`",
      "reset": "headers.`x-ratelimit-reset`"
    }
  },
  "error_expression": "$exists(data.error) ? { \"status\": $mapValues(data.error.code, { \"invalid_token\": 401, \"rate_limited\": 429, \"not_found\": 404, \"forbidden\": 403 }), \"message\": data.error.message }",
  "resources": {
    "contacts": {
      "list": {
        "method": "get",
        "path": "/contacts",
        "response_path": "data",
        "query": {
          "per_page": "100"
        },
        "description": "List all contacts with cursor-based pagination",
        "scopes": ["contacts.read"]
      },
      "get": {
        "method": "get",
        "path": "/contacts/{{id}}",
        "pagination": null,
        "description": "Retrieve a single contact by ID",
        "scopes": ["contacts.read"]
      },
      "create": {
        "method": "post",
        "path": "/contacts",
        "body_format": "json",
        "pagination": null,
        "description": "Create a new contact",
        "scopes": ["contacts.write"]
      },
      "update": {
        "method": "patch",
        "path": "/contacts/{{id}}",
        "body_format": "json",
        "pagination": null,
        "description": "Update an existing contact by ID",
        "scopes": ["contacts.write"]
      },
      "delete": {
        "method": "delete",
        "path": "/contacts/{{id}}",
        "pagination": null,
        "description": "Delete a contact by ID",
        "scopes": ["contacts.write"]
      }
    },
    "deals": {
      "list": {
        "method": "get",
        "path": "/deals",
        "response_path": "data",
        "description": "List all deals",
        "scopes": ["deals.read"]
      },
      "get": {
        "method": "get",
        "path": "/deals/{{id}}",
        "pagination": null,
        "description": "Retrieve a single deal by ID",
        "scopes": ["deals.read"]
      }
    }
  }
}

What happens at runtime

When a request hits GET /proxy/contacts?integrated_account_id=abc&cursor=eyJ..., the generic engine performs these steps using nothing but the config above:

  1. Loads the config from the database for the connected account's integration.
  2. Resolves authentication - reads the access token from context.oauth.token.access_token (the path specified in authorization.config.path) and sets Authorization: Bearer <token> on the request.
  3. Builds the URL - combines base_url + the list method's path: https://api.acmecrm.com/v2/contacts.
  4. Applies pagination - reads cursor_query_param (cursor) from the pagination config and appends ?cursor=eyJ... to the URL.
  5. Merges headers and query defaults - applies the integration-level headers (Accept, Content-Type) and the method's default query (per_page=100).
  6. Evaluates error_expression - checks if the response body contains a data.error object. If it does, maps the error code (e.g., invalid_token) to the correct HTTP status (401) and throws a structured error.
  7. Checks rate limits - evaluates is_rate_limited against the response status and normalizes vendor-specific rate limit headers into standard ratelimit-limit, ratelimit-remaining, ratelimit-reset headers.
  8. Extracts results - uses response_path (data) to pull the contacts array from the response body.
  9. Extracts the next cursor - uses cursor_field (meta.next_cursor) to find the next page token from the response.
  10. Returns the result with standardized result, next_cursor, and result_count fields.

No adapter class. No provider-specific code path. The same engine runs this config identically to how it runs a HubSpot config, a Salesforce config, or any other integration.

Example 2 - GraphQL API Blueprint

GraphQL APIs present a unique challenge: every operation is a POST to a single /graphql endpoint with a query string and typed variables. The same config-driven approach handles this without any GraphQL-specific code in the engine.

The key structural difference from REST: every resource method uses method: "post", path: "/graphql", and body_format: "json". The GraphQL query or mutation lives inside the body template, and response_path extracts the relevant slice from the GraphQL response envelope.

Here is a complete blueprint for a GraphQL-backed project management API:

{
  "base_url": "https://api.projecttool.com",
  "label": "ProjectTool",
  "headers": {
    "Content-Type": "application/json"
  },
  "credentials": {
    "format": "oauth2",
    "config": {
      "auth": {
        "tokenHost": "https://projecttool.com",
        "tokenPath": "/api/oauth/token",
        "authorizeHost": "https://projecttool.com",
        "authorizePath": "/api/oauth/authorize"
      },
      "scope": ["read", "write"]
    }
  },
  "authorization": {
    "format": "bearer",
    "config": {
      "path": "oauth.token.access_token"
    }
  },
  "error_expression": "$exists(data.errors) ? data.errors[0].{ \"status\": $mapValues(extensions.code, { \"UNAUTHENTICATED\": 401, \"FORBIDDEN\": 403, \"NOT_FOUND\": 404 }), \"message\": message }",
  "resources": {
    "issues": {
      "list": {
        "method": "post",
        "path": "/graphql",
        "body_format": "json",
        "body": {
          "query": "query($first: Int, $after: String, $filter: IssueFilter) { issues(first: $first, after: $after, filter: $filter) { nodes { id title description status { id name } assignee { id name email } priority createdAt updatedAt } pageInfo { hasNextPage endCursor } } }",
          "variables": {
            "first": "{{query.limit:int:null}}",
            "after": "{{pagination.cursor:str:null}}",
            "filter": "{{query.filter:json:null}}"
          }
        },
        "response_path": "data.issues.nodes",
        "pagination": {
          "format": "cursor",
          "config": {
            "cursor_field": "data.issues.pageInfo.endCursor"
          }
        },
        "description": "List issues with optional filtering and pagination"
      },
      "get": {
        "method": "post",
        "path": "/graphql",
        "body_format": "json",
        "body": {
          "query": "query($filter: IssueFilter) { issues(filter: $filter) { nodes { id title description status { id name } assignee { id name email } priority createdAt updatedAt } } }",
          "variables": {
            "filter": {
              "id": { "eq": "{{id}}" }
            }
          }
        },
        "response_path": "data.**.nodes.0",
        "pagination": null,
        "description": "Get a single issue by ID"
      },
      "create": {
        "method": "post",
        "path": "/graphql",
        "body_format": "json",
        "body": {
          "query": "mutation($input: IssueCreateInput!) { issueCreate(input: $input) { issue { id title } } }",
          "variables": {
            "input": {
              "title": "{{body.title:str:null}}",
              "description": "{{body.description:str:null}}",
              "assigneeId": "{{body.assignee_id:str:null}}",
              "priority": "{{body.priority:int:null}}",
              "projectId": "{{body.project_id:str:null}}"
            }
          }
        },
        "response_path": "data.issueCreate.issue",
        "pagination": null,
        "description": "Create a new issue"
      },
      "update": {
        "method": "post",
        "path": "/graphql",
        "body_format": "json",
        "body": {
          "query": "mutation($id: String!, $input: IssueUpdateInput!) { issueUpdate(id: $id, input: $input) { issue { id title } } }",
          "variables": {
            "issueUpdateId": "{{id}}",
            "input": {
              "title": "{{body.title:str:null}}",
              "description": "{{body.description:str:null}}",
              "assigneeId": "{{body.assignee_id:str:null}}",
              "priority": "{{body.priority:int:null}}"
            }
          }
        },
        "response_path": "data.issueUpdate.issue",
        "pagination": null,
        "description": "Update an existing issue"
      },
      "delete": {
        "method": "post",
        "path": "/graphql",
        "body_format": "json",
        "body": {
          "query": "mutation($id: String!) { issueDelete(id: $id) { success } }",
          "variables": {
            "issueDeleteId": "{{id}}"
          }
        },
        "response_path": "data.issueDelete",
        "pagination": null,
        "description": "Delete an issue by ID"
      }
    }
  }
}

How the engine translates REST to GraphQL

When a client sends POST /proxy/issues with the body { "title": "Fix login bug", "priority": 2 }, the engine:

  1. Selects the create method config based on the HTTP method and resource name.
  2. Substitutes placeholders in the body template. {{body.title:str:null}} becomes "Fix login bug". {{body.priority:int:null}} becomes the integer 2 (not the string "2" - the :int coercion ensures GraphQL type correctness). Fields not provided in the request body, like {{body.project_id:str:null}}, resolve to null.
  3. POSTs the resulting JSON to https://api.projecttool.com/graphql as a standard GraphQL request.
  4. Evaluates error_expression - GraphQL APIs typically return HTTP 200 even for errors, with failures buried in a data.errors array. The expression checks for data.errors, maps GraphQL error codes like UNAUTHENTICATED to standard HTTP status 401, and surfaces the error message.
  5. Extracts the result using response_path: "data.issueCreate.issue" from the GraphQL response envelope.

For list operations, pagination works through the same config-driven mechanism. The {{pagination.cursor:str:null}} placeholder resolves to null on the first request (meaning "start from the beginning"), and the engine extracts data.issues.pageInfo.endCursor as the next cursor. On subsequent requests, the client passes next_cursor=eyJ... and the engine injects it as the $after GraphQL variable.

The get method uses response_path: "data.**.nodes.0" - the ** wildcard matches through any intermediate keys in the response, making it resilient to changes in the GraphQL query name. And pagination: null explicitly disables pagination for single-item lookups, even though the integration-level config might define a default strategy.

The entire REST-to-GraphQL translation lives in the config. No GraphQL client library, no query builder, no per-integration handler. The same generic HTTP client that calls REST APIs posts these GraphQL payloads - because from the engine's perspective, it is just making an HTTP POST with a JSON body.

Handling Edge Cases: Rate Limits, Pagination, and Custom Fields

The biggest and most common technical objection to declarative architectures is: "That works for simple CRUD, but what about the messy, undocumented realities of third-party APIs?" Let us look at exactly how a generic engine handles rate limits, pagination, and custom fields without relying on custom code or procedural scripts.

Declarative Pagination Across Providers

Pagination strategies vary wildly across the SaaS ecosystem. HubSpot uses cursor-based pagination. Zendesk uses offset-based pagination. Many legacy APIs use page numbers, while GitHub uses link headers. Occasionally, something entirely custom is used.

Instead of writing custom while loops for each API, the integration configuration defines the pagination strategy as data. A well-designed declarative config handles this by letting the pagination strategy be specified as a config property:

Pagination Type Config Example Used By
Cursor { "format": "cursor", "config": { "cursor_field": "paging.next.after" } } HubSpot, Stripe
Page { "format": "page", "config": { "page_field": "page" } } Many legacy APIs
Offset { "format": "offset" } Zendesk, some HRIS tools
Link Header { "format": "link_header" } GitHub, GitLab
Dynamic { "format": "dynamic", "config": { "expression": "..." } } Non-standard APIs

The runtime engine reads the pagination.format field and dynamically extracts the next page token using a configured JSON path. The caller simply passes next_cursor=xyz in their unified request, and the engine translates it into the correct upstream format. No conditional branches per provider.

Rate Limits: Transparency Over Magic

Rate limiting is where many integration platforms make a questionable architectural choice: they silently absorb HTTP 429 errors, apply automatic backoff, and magically retry behind the scenes. This is an architectural anti-pattern. When an integration layer absorbs rate limit errors and pauses execution, it holds network connections open, exhausts worker pools, and creates unpredictable latency. It sounds convenient until your AI agent is waiting 30 seconds for a response and has no idea why, or until the platform's retry logic conflicts with your own. It abstracts away a failure state that your application desperately needs to know about.

A reliable hot-swappable architecture takes a radically honest approach: transparent rate limit normalization. It does not throttle, apply backoff, or magically retry your requests. If the upstream provider rate-limits you, the platform passes that 429 (Too Many Requests) error directly back to your application.

What the platform does do is normalize the chaotic rate limit headers from hundreds of different APIs (whether as X-RateLimit-Remaining, x-rate-limit-hour, or some other vendor-specific header) into standard response headers based on the IETF RateLimit header specification:

  • ratelimit-limit: The maximum number of requests permitted in the current window.
  • ratelimit-remaining: The number of requests left in the current window.
  • ratelimit-reset: The time (in seconds) until the rate limit window resets.

The IETF spec defines these as: "RateLimit-Limit: containing the requests quota in the time window; RateLimit-Remaining: containing the remaining requests quota in the current window; RateLimit-Reset: containing the time remaining in the current window, specified in seconds."

Your application reads these consistent headers and implements its own retry or queuing logic exactly how you want it. You retain full control over your system's behavior, rather than fighting a black-box integration layer that decides when to retry your data. This gives you full control and full visibility, which matters a lot more in production than the false convenience of opaque retries.

Preserving Custom Fields

Unified schemas are excellent for standardization, but they break the moment a customer needs a field that is not in your schema. Enterprise customers don't just use standard Salesforce fields. They have dozens of custom objects.

A hot-swappable architecture solves this by always returning a remote_data object containing the raw, unmapped payload from the third-party API. Customers get the clean, normalized unified model, plus an escape hatch for raw data. Combined with the account-level JSONata overrides mentioned earlier, you can dynamically promote any field from remote_data into your top-level schema without writing code.

Why Truto's Interpreter Pattern is the Ultimate Hot-Swappable Solution

Truto was built entirely around the interpreter pattern. Our architecture is the most thorough implementation of this pattern we're aware of, which is exactly why Truto's zero-code architecture wins over traditional unified APIs. The entire platform—every database table, every service module, the unified API engine, the proxy layer, sync jobs, webhooks, MCP tool generation—contains zero integration-specific code.

Across our platform, the database schema contains 38 tables, and not a single one has an integration-specific column. There is no hubspot_token field. There is no salesforce_instance_url. No if (provider === 'hubspot'). No switch statements on provider names.

Every aspect of third-party API communication is captured in a well-defined JSON schema. The integration table has a generic JSON column that holds the entire API blueprint. The runtime code operates exclusively on abstract concepts: "an integration config," "an integration mapping," "a JSONata expression." It never asks "which integration am I talking to?" The generic execution pipeline reads this configuration at runtime and executes it, without knowing or caring which integration it's running.

This means:

  • Adding a new integration is a pure data operation. The same code path that handles 100+ integrations today handles the 101st without a line of code being changed. The same engine that handles a HubSpot CRM contact listing also handles Salesforce, Pipedrive, Zoho, and Close.
  • Every customer can customize without code. The three-level override hierarchy lets anyone modify mappings without touching source code. If one customer's Salesforce instance has custom fields that need special handling, only that account's mapping is affected.
  • Bug fixes benefit everyone. When every integration flows through the same generic code path, bugs get fixed once and every integration benefits. When the pagination logic is improved or error handling is enhanced, all 100+ integrations get the improvement simultaneously. In a code-first architecture, a fix in the Salesforce adapter doesn't help the HubSpot adapter. The maintenance burden grows linearly with the number of unique API patterns—which is far smaller than the number of integrations—rather than growing linearly with every new connector you add.
  • MCP tools come for free. Because integration behavior is entirely data-driven, Truto automatically generates MCP (Model Context Protocol) tool definitions from the same configuration. Every integration with valid documentation records automatically becomes available as an AI tool—no per-integration MCP code needed.
Info

The honest trade-off: Declarative architectures shift complexity from code to configuration. Your JSONata expressions can get intricate (Salesforce's SOQL query mapping is a nontrivial expression). You need strong tooling for testing and debugging mapping expressions. And edge cases that don't fit the config schema still require platform-level work. This architecture doesn't eliminate integration complexity—it relocates it to a layer where changes are cheaper and faster.

Where to Start If You're Evaluating This Pattern

If your engineering team is drowning in integration maintenance, building custom adapters, and deploying code just to fix an API deprecation, it is time to stop writing code and start writing configuration. If your team is spending more than 20% of engineering capacity on integration maintenance, or if missing connectors are showing up in your win-loss analysis, here is a practical evaluation framework:

  1. Audit your current integration debt. Count how many integration-specific code files you maintain. Multiply by the average hours spent per deprecation or breaking change. That's your annual maintenance tax.
  2. Map your integration requests to deal velocity. Work with your sales team to identify how many deals in the last two quarters were blocked or slowed by missing integrations. Gartner's 2024 report makes it explicit: "Integration support is nonnegotiable" in the vendor selection process.
  3. Evaluate architectures on the "101st integration" test. Ask any platform you're considering: what does it take to add the 101st connector? If the answer involves writing code and deploying it, the platform uses the strategy pattern and your maintenance burden will grow linearly with integrations. If the answer is "add a configuration record," you're looking at the interpreter pattern.
  4. Don't ignore the escape hatch. Any declarative architecture needs a proxy/passthrough mode for integration-specific features that don't fit the unified model. If a platform forces everything through rigid schemas with no raw API access, it will break on your first enterprise customer's custom objects.
  5. Test rate limit transparency. Send a burst of requests through the platform and check: does it return standardized rate limit headers? Does it pass through 429 errors so your code can handle them? Or does it silently retry and add unpredictable latency? Transparent platforms are easier to debug and operate in production.

The enterprise integration strategy conversation has moved past "build vs. buy." The real question is whether your integration architecture treats connectors as code (requiring engineering cycles for every change) or as data (requiring a configuration update). That distinction determines whether your integration coverage can keep pace with your sales pipeline.

FAQ

What are hot-swappable API integrations?
Hot-swappable API integrations represent an architecture where all integration logic (field mappings, authentication, pagination, query translation) is stored as declarative configuration data rather than compiled code. A generic runtime engine reads this configuration at request time, meaning connectors can be added or updated by changing a database record—no code deploy required.
How do you add a new API connector without deploying code?
You create a JSON configuration record describing the API's base URL, authentication scheme, endpoints, and pagination strategy, alongside JSONata mapping expressions that translate between your data model and the provider's format. The generic execution engine picks up the new config instantly, eliminating the need for a CI/CD pipeline or deployment window.
What is the interpreter pattern in API integration architecture?
The interpreter pattern treats integration configurations as sentences in a domain-specific language that a generic execution engine interprets at runtime. Unlike the strategy pattern—which requires a new compiled code adapter per provider—the interpreter pattern treats new integrations as purely data for the same engine to execute.
What is JSONata and why is it used for API integrations?
JSONata is a declarative, Turing-complete query and transformation language designed specifically for JSON data. It is used for API integrations because expressions are storable as database strings, side-effect free, and can handle complex field mappings, conditionals, and query translations without writing procedural code.
How should API rate limits be handled in a unified integration platform?
A transparent approach normalizes chaotic upstream rate limit data into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) and passes 429 errors directly to the caller. This empowers your application to retain full control over retry and queuing logic instead of hiding rate limit behavior behind opaque, automatic retries.

More from our Blog