How to Build a Custom SaaS API Connector Without Code
Add new API integrations without deploying code. Complete guide to declarative JSON connectors, JSONata mappings, and config-based deployment.
If you want to know how to build a custom API connector without writing imperative code, the answer lies in shifting your integration architecture from custom scripts to declarative configurations. Instead of writing bespoke Node.js or Python logic for every new third-party API, you define the authentication flow, pagination rules, and data mappings in a standardized JSON format. A generic execution engine then interprets this configuration at runtime.
This approach eliminates the maintenance tax of traditional integrations. When you write custom code to interact with a vendor API, you inherit the responsibility of maintaining that code through every upstream deprecation, schema change, and undocumented edge case. By moving to a declarative model, you abstract the mechanics of HTTP requests, token management, and payload transformation away from your core product logic.
This guide breaks down the financial realities of building in-house connectors, explains the architectural shift required to adopt a declarative integration model, and provides an end-to-end tutorial on building a custom API connector without writing integration-specific code.
The Hidden Cost of Writing Integration-Specific Code
Every new third-party API connector starts as an ad-hoc request to unblock a specific enterprise deal. Engineering looks at the vendor documentation, estimates two sprints for the build, and proceeds to spend the next three months debugging token refresh failures, undocumented schema behaviors, and opaque error codes.
Depending on complexity and developer time, building a single API integration from scratch can easily reach $10,000 or more just for the initial build phase. This figure only covers the initial deployment. The true financial drain comes from the ongoing maintenance required to keep that connection alive in production.
The Integration Maintenance Tax is the recurring engineering cost associated with monitoring, updating, and fixing custom API connections. This includes handling breaking API changes, updating OAuth scopes, migrating from deprecated endpoints, and managing infrastructure to handle webhook ingestion and retry queues.
The average company relies on hundreds of SaaS applications, creating a massive integration backlog. The 2025 State of SaaS Integration outlook reports that companies today rely on over 250 SaaS applications on average. Your customers expect your product to connect to their existing stack natively. If you rely on custom code for every new connector, scaling your integration catalog becomes mathematically impossible without linearly scaling your engineering headcount.
Product teams often try to bypass this by pushing customers to third-party tools or relying on generic visual builders. But as we discussed in our guide on how to publish end-to-end developer tutorials, enterprise buyers evaluate your platform based on the friction of your native integrations. If your product requires them to build their own middleware, you will lose the deal.
Imperative Scripts vs. Declarative API Configurations
To escape the integration maintenance trap, engineering teams must stop treating API connections as bespoke software projects and start treating them as data-only operations.
There are several approaches to reducing integration development time, but not all of them scale for embedded, customer-facing use cases. Understanding the competitive landscape clarifies why declarative architecture wins:
- Visual Workflow Builders (Make / Zapier): These platforms position themselves as general-purpose visual workflow builders. They are excellent for internal automation but lack the enterprise-grade version control, isolated environments, and code-like deployment pipelines needed for native, white-labeled SaaS integrations.
- Code-First ETLs (Hotglue): Platforms like Hotglue position themselves as code-first Python ETL platforms for B2B integrations. While powerful for heavy data pipelines, they still require developers to write and maintain transformation scripts per connector, failing to eliminate the maintenance burden.
- Visual Backends (BuildShip): Tools like BuildShip offer a low-code visual backend that lets users build custom API integrations visually. However, developers still have to manually map individual API endpoints and manage the underlying logic visually, which becomes brittle at scale.
- Internal Data Extractors (Airbyte): Airbyte offers a no-code connector builder focused primarily on extracting data for internal analytics pipelines and data warehouses, rather than powering real-time, customer-facing embedded integrations.
The alternative to these methods is a declarative unified API architecture.
Gartner estimates that 70% of new enterprise applications will use no-code or low-code by 2026. This shift is driven by hard ROI. A Forrester TEI study found that low-code integration platforms reduced development timelines for non-complex integrations by up to 70% over three years.
The Zero Integration-Specific Code Pattern
To achieve true scale, you need an architecture that operates with zero integration-specific code.
In this model, the runtime is a generic execution engine. It takes a declarative JSON configuration describing how to talk to a third-party API (the base URL, the authentication method, the pagination style) and a declarative mapping describing how to translate between unified and native formats. The engine executes both without any hardcoded awareness of which integration it is running.
Whether you are connecting to Salesforce, HubSpot, or a niche industry-specific ERP, the underlying execution code path is identical. The only variable is the JSON configuration file.
What a Declarative Unified API Looks Like
Strip away the abstraction and a declarative unified API is three JSON objects working together:
- Integration Config - describes how to talk to the third-party API (base URL, auth, pagination, endpoints)
- Integration Mapping - a JSONata expression that translates the native response into a unified format
- Customer Override (optional) - lets a specific tenant modify the mapping without touching the base config
Here is a minimal integration config for a fictional CRM:
{
"base_url": "https://api.acmecrm.com",
"credentials": { "type": "oauth2" },
"authorization": { "format": "bearer" },
"pagination": { "format": "cursor", "config": { "cursor_field": "meta.next" } },
"resources": {
"contacts": {
"list": {
"method": "get",
"path": "/v2/contacts",
"response_path": "data"
}
}
}
}And here is the JSONata mapping that normalizes the response into a unified Contact schema:
response.{
"id": $string(contact_id),
"first_name": fname,
"last_name": lname,
"email": primary_email,
"phone": phone_number ? phone_number : null,
"created_at": $fromMillis(created_ts)
}The generic engine reads the config, authenticates, fetches the data, paginates automatically, and evaluates the JSONata expression against each response item. The output is always the same unified shape - regardless of which CRM is behind it.
No adapter class. No provider-specific function. The "integration" is two pieces of data, not a code module.
Code-First vs. Declarative: A Side-by-Side Comparison
To make the difference tangible, here is how listing contacts looks in a code-first architecture vs. a declarative one.
Code-first approach - a separate adapter file per provider, plus a router that branches on provider name:
// hubspot-adapter.ts
async function listContacts(creds, params) {
const res = await fetch(
`https://api.hubspot.com/crm/v3/objects/contacts?limit=${params.limit}`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` } }
);
const data = await res.json();
return data.results.map(c => ({
id: c.id,
first_name: c.properties.firstname,
last_name: c.properties.lastname,
email: c.properties.email,
}));
}
// salesforce-adapter.ts
async function listContacts(creds, params) {
const soql = `SELECT Id, FirstName, LastName, Email
FROM Contact LIMIT ${params.limit}`;
const res = await fetch(
`https://${creds.instanceUrl}/services/data/v59.0/query?q=${encodeURIComponent(soql)}`,
{ headers: { Authorization: `Bearer ${creds.accessToken}` } }
);
const data = await res.json();
return data.records.map(c => ({
id: c.Id, first_name: c.FirstName,
last_name: c.LastName, email: c.Email,
}));
}
// router.ts - grows with every new provider
switch (provider) {
case 'hubspot': return hubspotAdapter.listContacts(creds, params);
case 'salesforce': return salesforceAdapter.listContacts(creds, params);
// ... repeat for every new provider
}Every new provider means a new file, new tests, a new branch in the router, and a deployment. Thirty providers means thirty adapter files to maintain.
Declarative approach - the same generic engine handles both. The only difference is the config data stored in the database:
// HubSpot config (data, not code)
{
"path": "/crm/v3/objects/contacts",
"response_path": "results",
"response_mapping": "response.{ 'id': id, 'first_name': properties.firstname, 'last_name': properties.lastname, 'email': properties.email }"
}// Salesforce config (data, not code)
{
"path": "/services/data/v59.0/query",
"response_path": "records",
"response_mapping": "response.{ 'id': Id, 'first_name': FirstName, 'last_name': LastName, 'email': Email }"
}Both flow through one code path: resolve config, authenticate, fetch, paginate, map response, return. Adding provider #31 means inserting a config row, not writing code.
Developer Effort Comparison
| Task | Code-First Approach | Declarative Unified API |
|---|---|---|
| Add a new CRM integration | 2-4 weeks (adapter code, tests, code review, deployment) | 1-3 days (JSON config + JSONata mapping) |
| Handle a provider's API deprecation | Rewrite adapter logic, update tests, deploy | Update config fields - no code deployment |
| Support a customer's custom CRM fields | Fork the adapter or add conditional logic | Customer-level mapping override (config only) |
| Fix a pagination bug | Fix per adapter, test each, N deployments | One fix in the generic engine - all integrations benefit |
| Onboard a new engineer to integrations | Learn each adapter's structure and quirks | Learn the config schema once |
API Blueprint JSON Template (Annotated)
Every connector in a declarative architecture is described by a single JSON blueprint. Below is a complete, annotated template that covers the fields you will use for the vast majority of REST-based SaaS APIs. Save this as a starting point when adding a new integration.
{
"base_url": "https://api.example.com",
"label": "Example CRM",
"logo": "https://cdn.example.com/logo.svg",
"credentials": {
"format": "oauth2",
"config": {
"auth": {
"authorizeHost": "https://example.com",
"authorizePath": "/oauth/authorize",
"tokenHost": "https://example.com",
"tokenPath": "/oauth/token",
"refreshPath": "/oauth/token"
},
"scope": ["read:contacts", "write:contacts"],
"pkce": { "method": "S256" }
}
},
"authorization": {
"format": "bearer",
"config": { "path": "oauth.token.access_token" }
},
"pagination": {
"format": "cursor",
"config": {
"cursor_field": "paging.next.after",
"cursor_query_param": "after"
}
},
"rate_limit": {
"is_rate_limited": "status = 429",
"retry_after_header_expression": "headers.`retry-after`"
},
"resources": {
"contacts": {
"list": {
"method": "get",
"path": "/v3/contacts",
"response_path": "results"
},
"get": {
"method": "get",
"path": "/v3/contacts/{{id}}"
},
"create": {
"method": "post",
"path": "/v3/contacts",
"body_format": "json"
},
"update": {
"method": "patch",
"path": "/v3/contacts/{{id}}"
},
"delete": {
"method": "delete",
"path": "/v3/contacts/{{id}}"
}
}
},
"error_expression": "$exists(data.error) ? { 'status': 400, 'message': data.error.message }"
}Field Reference
| Field | Type | Purpose |
|---|---|---|
base_url |
string | Root URL prefixed onto every resource path. |
credentials.format |
enum | Authentication scheme: oauth2, oauth2_client_credentials, api_key, or oauth (OAuth 1.0). |
credentials.config.auth |
object | OAuth endpoint URLs - authorize, token, refresh, revoke. |
credentials.config.scope |
string [] | Required OAuth scopes. optional_scope holds additional non-required scopes. |
credentials.config.pkce |
object | Enables PKCE with { "method": "S256" }. |
authorization.format |
enum | How the token becomes a header: bearer, basic, or arbitrary header. |
authorization.config.path |
string | Dot-path into the account context where the token lives (e.g. oauth.token.access_token). |
pagination.format |
enum | One of six built-in strategies: cursor, page, offset, link_header, range, dynamic. |
pagination.config |
object | Strategy-specific settings - cursor field path, page param name, offset start value, and so on. |
rate_limit.is_rate_limited |
JSONata | Expression that returns true when a response is rate-limited. Falls back to HTTP 429 if omitted. |
rate_limit.retry_after_header_expression |
JSONata | Extracts retry-after seconds from provider response headers. |
resources.<name>.<method> |
object | Endpoint definition. Standard methods: list, get, create, update, delete. Custom methods allowed. |
resources.<name>.<method>.method |
enum | HTTP verb (get, post, put, patch, delete). |
resources.<name>.<method>.path |
string | URL path with {{placeholder}} substitution. |
resources.<name>.<method>.response_path |
string | Dot-path to the result array or object inside the response. |
resources.<name>.<method>.body_format |
enum | json, form, multipart, raw, or xml. |
resources.<name>.<method>.query |
object | Default query parameters applied to every request. |
resources.<name>.<method>.headers |
object | Method-level headers, merged onto integration-level headers. |
error_expression |
JSONata | Normalizes provider error responses into { status, message } shape for consistent error handling across integrations. |
Placeholders in paths and bodies ({{id}}, {{query.filter}}, {{body.title}}, {{context.subdomain}}) are resolved at request time from a merged context object that includes credentials, query parameters, request body, and any before-step outputs. The response mapping and query mapping are stored alongside the blueprint as JSONata expressions in a separate mapping record - the blueprint above only defines how to talk to the API.
End-to-End Tutorial: Building a Custom API Connector Without Code
In this tutorial, we will walk through defining a third-party API connection using a declarative configuration approach. We will define the authentication flow, map the data using JSONata expressions, normalize pagination, and handle incoming webhooks - all without writing custom imperative scripts.
We will use the architectural concepts from Truto's generic execution engine to illustrate how this is done in a production-grade environment.
Step 1: Defining the Authentication Flow Declaratively
Authentication is the most brittle component of any API integration. Managing OAuth 2.0 authorization code flows requires storing client credentials securely, handling redirects, exchanging codes for tokens, securely storing access and refresh tokens, and writing cron jobs or middleware to refresh tokens before they expire.
In a declarative model, you define the parameters of the authentication flow, and the execution engine handles the state management.
Here is an example of how an OAuth 2.0 flow is defined declaratively:
{
"auth": {
"type": "oauth2",
"grant_type": "authorization_code",
"authorization_url": "https://provider.com/oauth/authorize",
"token_url": "https://provider.com/oauth/token",
"client_id": "{{environment.client_id}}",
"client_secret": "{{environment.client_secret}}",
"scopes": ["read:contacts", "write:contacts"],
"token_refresh_behavior": "automatic"
}
}How the engine executes this: Instead of writing custom logic to track token expiration, the platform schedules work to handle token refresh automatically ahead of expiry. When a user initiates the connection, the engine dynamically constructs the authorization URL, handles the redirect callback, extracts the authorization code, and executes the token exchange.
If the provider returns an unexpected invalid_grant error during a refresh attempt, the engine flags the connection state, allowing you to trigger a re-authentication flow without writing custom error-handling logic per provider.
For more on the complexities of token management, see our guide on handling OAuth token refresh failures.
Step 2: Mapping the Unified API with JSONata
Once authenticated, the next challenge is translating the native API payload into a unified schema that your application understands.
Writing custom parsing logic for every API leads to massive switch statements and brittle code. Instead, declarative architectures use transformation languages like JSONata to translate payloads dynamically.
JSONata is a lightweight query and transformation language for JSON data. It allows you to extract values, apply conditional logic, and reshape objects without writing imperative code.
Consider a scenario where you want to map a native HubSpot contact into a unified Contact model.
{
"resource": "contacts",
"method": "GET",
"endpoint": "/crm/v3/objects/contacts",
"response_mapping": {
"id": "id",
"first_name": "properties.firstname",
"last_name": "properties.lastname",
"email": "properties.email",
"phone": "properties.phone ? properties.phone : null",
"created_at": "$toMillis(createdAt)"
}
}The Config Override Hierarchy Enterprise SaaS integrations rarely fit perfectly into a rigid, one-size-fits-all schema. Your enterprise customers will inevitably use custom fields in their CRM instances.
To handle this without code changes, Truto utilizes a 3-level JSONata architecture known as the Config Override Hierarchy:
- Platform Level: The baseline unified model provided by the platform.
- Integration Level: Overrides applied to a specific provider (e.g., adjusting the mapping for all Salesforce connections).
- Customer Level: Overrides applied to a specific tenant's linked account, allowing you to map a customer's bespoke custom field into your unified model without touching the global configuration.
This hierarchy allows you to support complex, enterprise-specific data requirements strictly through configuration. For a deeper look at this transformation layer, review our API schema normalization tutorial.
Step 3: Normalizing Pagination and Rate Limits
Third-party APIs handle pagination in wildly different ways. Some use cursor-based pagination, others use offset/limit, and some rely on URL link headers. Writing custom while-loops to handle these variations across fifty different APIs is a massive waste of engineering capacity.
In a declarative setup, you define the pagination strategy in the configuration file. The generic execution proxy intercepts the request, reads the strategy, and automatically builds the correct paginated requests.
{
"pagination": {
"type": "cursor",
"cursor_path": "paging.next.after",
"request_param": "after",
"limit_param": "limit",
"default_limit": 100
}
}When your application requests the next page of the unified resource, the execution engine injects the stored cursor into the native API request automatically.
Handling Rate Limits (HTTP 429) A critical architectural note regarding rate limits: high-performance unified APIs do not attempt to silently retry or absorb rate limit errors on behalf of the caller. Attempting to buffer requests globally across thousands of tenants leads to unpredictable latency and distributed queuing nightmares.
When an upstream API returns an HTTP 429 Too Many Requests error, Truto passes that error directly to the caller. However, it normalizes the upstream rate limit information into standardized headers per the IETF specification:
ratelimit-limit: The maximum number of requests allowed in the current window.ratelimit-remaining: The number of requests remaining in the current window.ratelimit-reset: The time at which the rate limit window resets.
The calling application is responsible for reading these standardized headers and implementing its own retry logic with exponential backoff. This separation of concerns ensures that your core application retains full control over its scheduling and backoff behavior, while the integration layer focuses strictly on normalization.
Step 4: Handling Webhooks Without Custom Endpoints
Polling APIs for changes is inefficient and quickly exhausts rate limits. Webhooks are the preferred method for real-time data synchronization, but they present significant infrastructure challenges.
Every SaaS provider formats webhooks differently. They use different HTTP methods, different payload structures, and critically, different cryptographic signature methods for verification (HMAC SHA-256, RSA, custom headers).
If you build this in-house, you have to deploy a public-facing ingestion gateway, write custom verification middleware for every provider, and maintain a durable queue to handle traffic spikes.
With a declarative unified API, you bypass this entirely. The platform provides a centralized ingestion gateway that receives webhooks from all third-party providers.
graph TD
A[Third-Party Provider] -->|HTTP POST| B(Ingestion Gateway)
B --> C{Verify Signature}
C -->|Valid| D[JSONata Transformation]
C -->|Invalid| E[Drop Request]
D --> F[Standardize Payload]
F --> G[Sign with X-Truto-Signature]
G --> H[Deliver to Customer Endpoint]The declarative configuration defines how to verify the incoming signature and how to map the native event payload into a unified event structure.
The platform then delivers this normalized event to a single webhook endpoint hosted by your application. Every outbound webhook delivered by Truto is signed with a standardized X-Truto-Signature header.
Your engineering team only needs to write one piece of verification logic to validate the Truto signature, rather than maintaining fifty different HMAC verification scripts for fifty different providers.
Security Best Practice: Always verify webhook signatures before processing payloads to prevent replay attacks and spoofing. Relying on a unified API platform standardizes this process, reducing the surface area for cryptographic implementation errors.
Exposing Complex APIs via the Proxy Layer
One of the most powerful features of a mature declarative architecture is the ability to simplify complex vendor APIs.
For example, modern project management tools like Linear rely heavily on GraphQL. Integrating with GraphQL APIs requires constructing complex query strings and understanding deep graph relationships.
A declarative proxy layer can expose these GraphQL-backed integrations as standard RESTful CRUD resources. By defining the GraphQL query in the configuration and using placeholder-driven request building, the proxy translates a standard GET /unified/issues request into the complex GraphQL query required by the provider, extracts the relevant nodes using JSONata, and returns a flat, RESTful JSON response to the caller.
This allows your engineering team to interact with complex graph architectures using standard HTTP methods.
Install to Staging and Production
Once your JSON blueprint is defined, promoting a connector from development to production is a config operation, not a deploy.
In staging:
- Open the Truto dashboard and navigate to Integrations → New integration. Paste your blueprint JSON into the config editor and save. The integration editor exposes every field from the blueprint - base URL, credentials, authorization, pagination, resources, error expression - as either a structured form or a raw JSON view, so you can edit safely without repo access.
- From your staging environment, connect a test account using the sandbox credentials of the provider. The OAuth or API key flow uses the
credentialsblock from your blueprint automatically - no additional wiring required. - Call the unified API against your test account (for example,
GET /unified/crm/contacts?integrated_account_id=<staging_account_id>). Any config changes you make in the editor are picked up on the next request. There is no runtime restart, no build step, no cache to invalidate.
Where to find the config editor: In the Truto dashboard, open any integration and click Edit integration in the top right. Scroll to the Resources section to add or edit endpoints, or switch to the Raw config tab to edit the full JSON blueprint.
Promote to production:
- Export the integration config from staging (JSON download from the editor, or
truto integrations get <id> -o jsonfrom the CLI). - Import it into your production environment. Environment-specific values - OAuth client IDs and secrets, custom scopes, region-specific base URLs - are applied through environment-level overrides so the base config stays identical across environments. This is what makes the promotion safe: the config that worked in staging is the config running in production, with only the credentials swapped.
- Verify by connecting a production account and running a smoke-test request against each
listendpoint.
Because the runtime engine reads config from the database on every request, the platform picks up config changes immediately with no code deploy. A JSONata mapping updated at 2:03 PM is live at 2:03 PM. This is the defining property of a declarative integration architecture - shipping a new connector or fixing a broken one is a data write, not a release.
Testing and Validation Checklist
Before rolling a new connector out to customers, walk through this checklist. Each item catches a class of bug that shows up in production if skipped.
Authentication
- OAuth flow completes end-to-end using the provider's sandbox credentials
- Token refresh happens automatically before expiry (verify by inspecting
expires_aton the connected account after waiting past the initial expiry window) - A revoked refresh token surfaces as a re-auth required state, not a silent failure
- Required scopes match what the unified endpoints actually need - no more, no less
Endpoints
- Each resource method (
list,get,create,update,delete) returns a response that maps cleanly to the unified schema - Path placeholders (
{{id}},{{context.subdomain}}) resolve correctly against real request data - Custom methods return the expected shape and are exposed as tools if you plan to use them with MCP clients
Pagination
- First page returns without a cursor
-
next_cursorfrom a response feeds back into the next request unchanged - The last page returns no cursor (or an empty one) so pagination terminates cleanly
-
limitparameter is honored by the provider and by the response mapper
Response mapping
- Every field in the unified schema is populated for at least one test record
- Null-safe expressions handle missing optional fields (e.g.
field ? field : null) -
remote_datais preserved for any consumer that needs raw provider fields - Date fields are normalized to ISO 8601 (use
$fromMillisor$toMilliswhere appropriate)
Errors
- HTTP 401 from the provider marks the account for re-auth and fires the corresponding webhook event
- HTTP 429 forwards standardized
ratelimit-limit,ratelimit-remaining, andratelimit-resetheaders to the caller - Provider-specific error shapes are unwrapped by
error_expressioninto{ status, message } - A malformed request returns a useful error message, not a stack trace or raw provider JSON
Webhooks (if applicable)
- Verification signature validates against a real payload from the provider
- JSONata mapping produces the expected
record:created,record:updated, andrecord:deletedevents - Enrichment step fetches full resource data when the webhook only carries an ID
Troubleshooting Common Errors
Most failures during connector development fall into a small number of categories. Here is how to diagnose them without reading source code.
"Resource X not found for integration Y"
The resource name in your unified API call does not match a key under resources in the blueprint. Check spelling and casing - resource names are case-sensitive. If you renamed a resource in the config, existing callers using the old name will start failing until they update.
Response mapping returns null for every field
The response_path is wrong. Log the raw response and confirm the array lives where you think it does (data.results vs. results vs. data.items). Also confirm the JSONata expression is evaluating against the right shape - for list methods, the mapping is applied per-item, not to the whole array, so the expression should reference response.field, not response.results [0].field.
Placeholders show up literally in the URL ({{id}} appears in the request)
The context did not carry the value you referenced. {{id}} is only populated for get, update, and delete methods. For list methods, filter values come from {{query.<param>}}. For post-connect metadata (subdomain, tenant ID), use {{context.<field>}}. Check your placeholder path against what is actually available for that method.
"invalid_grant" during token refresh The refresh token was revoked, expired, or the OAuth client credentials in the environment override do not match the client used at connect time. Ask the customer to reconnect. If it happens across many accounts at once, check whether the provider rotated their signing keys or your team rotated the client secret without updating the environment override.
Provider returns HTTP 200 with an error in the body
Add or refine the error_expression. Without it, an error masquerading as success flows straight through the mapping layer and produces confusing null-heavy responses. Have the expression return { "status": 4xx, "message": ... } based on the body's error shape - for example, $not(data.ok) ? { "status": 400, "message": data.error } for a Slack-style response.
Pagination loops forever or never returns the next page
The cursor_field path is wrong. Log the response and confirm the cursor path in the config points at the actual next-page token. For providers that stop returning a cursor on the last page, ensure the config does not require one - the engine will terminate automatically when the cursor is absent.
Rate limit headers not appearing on the caller's response
The provider uses non-standard header names. Configure rate_limit_header_expression to remap them to the ratelimit-limit/ratelimit-remaining/ratelimit-reset format the caller expects.
Webhook signature verification fails The signature format is right but the parts list is wrong. HMAC verification usually signs a specific concatenation of headers, timestamp, and body - check the provider's docs for the exact string to sign. Also confirm you are verifying against the raw body, not the parsed JSON (parsing reorders keys and breaks the hash).
A config change did not take effect Config is read from the database on every request, so there is no cache to flush. If you edited a mapping but the response looks unchanged, confirm you saved the change in the correct environment (staging vs. production) and that you are calling the unified API against an account in that environment.
How Maintenance Is Reduced When APIs Change
The real cost of integrations is not the initial build - it is the years of maintenance that follow. API providers deprecate endpoints, change response schemas, and overhaul versioning systems on their own timelines. You either keep up or your customers experience broken integrations.
Here is a real-world example. HubSpot sunset their Contact Lists v1 API on April 30, 2026, requiring all consumers to migrate to the v3 Lists API. The v3 API uses different endpoint paths, different ID formats (listId instead of legacyListId), and different response structures. HubSpot also introduced an entirely new date-based versioning system in March 2026, replacing the older v1/v2/v3/v4 naming with a /YYYY-MM/ format.
This is not an isolated event. API lifecycle changes like these are a constant operational reality for SaaS products that integrate with third-party platforms. Every deprecation triggers scoping work, and every sunset deadline creates a hard cutoff your engineering team did not choose.
What this looks like with code-first integrations:
Every adapter file that touched the v1 Contact Lists API needs a rewrite. Endpoint paths change. Response parsing logic changes. ID references stored in your database may need migration. You scope the work, write the code, update the tests, review, and deploy - under a deadline set by HubSpot, not by your sprint planning. While you are migrating HubSpot Lists to v3, another provider might deprecate their own endpoints. The maintenance burden scales linearly with your integration count.
What this looks like with a declarative architecture:
The migration is a config update. You change the path field from the v1 endpoint to the v3 endpoint, adjust the response_path if the response structure changed, and update the JSONata response mapping to handle the new field names. No adapter code is rewritten. No application code is redeployed. The generic execution engine does not know or care that HubSpot changed their API - it reads the updated config and executes it.
Because every integration flows through the same engine, improvements compound in your favor instead of working against you. When the pagination handler is improved, all integrations benefit. When error handling is tightened, it applies everywhere. The maintenance burden scales with the number of unique API patterns, not the number of integrations.
When You Still Need Custom Code (and How to Isolate It)
A declarative architecture handles the vast majority of standard API interactions: REST endpoints, OAuth and API key auth, JSON and XML responses, cursor/page/offset pagination, and field-level data mapping. That covers 90%+ of SaaS integrations.
But some scenarios push past what pure configuration can express:
-
Multi-step orchestration - Some operations require calling multiple API endpoints in sequence, where the output of one call feeds into the next. For example, creating a CRM contact and then associating it with a company requires two API calls with data flowing between them. Declarative architectures handle this through before/after step pipelines - ordered sequences of calls with conditional JSONata logic - rather than custom code.
-
Non-standard protocols - A small number of enterprise APIs use SOAP, proprietary binary formats, or custom RPC protocols that fall outside a REST/GraphQL config schema. The practical approach is to wrap these behind a proxy endpoint that normalizes them into REST, then treat the proxy as the "API" for your declarative config.
-
Business logic between API calls - If your application needs to run custom validation, deduplication, or enrichment logic between the inbound API response and your database write, that logic belongs in your application layer - not in the integration layer. The integration platform delivers clean, normalized data. What you do with it afterward is your responsibility.
-
Truly unique provider behaviors - Some providers return different response formats depending on the account's subscription tier, or require fetching supplementary data before a webhook payload is usable. JSONata expressions are Turing-complete and can handle most conditional logic, but occasionally you may need a raw proxy call to work around a one-off edge case.
The key principle is isolation. Your integration layer should be declarative. Your business logic layer should be code. The two connect through a clean API boundary - your application calls the unified API, receives normalized data, and applies its own rules. When a third-party API changes, only the declarative config changes. Your application code stays untouched.
Shipping Integrations as Data, Not Code Deploys
Transitioning from imperative scripts to declarative configurations fundamentally changes how a software company operates.
When integrations are defined as JSON configurations and JSONata mappings, they cease to be engineering projects. They become data-only operations. This means Product Managers, Solutions Engineers, and Technical Account Managers can build, modify, and launch new custom API connectors without waiting for an engineering sprint or going through a full CI/CD code deployment cycle.
If a vendor updates their API schema, you update a JSONata string in the platform dashboard. If an enterprise customer needs a custom field mapped, a Solutions Engineer updates the customer-level override configuration. The core application code remains untouched.
Building custom API connectors without code is not about using visual drag-and-drop toys. It is about adopting a rigorous, generic execution architecture that separates the volatile mechanics of third-party APIs from the stable business logic of your application.
FAQ
- How much does it cost to build a custom API integration?
- Building a single custom API integration from scratch can easily cost $10,000 or more in initial engineering time, not including the ongoing financial drain of maintenance, monitoring, and infrastructure.
- What is a declarative API integration?
- A declarative API integration uses a configuration file (like JSON) to define the authentication, endpoints, and data mappings. A generic execution engine reads this file at runtime, eliminating the need to write custom imperative code for each provider.
- How do you handle API rate limits without custom code?
- A unified API platform passes HTTP 429 Too Many Requests errors directly to the caller, normalizing the upstream rate limit data into standardized headers. The calling application then applies its own exponential backoff logic.
- Can I build custom API connectors without an engineering team?
- Yes. By using a declarative architecture, Product Managers and Solutions Engineers can define API mappings and authentication flows via configuration files, allowing them to ship integrations without waiting for an engineering sprint.