How to Publish End-to-End Developer Tutorials with Runnable API Examples
Publish developer tutorials that reduce Time to First Call, plus a white-labeled integration marketplace architecture: JSON provider configs, three-tier overrides, Link SDK.
When enterprise procurement teams evaluate your B2B SaaS product, the real decision-maker is rarely the person holding the budget. As we've discussed regarding reproducible API benchmarking, the true buyer is a lead architect or staff engineer who evaluates your platform by opening your documentation, finding a code snippet, and attempting to run it. If you ship a B2B SaaS product with a public API, the highest-leverage thing your product team can publish is an end-to-end developer tutorial with runnable API examples.
Developers evaluate APIs based on friction. They will paste your example into a terminal or an IDE, run it, and decide in under five minutes whether your platform is worth their time. Not by reading a reference page. Not by scrolling a Swagger dump. If your tutorial requires them to spend three hours reverse-engineering undocumented payloads, guessing OAuth scopes, or writing custom retry logic from scratch, your product fails the technical evaluation.
This guide gives senior PMs and developer advocates a concrete framework for writing tutorials that convert evaluating developers into active users. We will cover how to optimize for Time to First Call (TTFC), handle the painful realities of authentication and rate limits in your code examples, and scale your documentation across dozens of third-party integrations using a unified API architecture.
Why Time to First Call (TTFC) Is Your Most Important API Metric
Time to First Call (TTFC) is a developer experience metric that measures the elapsed time from a developer signing up for your service to executing their first successful, authenticated API request that returns a non-error response.
API tutorials are not just reference documentation—they are a primary product growth lever. Industry leaders define TTFC as the most critical metric for evaluating developer onboarding success. Postman calls TTFC the most important metric you'll need for a public API, and the data backs it up. In a controlled experiment across multiple API publishers, developers were 1.7 times faster making their first call when using a collection provided by the API publisher, with some APIs reaching up to 56 times faster. PayPal, for instance, used a public Postman Collection to reduce its Time to First Call from hours to exactly one minute.
When you sell into the enterprise, integrations are a top-three buyer consideration, sitting only behind security and ease of use. As detailed in our guide to building a high-converting SaaS integrations page, data from PartnerFleet indicates that roughly 51% of B2B buyers cite poor integration with their existing tech stack as a primary reason to explore alternative software vendors. Furthermore, 90% of B2B buyers either agree or strongly agree that a vendor's ability to integrate with their existing technology significantly influences their decision to add them to the shortlist.
Your API is the surface your prospects' engineers touch first. If your documentation consists only of an auto-generated Swagger UI or a static list of endpoints, you are forcing the user to build the integration mental model from scratch. A high TTFC correlates directly with abandoned evaluations.
TTFC is not a vanity metric. A faster time to first call equates to faster time to value, which has downstream impacts like higher conversion and retention rates. Treat it as a leading indicator for activation, not as a docs-team OKR.
The Anatomy of an End-to-End Developer Tutorial That Actually Converts
Writing a top-tier developer tutorial requires radical honesty about how software is actually built. A reference page lists what your API can do. A tutorial walks a developer through getting a specific business outcome end-to-end. The two are not interchangeable.
An effective end-to-end tutorial consists of these specific, non-negotiable components, ideally in this exact order:
1. A One-Sentence Business Outcome
Avoid generic "Hello World" examples. A developer integrating your product is trying to solve a specific workflow problem. Frame your tutorials around tangible business outcomes.
Instead of "How to use the POST /contacts endpoint," write "How to sync new marketing leads to Salesforce." Instead of "Fetching Invoices," write "How to extract the last 30 days of paid invoices for commission calculations."
2. Prerequisites with Explicit Versions
State exactly what the developer needs before they begin. Node 20+, Python 3.11+, a sandbox account URL. Pin everything so there is no ambiguity about environment compatibility.
3. Clear Authentication Steps
Authentication is historically where the highest drop-off occurs. Do not assume the developer knows how to obtain your specific flavor of API key or OAuth token. If a developer has to file a support ticket just to get an API key, the tutorial is dead.
Your tutorial must explicitly state:
- Where to find the credentials in the UI (ideally, provide a sandbox where credentials are issued instantly).
- Which specific OAuth scopes are required for the tutorial's operations.
- How to format the
Authorizationheader.
4. Copy-Pasteable, Runnable Code Blocks
Your code blocks must be complete scripts. Do not provide fragmented snippets that require the user to guess the import statements or environment variables. A single runnable code block per step is vastly superior to interleaved prose that breaks the copy-paste flow.
The trap most PMs fall into is writing tutorials that demo the SDK, not the business outcome. Developers don't need a client.init() call lesson—they need to see the smallest possible code path from zero to a real CRM contact appearing in their database.
Here is an example of a solid SDK-driven template in TypeScript:
// Goal: Create a contact in any CRM and confirm it landed.
import { Truto } from '@truto/sdk'
const truto = new Truto({ apiKey: process.env.TRUTO_API_KEY })
const contact = await truto.unified.crm.contacts.create({
integratedAccountId: process.env.ACCOUNT_ID,
data: {
first_name: 'Ada',
last_name: 'Lovelace',
email_addresses: [{ email: 'ada@example.com', type: 'work' }]
}
})
console.log('Created:', contact.id)Never hide authentication complexity behind a proprietary SDK in your tutorials without also explaining the underlying HTTP request. Senior engineers often need to implement your API in languages or frameworks where your SDK is not supported. Always show the raw HTTP headers in at least one example.
Here is how that same concept looks when demonstrating the raw HTTP request in Python:
import os
import requests
# 1. Set your credentials
API_KEY = os.getenv("TRUTO_API_KEY")
TENANT_ID = "cust_12345"
# 2. Define the exact headers required
headers = {
"Authorization": f"Bearer {API_KEY}",
"X-Tenant-ID": TENANT_ID,
"Content-Type": "application/json"
}
# 3. Execute the request with a realistic payload
response = requests.post(
"https://api.truto.one/crm/contacts",
headers=headers,
json={
"first_name": "Jane",
"last_name": "Doe",
"email": "jane.doe@example.com",
"company_name": "Acme Corp"
}
)
print(f"Created contact: {response.json().get('id')}")5. Expected Output and Explicit Error Cases
Show the expected JSON output for every call so the developer can verify they're on the right track without reading 200 lines of raw JSON. Just as importantly, show what an explicit error case looks like. Show what a 401 Unauthorized, a 429 Too Many Requests, and a 400 Malformed Payload look like in practice.
For a deeper breakdown of how to structure runnable snippets and convert them into discoverable content, see our guide on how to publish developer API recipes with runnable code.
Handling Authentication and Rate Limits in API Examples
Third-party APIs are notoriously hostile environments. Tokens expire, endpoints throttle aggressively, and undocumented edge cases cause silent failures. If your tutorial ignores these realities, the developer's integration will break in production, resulting in support tickets routed directly to your engineering team.
Standardizing HTTP 429s and Retries
Rate limiting is the most common failure mode in API integrations. Every public API returns an HTTP 429 Too Many Requests eventually, and the headers that signal when to retry are historically inconsistent across vendors. Do not pretend your API has infinite throughput. Teach developers how to handle these errors gracefully.
When writing tutorials, show developers exactly how to read rate limit headers and implement exponential backoff. The IETF draft standard defines three response headers: ratelimit-limit, ratelimit-remaining, and ratelimit-reset.
Truto normalizes upstream rate limit information into these standardized IETF headers. However, Truto does not retry or absorb rate limit errors on behalf of the user. When an upstream API returns a 429, Truto surfaces that error directly to the caller. This architectural decision ensures transparency, keeps backoff strategies in the application layer where they belong, and prevents hidden, hard-to-debug latency spikes.
Your tutorial should provide a standardized retry loop that developers can drop into their code. Here is how that looks in TypeScript:
async function callWithBackoff(fn, attempts = 5) {
for (let i = 0; i < attempts; i++) {
try {
return await fn()
} catch (err) {
if (err.status !== 429) throw err
// Read the standardized IETF header provided by Truto
const reset = Number(err.headers['ratelimit-reset'] ?? 1)
const jitter = Math.random() * 250
console.log(`Rate limited. Retrying in ${reset} seconds...`)
await new Promise(r => setTimeout(r, reset * 1000 + jitter))
}
}
throw new Error('Exhausted retries')
}And the equivalent logic in Python:
import time
import requests
def fetch_with_backoff(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
# Read the standardized IETF header provided by Truto
reset_time = int(response.headers.get('ratelimit-reset', 5))
print(f"Rate limited. Retrying in {reset_time} seconds...")
time.sleep(reset_time)
continue
response.raise_for_status()
return response.json()
raise Exception("Max retries exceeded")For more context on this pattern, read our guide on best practices for handling API rate limits and retries across multiple third-party APIs.
Documenting OAuth Flows
Managing OAuth 2.0 token lifecycles is a massive operational burden. Access tokens expire, refresh tokens are revoked, and concurrency issues can cause invalid grants.
If you are building point-to-point integrations—rather than using an embeddable Link SDK to handle the UI and token storage—your tutorials must explain how to store and rotate these tokens securely. Provide a sandbox where credentials are issued instantly, and show what an expired token looks like. Most developers ship token refresh as an afterthought. A tutorial that demonstrates the failure mode prevents a production incident.
However, if you are using a modern integration platform, this complexity is abstracted away. For example, Truto handles OAuth token refresh proactively. The platform schedules work ahead of token expiry, checking the token's validity before every API call and refreshing it automatically. If a refresh fails, the system emits an integrated_account:authentication_error webhook. This allows your tutorials to focus entirely on business logic rather than token lifecycle management. You can dive deeper into this architecture in our post on handling OAuth token refresh failures in production.
The Operational Trap of Scaling Tutorials Across 50+ Integrations
Here's where most B2B SaaS teams hit a wall. Writing one excellent tutorial for Salesforce is hard. Writing 50 excellent tutorials for Salesforce, HubSpot, Pipedrive, Zendesk Sell, and Dynamics 365 is a logistical nightmare.
The N+1 Documentation Problem
Let's say your product needs to support CRM sync for the top eight CRMs your customers use. To publish a credible "sync contacts" tutorial for each one, you face the N+1 documentation problem. Every system has its own distinct data model, pagination strategy, and query language.
- Salesforce requires SOQL queries, uses offset pagination, and has a highly custom schema.
- HubSpot uses cursor-based pagination and a completely different JSON structure for contacts.
- Zendesk uses link-based pagination.
The naive approach is to write eight separate tutorials. That seems fine until you realize each one drifts independently. HubSpot deprecates v1 contacts, Salesforce changes its pagination semantics, Pipedrive renames a field. Now you're not writing eight tutorials; you're maintaining eight forever.
flowchart TD
A[1 PM writes 1 tutorial per CRM] --> B[8 Separate Tutorials]
B --> C[8 SDKs to Track]
C --> D[8 Auth Flows to Debug]
D --> E[8 Schemas Drift Independently]
E --> F[Tutorials Rot Within 6 Months]
F --> G[Support Tickets + Abandoned Evaluations]This brute-force approach to documentation does not scale. It drains engineering resources and creates a fragmented developer experience. Businesses with five integrations are willing to pay 20% more for the same core product, but you can't ship five integrations if your technical writing budget caps you at two. PMs feel this as "we keep promising integrations on the roadmap and slipping."
How a Unified API Lets You Write One Tutorial for Every Provider
To scale your integration documentation, you must decouple the business logic from the underlying provider's quirks. A unified API collapses the matrix. Instead of writing one tutorial per provider, you write one tutorial per unified model—and it works against every underlying integration in that category.
Zero Integration-Specific Code
The architectural pattern is straightforward: a generic execution engine reads declarative configuration that describes how to talk to each third-party API, plus declarative mappings that translate between native and unified data shapes.
Truto's architecture is built on a fundamental principle: zero integration-specific code. There are no if (provider === 'hubspot') statements in the runtime logic. Integration-specific behavior is defined entirely as declarative data—JSON configuration blobs and JSONata expressions that map provider-specific fields to a unified schema.
Because the execution engine is generic, your tutorials become generic as well. A single tutorial then looks like this:
// Works against ANY connected CRM. No provider-specific branches.
const contacts = await truto.unified.crm.contacts.list({
integratedAccountId: ACCOUNT_ID,
pageSize: 100,
filter: { updated_after: '2026-04-01T00:00:00Z' }
})
for (const c of contacts.data) {
console.log(c.id, c.email_addresses[0]?.email)
}
if (contacts.next_cursor) {
// Same pagination contract for every provider.
}The developer reading this never sees the underlying Salesforce SOQL query or HubSpot v3 cursor. Pagination is normalized. Field names are normalized. Errors are normalized. Rate-limit headers follow the IETF draft so the same backoff loop works everywhere.
What does this do to your tutorial backlog?
| Approach | Tutorials to Write | Tutorials to Maintain |
|---|---|---|
| Point-to-Point (One per provider) | 8 (CRM) + 6 (HRIS) + 12 (ATS) = 26 | All 26, forever |
| Unified API | 3 (One per category) | 3 |
This is the deeper point of the zero integration-specific code pattern: if your runtime doesn't branch on provider, your documentation doesn't have to either.
The Honest Trade-offs
A unified API is not free. A top-tier tutorial acknowledges these limits. The trade-offs are real:
- Long-tail field coverage: Unified models cover the 80% case. Custom fields and non-standard objects still need passthrough APIs or per-customer overrides.
- Latency layer: You're adding a network hop. For most use cases this is negligible, but real-time use cases (sub-100ms) deserve scrutiny.
- Vendor lock-in shape: You're now dependent on the unified model evolving alongside the underlying providers.
Show developers when to use the unified endpoint and when to drop down to a passthrough call. Developers respect candor about edge cases far more than marketing claims of "works with everything."
Handling Bulk Data Extraction and ETL Workflows Through Unified APIs
The tutorials above cover interactive API usage - a single call, a single response. But most enterprise integration work is not interactive. It's bulk extraction: pulling tens of thousands of CRM contacts, HRIS employee records, or ticketing data into your own database or data warehouse on a recurring schedule. This is the ETL problem, and it's where naive API tutorials fall apart.
Building 40 custom ETL pipelines is not realistic. Each one requires managing OAuth tokens, pagination quirks, rate limits, and schema differences for every provider. A unified API collapses this complexity, but only if your extraction pipeline is designed for bulk workloads from the start. For a deeper dive into the architectural trade-offs between store-and-sync and pass-through unified API models, see our guide on ETL workflows using unified APIs.
End-to-End Bulk Extraction Architecture
A production-grade bulk extraction pipeline built on a unified API has five layers: authentication, paginated extraction, transformation, checkpointed loading, and scheduling.
flowchart TD
subgraph Scheduling
SCHED[Cron / Orchestrator]
end
subgraph Authentication
SCHED --> AUTH[Token Manager<br>per integrated account]
end
subgraph Extraction
AUTH --> PAGINATE[Cursor-based<br>paginator]
PAGINATE --> RATE[Rate-limit<br>aware fetcher]
RATE -->|next_cursor| PAGINATE
end
subgraph Transform
RATE --> NORM[Normalize to<br>unified schema]
end
subgraph Load
NORM --> CHECKPOINT[Checkpoint<br>manager]
CHECKPOINT --> UPSERT[Idempotent<br>UPSERT to DB]
UPSERT --> LOG[Sync run<br>metadata log]
endThe key insight: every layer except the load target is provider-agnostic when you use a unified API. The same paginator, the same rate-limit handler, and the same checkpoint logic work whether the source is Salesforce, HubSpot, or BambooHR.
Authentication and Tenant-Aware Token Management
In a multi-tenant SaaS product, you're not managing one OAuth token - you're managing hundreds or thousands, one per customer connection. Bulk extraction amplifies every token management weakness. A sync job that runs for 45 minutes will outlive most access tokens (typically 30-60 minutes), so your pipeline must handle mid-run token expiry gracefully.
If you're building this yourself, the requirements are steep:
- Proactive refresh: Schedule token refreshes ahead of expiry, not in reaction to a 401. A randomized window (e.g., 60-180 seconds before expiry) spreads load and avoids thundering herds across accounts.
- Concurrency control: Multiple sync jobs for the same account must not race to refresh the same token. Use a mutex or lock-per-account pattern so concurrent callers await the in-progress refresh rather than triggering duplicate refresh requests.
- Failure handling: When a refresh fails (revoked token, expired grant), mark the account as
needs_reauth, emit a webhook, and skip rather than retry indefinitely. Aninvalid_granterror will never succeed no matter how many times you retry it.
Truto handles all of this automatically. The platform refreshes OAuth tokens proactively before they expire, with one refresh operation per account serialized through a lock to prevent races. Before every API call - including each page of a bulk extraction - Truto validates the token and refreshes if needed. If a refresh fails, the account is flagged and an integrated_account:authentication_error webhook fires so your system can notify the affected customer.
Here's how a tenant-aware extraction loop looks in practice:
// Pull all active integrated accounts for a given category
const accounts = await truto.integratedAccounts.list({
unifiedModel: 'crm',
status: 'active'
})
for (const account of accounts) {
try {
// Token refresh happens automatically per-account before each call
await extractAllContacts(account.id)
} catch (err) {
if (err.status === 401) {
// Account needs re-authentication - skip and alert
console.error(`Account ${account.id} needs reauth, skipping`)
continue
}
throw err
}
}Pagination and Parallelization Patterns
Bulk extraction means paginating through entire datasets. A CRM with 500,000 contacts at 100 records per page requires 5,000 API calls just for one resource. Your pipeline needs to handle this efficiently.
Sequential Cursor-Based Extraction
Cursor-based pagination is the most reliable strategy for bulk extraction. Unlike offset pagination, it won't skip or duplicate records when the underlying data changes mid-extraction. Truto normalizes all provider pagination strategies (cursor, page, offset, link-header) into a single next_cursor / prev_cursor interface.
async function extractAllRecords(
accountId: string,
resource: string,
onBatch: (records: any[]) => Promise<void>
) {
let cursor: string | undefined
let totalExtracted = 0
do {
const response = await callWithBackoff(() =>
truto.unified.crm[resource].list({
integratedAccountId: accountId,
pageSize: 200,
nextCursor: cursor,
filter: { updated_after: getLastSyncTimestamp(accountId, resource) }
})
)
await onBatch(response.result)
totalExtracted += response.result.length
cursor = response.next_cursor
console.log(`Extracted ${totalExtracted} ${resource} so far...`)
} while (cursor)
return totalExtracted
}Parallelizing Across Accounts and Resources
You cannot parallelize pages within a single cursor-based extraction - each page depends on the previous cursor. But you can parallelize across two dimensions:
- Across accounts: Sync customer A's contacts concurrently with customer B's contacts.
- Across resources: Sync one customer's contacts, deals, and companies in parallel.
The constraint is the upstream provider's rate limit, which is typically per-account. A safe default is 3-5 concurrent resource extractions per account, with a global concurrency pool of 10-20 accounts in flight.
import pLimit from 'p-limit'
const accountConcurrency = pLimit(15) // Max accounts in parallel
const resourceConcurrency = pLimit(4) // Max resources per account
const resources = ['contacts', 'deals', 'companies', 'notes']
await Promise.all(
accounts.map(account =>
accountConcurrency(async () => {
await Promise.all(
resources.map(resource =>
resourceConcurrency(() =>
extractAllRecords(account.id, resource, batch =>
upsertBatch(account.id, resource, batch)
)
)
)
)
})
)
)Set pageSize to the maximum the provider allows (usually 100-250). Fewer pages means fewer API calls and less time spent on network round-trips. Truto's unified API accepts a limit parameter and requests the maximum batch size the underlying provider supports.
Checkpointing and Replay/Backfill Strategy
Bulk extractions fail. Networks drop, rate limits hit, and providers have outages. Without checkpointing, a failure at record 450,000 of 500,000 means starting over from scratch.
High-Watermark Checkpointing
The most practical checkpointing strategy for API-based ETL is a high-watermark pattern using the updated_at timestamp. After each successful batch, persist the most recent updated_at value you've seen. On the next run - or after a failure recovery - resume from that watermark.
interface SyncCheckpoint {
accountId: string
resource: string
lastUpdatedAt: string // ISO 8601
lastCursor: string | null
status: 'in_progress' | 'completed' | 'failed'
recordsSynced: number
startedAt: string
}
async function saveCheckpoint(checkpoint: SyncCheckpoint) {
await db.query(
`INSERT INTO sync_checkpoints
(account_id, resource, last_updated_at, last_cursor, status, records_synced, started_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (account_id, resource)
DO UPDATE SET
last_updated_at = EXCLUDED.last_updated_at,
last_cursor = EXCLUDED.last_cursor,
status = EXCLUDED.status,
records_synced = EXCLUDED.records_synced`,
[checkpoint.accountId, checkpoint.resource, checkpoint.lastUpdatedAt,
checkpoint.lastCursor, checkpoint.status, checkpoint.recordsSynced,
checkpoint.startedAt]
)
}Incremental Sync vs. Full Backfill
Design your pipeline with two explicit modes:
- Incremental sync (default): Filter by
updated_afterusing the last checkpoint's watermark. Only new and modified records are fetched. This is what runs on your cron schedule - every 15 minutes, hourly, or daily. - Full backfill: Ignore the watermark and paginate through the entire dataset. Trigger this manually when onboarding a new customer, after a schema migration, or to reconcile data drift. Use idempotent upserts (INSERT ... ON CONFLICT DO UPDATE) so backfills are safe to run repeatedly.
async function syncResource(accountId: string, resource: string, mode: 'incremental' | 'full') {
const checkpoint = await getCheckpoint(accountId, resource)
const filter = mode === 'incremental' && checkpoint?.lastUpdatedAt
? { updated_after: checkpoint.lastUpdatedAt }
: {} // Full backfill - no filter
let cursor: string | undefined
let highWatermark = checkpoint?.lastUpdatedAt || '1970-01-01T00:00:00Z'
let count = 0
do {
const response = await callWithBackoff(() =>
truto.unified.crm[resource].list({
integratedAccountId: accountId,
pageSize: 200,
nextCursor: cursor,
filter
})
)
// Idempotent upsert - safe for both incremental and backfill
await upsertBatch(accountId, resource, response.result)
count += response.result.length
// Track the highest updated_at seen in this batch
for (const record of response.result) {
if (record.updated_at > highWatermark) {
highWatermark = record.updated_at
}
}
// Checkpoint after each batch so we can resume on failure
await saveCheckpoint({
accountId, resource,
lastUpdatedAt: highWatermark,
lastCursor: response.next_cursor || null,
status: 'in_progress',
recordsSynced: count,
startedAt: new Date().toISOString()
})
cursor = response.next_cursor
} while (cursor)
await saveCheckpoint({
accountId, resource,
lastUpdatedAt: highWatermark,
lastCursor: null,
status: 'completed',
recordsSynced: count,
startedAt: new Date().toISOString()
})
return count
}Timestamp-based incremental sync cannot detect deletions. The source record simply disappears - no updated_at change occurs. Handle this with periodic full reconciliation (e.g., weekly backfill) or by consuming delete webhooks if the provider supports them. Truto's unified webhooks can forward provider delete events to your endpoint.
Rate-Limit Handling for Bulk Workloads
The retry loop shown earlier in this article handles individual 429 errors. Bulk extraction needs a more systematic approach because you're making thousands of sequential calls and a single rate-limit hit can cascade.
Adaptive Throttling
Instead of waiting for a 429 and then backing off, read the ratelimit-remaining header on every successful response and proactively slow down as you approach the limit:
async function throttledFetch(fn: () => Promise<any>) {
const response = await callWithBackoff(fn)
const remaining = Number(response.headers?.['ratelimit-remaining'])
const limit = Number(response.headers?.['ratelimit-limit'])
const reset = Number(response.headers?.['ratelimit-reset'])
// When less than 10% of quota remains, spread remaining calls across the reset window
if (remaining && limit && remaining < limit * 0.1) {
const delayMs = (reset / Math.max(remaining, 1)) * 1000
console.log(`Throttling: ${remaining}/${limit} remaining, delaying ${delayMs}ms`)
await new Promise(r => setTimeout(r, delayMs))
}
return response
}Truto normalizes upstream rate-limit information into standardized IETF headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) regardless of how the underlying provider signals its limits. Some providers use X-RateLimit-Remaining, some use custom headers, and some signal via HTTP 200 with a body flag. Truto translates all of these into a consistent interface so your throttling code works identically across every integration.
Multi-Tenant Rate Limit Budgeting
When syncing across many customer accounts, remember that rate limits are typically per-account with the upstream provider. Customer A's sync should not be slowed down by customer B hitting their rate limit. Structure your concurrency pools per-account:
// Each account gets its own rate-limit state
const accountThrottles = new Map<string, { remaining: number; resetAt: number }>()
function shouldThrottle(accountId: string): boolean {
const state = accountThrottles.get(accountId)
if (!state) return false
return state.remaining <= 1 && Date.now() < state.resetAt
}Performance Benchmarks and Sizing Guidance
API-based bulk extraction throughput is bounded by three factors: the upstream provider's rate limit, page size, and your network latency to the provider. Here's what to expect in practice:
| Factor | Typical Range | Impact |
|---|---|---|
| Page size | 100-250 records/request | Larger pages = fewer round-trips |
| Provider rate limit | 100-600 requests/minute (varies widely) | Hard ceiling on throughput |
| Network round-trip | 100-500ms per request | Adds up over thousands of pages |
| Effective throughput | 5,000-60,000 records/minute | Depends on provider + page size |
Use these rough benchmarks to estimate sync times for your workload:
| Dataset Size | Estimated Sync Time (first full backfill) | Incremental Sync (1% daily change) |
|---|---|---|
| 10,000 records | 1-3 minutes | < 30 seconds |
| 100,000 records | 10-30 minutes | 1-3 minutes |
| 1,000,000 records | 2-8 hours | 10-30 minutes |
| 5,000,000+ records | 8-24+ hours | 1-3 hours |
These estimates assume a typical REST API with cursor-based pagination at 200 records/page and a rate limit of ~200 requests/minute. Providers with bulk/batch APIs (like Salesforce Bulk API) can be significantly faster for initial backfills. Providers with aggressive rate limits (some accounting platforms cap at 60 requests/minute per app) will be significantly slower.
Recommended defaults for production pipelines:
- Page size: 200 (maximum most providers allow)
- Account concurrency: 10-15 accounts in parallel
- Resource concurrency per account: 3-5 resources in parallel
- Checkpoint frequency: Every batch (every 200 records)
- Incremental sync interval: Every 15-60 minutes for active data, daily for archival
- Full reconciliation: Weekly, during off-peak hours
Troubleshooting Checklist
When your bulk extraction pipeline stalls or produces unexpected results, work through this checklist:
| Symptom | Likely Cause | Fix |
|---|---|---|
| Sync stalls after a fixed number of pages | Rate limit hit without backoff | Check for 429 responses; implement the adaptive throttling pattern above |
| Duplicate records in destination | Using offset pagination on a changing dataset | Switch to cursor-based pagination; use idempotent upserts |
| Missing recently updated records | Watermark timestamp precision issue | Subtract a small overlap window (e.g., 5 minutes) from the checkpoint watermark |
401 Unauthorized mid-sync |
Access token expired during long extraction | Ensure tokens are refreshed before each page request, not just at sync start |
| Sync completes but record counts don't match | Provider API excludes soft-deleted or archived records | Run a periodic full reconciliation; consume delete webhooks |
| Increasing sync times on same dataset | Checkpoint not advancing; re-fetching same records | Verify the updated_after filter is being passed correctly |
400 Bad Request on specific accounts |
Provider-specific schema differences (custom fields, required fields) | Check the remote_data field in the unified response for the raw provider error |
| Memory exhaustion on large syncs | Accumulating all records in memory before writing | Process and upsert each batch immediately; don't buffer the full dataset |
For rate-limit-specific troubleshooting, see our detailed guide on best practices for handling API rate limits and retries.
Building a White-Labeled Integration Marketplace for Your SaaS
Bulk extraction is one use case. Once you commit to a unified API architecture, a much larger product surface opens up: a white-labeled integration marketplace that lives inside your own product. Instead of shipping a separate "connect to Salesforce" flow, a "connect to HubSpot" flow, and a "connect to Pipedrive" flow, you ship one marketplace UI that lists every provider your customers care about, handles OAuth on your behalf, and lets customers self-serve without a support ticket.
This is the pattern behind most modern B2B products with 20+ native integrations. The economics work because the underlying platform is configuration-driven: each provider is a JSON blob and a set of field mappings, not a bespoke code path. That data-first architecture is what the search query "white label integration marketplace SaaS architecture" actually resolves to in practice.
White-Label Marketplace Architecture at a Glance
flowchart LR
subgraph YourApp["Your SaaS App"]
UI["Marketplace UI<br>your branding"]
BE["Your Backend"]
end
subgraph Platform["Unified API Platform"]
REG["Provider Registry<br>JSON configs"]
OV["Override Store<br>env + account"]
RUN["Generic Runtime"]
end
subgraph Providers["Third-Party APIs"]
P1["Salesforce"]
P2["HubSpot"]
P3["...50 more"]
end
UI -->|Link SDK| REG
UI --> BE
BE --> OV
RUN --> P1
RUN --> P2
RUN --> P3
REG --> RUN
OV --> RUNThe critical property: the runtime does not know or care which provider it's calling. Provider behavior lives in three layers of configuration that merge at request time.
Config Schema: What a Provider Definition Actually Looks Like
Every provider is described by a single JSON document with a fixed schema. The schema captures everything the runtime needs: base URL, authentication, pagination, endpoints, rate limits, error handling. Every provider uses the same schema. Only the values differ.
Here's the top-level shape:
type IntegrationConfig = {
base_url: string
label: string
logo: string | null
icon: string | null
headers?: Record<string, string>
query?: Record<string, string>
query_array_format?: 'comma' | 'brackets' | 'indices' | 'repeat'
credentials: CredentialsConfig // How the user authenticates
authorization: AuthorizationConfig // How credentials become HTTP headers
pagination: PaginationConfig // Default pagination strategy
rate_limit?: RateLimitConfig // How to detect and read 429s
resources: {
[resourceName: string]: {
[method in 'list' | 'get' | 'create' | 'update' | 'delete']?: ResourceMethod
}
}
webhook?: WebhookConfig // Signature verification
error_expression?: string // JSONata for custom error handling
}And here's a concrete provider definition for HubSpot CRM contacts, trimmed to the essentials:
{
"base_url": "https://api.hubapi.com",
"label": "HubSpot",
"credentials": {
"format": "oauth2",
"config": {
"authorize_url": "https://app.hubspot.com/oauth/authorize",
"token_url": "https://api.hubapi.com/oauth/v1/token",
"scopes": ["crm.objects.contacts.read", "crm.objects.contacts.write"]
}
},
"authorization": {
"format": "bearer",
"config": { "path": "oauth.token.access_token" }
},
"pagination": {
"format": "cursor",
"config": {
"cursor_field": "paging.next.after",
"cursor_query_param": "after",
"limit_query_param": "limit"
}
},
"rate_limit": {
"remaining_header": "X-HubSpot-RateLimit-Remaining",
"reset_header": "X-HubSpot-RateLimit-Interval-Milliseconds"
},
"resources": {
"contacts": {
"list": { "method": "get", "path": "/crm/v3/objects/contacts", "response_path": "results" },
"get": { "method": "get", "path": "/crm/v3/objects/contacts/{{id}}" },
"create": { "method": "post", "path": "/crm/v3/objects/contacts" },
"update": { "method": "patch","path": "/crm/v3/objects/contacts/{{id}}" },
"delete": { "method": "delete","path": "/crm/v3/objects/contacts/{{id}}" }
}
}
}A Salesforce config uses the same schema. Only the values change:
{
"base_url": "https://{{context.instance_url}}",
"label": "Salesforce",
"credentials": {
"format": "oauth2",
"config": {
"authorize_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
"scopes": ["api", "refresh_token"]
}
},
"authorization": {
"format": "bearer",
"config": { "path": "oauth.token.access_token" }
},
"pagination": {
"format": "dynamic",
"config": { "next_url_field": "nextRecordsUrl" }
},
"resources": {
"contacts": {
"list": { "method": "get", "path": "/services/data/v58.0/query", "query": { "q": "SELECT Id, FirstName, LastName, Email FROM Contact" }, "response_path": "records" },
"get": { "method": "get", "path": "/services/data/v58.0/sobjects/Contact/{{id}}" },
"create": { "method": "post", "path": "/services/data/v58.0/sobjects/Contact" }
}
}
}Two providers, two JSON documents, one runtime. Placeholders like {{id}} and {{context.instance_url}} are resolved at request time from the connected account's stored context.
Runtime Merging: Platform Defaults → Environment → Account
The power of a white-label marketplace is not just that providers are declarative. It's that each customer can customize how a provider behaves for their tenant without touching the platform base config.
Configs merge in three layers, each overriding the previous with deep-merge semantics:
- Platform Base - the canonical provider definition. Ships with the platform. Used by every customer by default.
- Environment Override - tweaks scoped to a whole environment. Common uses: a different OAuth app, extra default headers, disabled auth types.
- Account Override - per-connected-account tweaks. Common uses: a specific customer's Salesforce instance has a custom field that needs mapping.
flowchart LR
A["Platform Base<br>global template"] --> M["Deep Merge"]
B["Environment Override<br>per-env customizations"] --> M
C["Account Override<br>per-tenant customizations"] --> M
M --> R["Effective Config<br>used at request time"]The merge is deterministic and happens before every API call. Arrays from overrides replace base arrays entirely (they don't concatenate) so a customer can, for example, override the OAuth scopes list without accidentally inheriting a scope they explicitly removed.
Here's what a per-account override might look like:
{
"override": {
"headers": {
"X-Custom-Tenant-Header": "acme-corp"
},
"resources": {
"contacts": {
"list": {
"query": {
"q": "SELECT Id, FirstName, LastName, Email, Custom_Score__c FROM Contact"
}
}
}
}
}
}And the merge logic itself, sketched out:
import deepmerge from 'deepmerge'
const overwriteArrays = (_dest: any[], src: any[]) => src
function resolveConfig(
base: IntegrationConfig,
environmentOverride: Partial<IntegrationConfig> = {},
accountOverride: Partial<IntegrationConfig> = {}
): IntegrationConfig {
const withEnv = deepmerge(base, environmentOverride, { arrayMerge: overwriteArrays })
const withAccount = deepmerge(withEnv, accountOverride, { arrayMerge: overwriteArrays })
return withAccount as IntegrationConfig
}
// At request time:
const effectiveConfig = resolveConfig(
integration.config,
environmentIntegration.override,
integratedAccount.integration_override
)Response mappings (the JSONata expressions that translate provider fields to your unified schema) follow the same three-tier override pattern. A customer with custom Salesforce fields can extend the response mapping for their account only, without asking your team to ship a code change. This is what makes a marketplace white-labeled in a serious sense: each of your customers can shape the platform for their own end users.
Onboarding a New Provider as a Data Operation
Here's the punchline of the whole architecture: adding a new provider to your marketplace is an INSERT statement.
With a code-per-integration approach, adding "Freshsales" to your marketplace means:
- Write endpoint handler functions.
- Add conditional branches to shared code paths.
- Write integration-specific tests.
- Code review, CI, deployment.
- Repeat next quarter when Freshsales v2 ships.
With a configuration-driven platform, the same task looks like this:
// 1. Author the provider JSON once, offline.
const freshsalesConfig: IntegrationConfig = {
base_url: 'https://{{context.domain}}.myfreshworks.com',
label: 'Freshsales',
credentials: {
format: 'oauth2',
config: {
authorize_url: 'https://{{context.domain}}.myfreshworks.com/crm/sales/auth/authorize',
token_url: 'https://{{context.domain}}.myfreshworks.com/crm/sales/auth/token',
scopes: ['read', 'write']
}
},
authorization: { format: 'bearer', config: { path: 'oauth.token.access_token' } },
pagination: {
format: 'page',
config: { page_query_param: 'page', per_page_query_param: 'per_page' }
},
resources: {
contacts: {
list: { method: 'get', path: '/crm/sales/api/contacts', response_path: 'contacts' },
get: { method: 'get', path: '/crm/sales/api/contacts/{{id}}' },
create: { method: 'post', path: '/crm/sales/api/contacts' }
}
}
}
// 2. Insert into the provider registry.
await db.integrations.insert({
name: 'freshsales',
category: 'crm',
config: freshsalesConfig
})
// 3. Insert the JSONata mapping into the unified schema.
await db.unifiedModelMappings.insert({
integration: 'freshsales',
unified_model: 'crm',
resource: 'contacts',
response_mapping: `{
"id": id,
"first_name": first_name,
"last_name": last_name,
"email_addresses": [{ "email": email, "type": "work" }]
}`
})No deployment. No restart. No integration-specific tests. The next API call to /unified/crm/contacts with an integrated account pointing to Freshsales flows through the same generic pipeline that handles Salesforce and HubSpot. Because the runtime engine is generic, the same code path that lists HubSpot contacts also lists Salesforce, Pipedrive, and every other CRM without knowing which one it's talking to.
For your PM roadmap, this means the cost of shipping a new integration collapses from "engineering sprint" to "config authoring session." Your marketplace can grow from 10 to 100 providers without a proportional headcount increase.
Link SDK: Embedding the Marketplace UI in Your Product
The final piece of a white-label marketplace is the customer-facing UI. Building an OAuth flow in-house means popup handling, redirect URIs, token exchange, error states, and re-authentication - for every provider. A Link SDK collapses this into a few lines.
Truto's Link SDK gives you an embeddable component that renders your integration catalog inside your product, kicks off the OAuth flow for the chosen provider, and returns an integratedAccountToken you use to make unified API calls.
import { showTrutoLink } from '@truto/truto-link-sdk'
showTrutoLink({
// Short-lived token issued by your backend for the logged-in end user
linkToken: LINK_TOKEN,
// Filter to categories or specific providers for a curated catalog
filters: {
category: ['crm', 'accounting'],
// integration: ['salesforce', 'hubspot', 'quickbooks']
},
// Your branding
theme: 'light',
primaryColor: '#4F46E5',
onSuccess: (integratedAccount) => {
console.log('Connected:', integratedAccount.integration, integratedAccount.id)
// Persist the account id against your tenant record
fetch('/api/integrations', {
method: 'POST',
body: JSON.stringify({ accountId: integratedAccount.id })
})
},
onExit: (error) => {
if (error) console.error('Link exited with error:', error)
else console.log('User closed the modal without connecting')
},
onEvent: (event) => {
// Instrument the funnel: provider_selected, oauth_started, oauth_completed
analytics.track('link_' + event.name, event.metadata)
}
})The backend counterpart is a single call to mint the short-lived linkToken:
// POST /api/link-token on your backend
const linkToken = await truto.linkTokens.create({
endUserId: currentUser.tenantId,
endUserOrganizationName: currentUser.orgName,
// Optional: pre-scope which environment override applies
environmentId: currentUser.trutoEnvironmentId
})
res.json({ linkToken: linkToken.token })The important UI callbacks to instrument:
| Callback | Fires when | Use it for |
|---|---|---|
onSuccess |
User completes OAuth for a provider | Persist the account id, redirect to a success screen |
onExit |
User closes the modal (with or without connecting) | Reset UI state, log abandonment |
onEvent |
Any lifecycle event inside the modal | Funnel analytics: provider selected, OAuth started, OAuth completed |
For file-picker use cases (Google Drive, SharePoint, OneDrive, Box, Dropbox), the same SDK exposes showFilePicker(integrationName, integratedAccountToken, config) which launches the vendor's own native picker rather than a Truto-built replica. The picker returns the selected items via a promise and persists them against the integrated account so previously chosen items pre-select on the next open.
For more on the connect flow itself, see our post on how to build and document a high-converting Link SDK for SaaS integrations.
Trade-offs of the Marketplace Pattern
Being honest about the limits of a white-labeled integration marketplace for SaaS:
- Unified schema is opinionated. Fields that exist on only 3 of 8 CRMs won't be first-class. Passthrough APIs cover the gap, but that's a per-provider tutorial anyway.
- Custom OAuth apps are per-environment. If you want your customers to see your OAuth consent screen (branded as your product, not the platform's), register OAuth apps per provider under your own developer accounts and inject them via environment overrides.
- Provider webhooks require normalization. The platform handles this via JSONata mapping, but new event types occasionally need a mapping update - a data operation, not a code deployment.
Customizing Unified API Data Models Per Customer with JSONata
The three-tier override system above answers the "how to customize unified API data models per customer without code" question at the infrastructure level: platform → environment → account, deep-merged at request time. But most of the day-to-day customization work happens inside a single string: the JSONata expression that translates a provider's raw payload into your unified schema. This section is a hands-on tour of that layer, with runnable examples an implementer can lift directly into a mapping editor.
Introduction to JSONata for Mappings
JSONata is a declarative query and transformation language for JSON. Think of it as SQL for nested documents: you describe the shape of the output you want, and the engine figures out how to walk the input to produce it. A few properties make it a good fit for API mapping:
- Declarative and side-effect free. An expression is a pure function from input JSON to output JSON. No mutation, no I/O.
- Storable as data. An expression is a string. It lives in a database column and can be edited, versioned, or overridden per customer without a deployment.
- Turing-complete. Conditionals, string manipulation, recursion, custom functions, date arithmetic, and array transforms are all first-class.
- Introspectable. Given a sample payload, you can evaluate an expression and inspect the output in isolation, which makes unit testing trivial.
In Truto, every field mapping, query translation, and error normalization is a JSONata expression. The runtime evaluates the expression against a context object containing response, query, headers, body, and the account's context (credentials, tenant IDs, subdomains). That's it. No branching on provider name, no if/else per integration.
The mental model to hold: a JSONata expression is your customization surface. Anything about how a provider's data enters your unified schema can be changed by editing a string. When a customer needs the unified contact schema to include a field their Salesforce instance uses but the base mapping doesn't, the answer is an override expression at the account level, not a support ticket routed to engineering.
Example 1: Simple Field Mapping (Payload → JSONata → Unified Output)
Start with HubSpot's contacts API. Here's an abbreviated raw response for a single contact:
{
"id": "12345",
"properties": {
"firstname": "Ada",
"lastname": "Lovelace",
"email": "ada@example.com",
"phone": "+1-555-0100",
"jobtitle": "Chief Analyst",
"createdate": "2025-01-10T09:00:00Z",
"lastmodifieddate": "2026-06-01T14:30:00Z"
},
"createdAt": "2025-01-10T09:00:00Z",
"updatedAt": "2026-06-01T14:30:00Z"
}Your unified contact schema expects flat fields, a normalized email array, and phone-number objects. The JSONata expression that produces that shape:
{
"id": response.id,
"first_name": response.properties.firstname,
"last_name": response.properties.lastname,
"title": response.properties.jobtitle,
"email_addresses": response.properties.email
? [{ "email": response.properties.email, "type": "work", "is_primary": true }],
"phone_numbers": response.properties.phone
? [{ "number": response.properties.phone, "type": "phone" }],
"created_at": response.createdAt,
"updated_at": response.updatedAt
}Fed the payload above, the expression evaluates to:
{
"id": "12345",
"first_name": "Ada",
"last_name": "Lovelace",
"title": "Chief Analyst",
"email_addresses": [{ "email": "ada@example.com", "type": "work", "is_primary": true }],
"phone_numbers": [{ "number": "+1-555-0100", "type": "phone" }],
"created_at": "2025-01-10T09:00:00Z",
"updated_at": "2026-06-01T14:30:00Z"
}Three things worth noticing:
- Conditional array construction. The ternary
response.properties.email ? [{...}]returnsundefinedwhen the field is missing, which JSONata drops from the output entirely. No empty arrays ornullvalues leak into your unified schema. - No provider name anywhere. The expression is HubSpot-specific in intent, but nothing about it encodes "HubSpot" as a keyword. Swap the expression for Salesforce (PascalCase fields, no
propertieswrapper) and the pipeline is identical. remote_datais automatic. The runtime attaches the original payload asremote_dataon the output, so consumers can access any field the unified schema doesn't cover.
Now the per-customer customization case. Suppose one of your customers uses a custom HubSpot property called linkedin_url and wants it surfaced as a top-level field. They edit their account-level override in your dashboard:
{
"linkedin_url": response.properties.linkedin_url
}At request time, the runtime evaluates both the base expression and the override expression against the same payload and deep-merges the results. That customer's unified response gains a linkedin_url field. No other customer sees it. No code shipped. This is the concrete answer to "how to customize unified API data models" for a single tenant.
Example 2: Mapping a Custom Object with Nested Line Items
The simple case is easy. The harder case, and the one enterprise customers actually care about, is nested data: a Salesforce custom object with related child records, or an ERP invoice with line items.
Consider a Salesforce custom "Project" object (Project__c) with related "Task" records surfaced through a SOQL relationship query:
{
"Id": "a0X5f00000ABCDEFG",
"Name": "Q3 Migration",
"Status__c": "In Progress",
"Owner__c": "005XX000001Sv6YAAS",
"Start_Date__c": "2026-07-01",
"End_Date__c": "2026-09-30",
"Budget__c": 125000.00,
"Tasks__r": {
"totalSize": 2,
"done": true,
"records": [
{ "Id": "a1Y001", "Subject": "Kickoff", "Status": "Completed", "Hours__c": 4, "AssignedTo__c": "user_abc" },
{ "Id": "a1Y002", "Subject": "Schema Review", "Status": "In Progress", "Hours__c": 12, "AssignedTo__c": "user_def" }
]
},
"attributes": { "type": "Project__c", "url": "/services/data/v58.0/sobjects/Project__c/a0X5f00000ABCDEFG" }
}You want a unified project resource with a normalized tasks array, aggregated fields, and a computed progress_percent. The JSONata:
(
$tasks := response.Tasks__r.records ? response.Tasks__r.records : [];
$completed := $count($tasks[Status = "Completed"]);
$total := $count($tasks);
{
"id": response.Id,
"name": response.Name,
"status": response.Status__c,
"owner_id": response.Owner__c,
"start_date": response.Start_Date__c,
"end_date": response.End_Date__c,
"budget": {
"amount": response.Budget__c,
"currency": "USD"
},
"tasks": $tasks.{
"id": Id,
"title": Subject,
"status": Status,
"hours_logged": Hours__c,
"assignee_id": AssignedTo__c,
"is_completed": Status = "Completed"
},
"task_summary": {
"total": $total,
"completed": $completed,
"progress_percent": $total > 0 ? $round(($completed / $total) * 100, 1) : 0
}
}
)A few patterns worth calling out:
- Local variables (
$tasks,$completed,$total). JSONata lets you bind intermediate values with:=, which keeps expressions readable and avoids recomputing the same predicate multiple times. - Array projection with
.{}.$tasks.{ "id": Id, ... }maps each element to a new object shape. This is the equivalent of aSELECTinside a subquery. - Predicate filtering.
$tasks [Status = "Completed"]filters the array inline.$count(...)gives you the size. - Defensive defaults.
response.Tasks__r.records ? response.Tasks__r.records : []handles the case where the relationship returns no records (Salesforce omits the field entirely rather than returning an empty array).
The resulting unified output:
{
"id": "a0X5f00000ABCDEFG",
"name": "Q3 Migration",
"status": "In Progress",
"owner_id": "005XX000001Sv6YAAS",
"start_date": "2026-07-01",
"end_date": "2026-09-30",
"budget": { "amount": 125000.00, "currency": "USD" },
"tasks": [
{ "id": "a1Y001", "title": "Kickoff", "status": "Completed", "hours_logged": 4, "assignee_id": "user_abc", "is_completed": true },
{ "id": "a1Y002", "title": "Schema Review", "status": "In Progress", "hours_logged": 12, "assignee_id": "user_def", "is_completed": false }
],
"task_summary": { "total": 2, "completed": 1, "progress_percent": 50.0 }
}The same pattern applies to ERP line items. An invoice returned by NetSuite or QuickBooks typically ships with a line_items array where each row has a product reference, quantity, unit price, and tax breakdown. A JSONata mapping projects the line items into your unified invoice_line schema, computes derived fields like subtotal and tax_total, and passes through the original array via remote_data for consumers that need vendor-specific columns.
For customers with tenant-specific fields (a Custom_Priority__c picklist, a Client_Portal_ID__c external id), the account-level override extends the base mapping without duplicating it:
{
"priority": response.Custom_Priority__c,
"external_ids": {
"client_portal": response.Client_Portal_ID__c
}
}The merged result has both the base fields and the customer-specific extensions. Other customers pointing at their own Salesforce instances are unaffected. This is the operational shape of per-customer schema customization: one override string, one account, zero deployments.
Validation and Unit-Test Examples
Because JSONata expressions are pure functions, they are trivial to unit test. A test is just: sample payload in, evaluate the expression, assert on the output. This is exactly the loop the platform's mapping editor exposes, and it's the loop you should replicate in CI for every mapping you own.
Here's a minimal test harness using Node.js and the jsonata package:
import jsonata from 'jsonata'
import { test, expect } from 'vitest'
const expression = `
{
"id": response.id,
"first_name": response.properties.firstname,
"last_name": response.properties.lastname,
"email_addresses": response.properties.email
? [{ "email": response.properties.email, "type": "work", "is_primary": true }]
}
`
async function evaluate(payload: unknown) {
const expr = jsonata(expression)
return expr.evaluate({ response: payload })
}
test('maps a HubSpot contact to the unified schema', async () => {
const payload = {
id: '12345',
properties: { firstname: 'Ada', lastname: 'Lovelace', email: 'ada@example.com' }
}
const result = await evaluate(payload)
expect(result).toEqual({
id: '12345',
first_name: 'Ada',
last_name: 'Lovelace',
email_addresses: [{ email: 'ada@example.com', type: 'work', is_primary: true }]
})
})
test('omits email_addresses when email is missing', async () => {
const payload = { id: '12345', properties: { firstname: 'Ada', lastname: 'Lovelace' } }
const result = await evaluate(payload)
expect(result.email_addresses).toBeUndefined()
})
test('handles a null properties object gracefully', async () => {
const payload = { id: '12345', properties: null }
const result = await evaluate(payload)
expect(result.id).toBe('12345')
expect(result.first_name).toBeUndefined()
})A production test suite for a mapping expression should cover, at minimum:
- The happy path. A representative payload produces the expected unified shape.
- Missing optional fields. The expression must not crash when a field is absent. Assert that the corresponding unified field is
undefined, notnullor an empty object. - Empty arrays and null values. Providers are inconsistent about whether a missing collection returns
[],null, or omits the field. Your test suite pins the expected behavior for all three. - Type coercion edge cases. Numbers arriving as strings, booleans arriving as
"true"/"false", dates in unexpected formats. Assert the expression normalizes them (or explicitly documents that it does not). - Override merging. Given a base expression and a customer override, assert that the merged output contains fields from both, and that the override wins on collision.
Here's a test that pins override-merging behavior for a per-customer extension:
test('account override adds a linkedin_url field without breaking base mapping', async () => {
const baseExpr = jsonata(`{ "id": response.id, "first_name": response.properties.firstname }`)
const overrideExpr = jsonata(`{ "linkedin_url": response.properties.linkedin_url }`)
const payload = {
id: '99',
properties: { firstname: 'Grace', linkedin_url: 'https://linkedin.com/in/grace' }
}
const base = await baseExpr.evaluate({ response: payload })
const override = await overrideExpr.evaluate({ response: payload })
const merged = { ...base, ...override }
expect(merged).toEqual({
id: '99',
first_name: 'Grace',
linkedin_url: 'https://linkedin.com/in/grace'
})
})Snapshot a real production payload for each provider you support and commit it to your test suite. When the provider changes their response shape, your CI catches it before customers do.
For validation before the expression ever runs against live traffic, apply JSON Schema to the output of the mapping. The unified schema is the contract; the mapping is the implementation. A schema check on the mapped output catches drift like "the provider returned a string in a field the unified schema expects to be a number" without waiting for a downstream consumer to blow up.
Performance and Edge-Case Considerations
JSONata expressions are fast for reasonable payloads, but there are patterns that get expensive at scale. Some rules of thumb:
- Compile once, evaluate many. Parsing a JSONata expression string into an evaluator has non-trivial cost. Cache the compiled expression per mapping and reuse it across payloads. In a batch of 10,000 records, this alone can cut latency by an order of magnitude.
- Avoid nested wildcards on large arrays.
**(descendant wildcard) walks every node in the document. On a payload with 5,000 line items, that's a lot of work per record. Prefer explicit paths (response.line_items.quantity) when you know the shape. - Bind intermediate results. If you reference the same subexpression more than once (a filtered array, a joined string), assign it to a local variable with
:=. Recomputing predicates inside a projection is a common performance trap. - Push filtering upstream when possible. If you only need active contacts, use a query mapping to send
filter=status:activeto the provider rather than fetching everything and filtering in JSONata. Network cost beats CPU cost for large datasets. - Watch out for regex on hot paths.
$matchand$containswith complex patterns are convenient but slow when applied to every field of every record. Use them for error detection and dispatch, not for bulk field extraction.
Edge cases every mapping should handle explicitly:
| Edge Case | Symptom | Mitigation |
|---|---|---|
Provider returns null for a field the schema expects |
Downstream code sees null and crashes |
Coalesce with a default: $firstNonEmpty(field, "") |
| Nested collection is absent entirely | JSONata evaluates missing paths to undefined, but downstream JSON has no field |
Guard with a ternary and provide an empty array default when the unified schema requires one |
| String field arriving as a number (or vice versa) | Type mismatch downstream | Force with $string(...) or $number(...) in the mapping |
| Dates in local time without a timezone | Downstream computes wrong intervals | Normalize to ISO 8601 UTC with $fromMillis(...) or explicit formatting |
Pagination cursor is null on last page (vs. absent) |
Loops attempt one extra call with a null cursor | Check both $not($exists(next_cursor)) and next_cursor = null |
| Custom fields with keys containing hyphens or special chars | JSONata dot-notation breaks | Use backtick escaping: response.`custom-field-name` |
| Response body is not JSON (XML, plain text, binary) | Expression evaluates against an unexpected type | Check $type(data) before dereferencing; error expressions should short-circuit |
| Payload larger than expected (>10MB) | Memory spikes when the whole document is held in RAM per record | Set page sizes conservatively; stream where the provider supports it |
The good news: because every one of these cases can be handled inside a JSONata expression, the fix is a mapping edit, not a code deployment. When a customer reports "our custom Priority field isn't coming through," the fix is an override string, tested against a captured payload, applied to that account. Ship time: minutes.
That's the deeper answer to "how to customize unified API data models per customer without code": the runtime is generic, the customization is declarative, and the feedback loop is short enough that customer-specific mapping tweaks stop being engineering work.
Where to Take This Next
Publishing an end-to-end developer tutorial with API examples is the highest-leverage activity you can undertake to improve your API's Time to First Call (TTFC). Senior engineers have no patience for marketing fluff or incomplete code snippets. They want runnable, copy-pasteable scripts that solve real business problems, handle rate limits transparently, and abstract away the nightmare of OAuth token management.
If you're a senior PM staring at an integration roadmap and a content backlog, the move is:
- Measure your current TTFC. Run the experiment yourself: time how long it takes a junior engineer to get a green response on each of your top tutorials. If it's over five minutes, that's your first fix.
- Pick one category and consolidate. Replace per-provider CRM tutorials with a single unified tutorial and per-provider "gotchas" appendices.
- Make runnable examples a CI artifact. Every tutorial should have a test that runs the snippet end-to-end against a sandbox on every doc deploy. Tutorials rot silently; tests fail loudly.
- Instrument the funnel. Track sign-up → API key → first successful call by provider. Use those numbers in your next integration prioritization meeting.
The PMs who win the integration race will not be the ones with the most providers on a logo wall. They'll be the ones whose senior engineers can read a single tutorial on Tuesday and ship the integration to production by Friday. Build for that developer.
FAQ
- How do you handle bulk data extraction through a unified API?
- Build a pipeline with five layers: tenant-aware token management, cursor-based paginated extraction, unified schema normalization, high-watermark checkpointing, and idempotent upserts. The unified API normalizes pagination and rate-limit headers across providers so the same extraction code works for every integration.
- What throughput can I expect from API-based bulk extraction?
- Typical REST API extraction yields 5,000 to 60,000 records per minute, depending on the provider's rate limit and page size. A 100,000-record initial backfill takes roughly 10-30 minutes; incremental syncs of 1% daily change complete in 1-3 minutes.
- How do you checkpoint an ETL pipeline to handle failures?
- Use a high-watermark pattern: after each batch, persist the highest updated_at timestamp seen. On failure or restart, resume from that watermark. Combine this with idempotent upserts (INSERT ON CONFLICT DO UPDATE) so partial batches don't cause duplicates.
- What is Time to First Call (TTFC) and why does it matter?
- TTFC measures the time from a developer signing up to executing their first successful API request. It's the leading indicator for developer activation. Developers using publisher-provided collections are 1.7x faster to first call, and PayPal reduced TTFC from hours to one minute using this approach.
- How should I handle rate limits during bulk API extraction?
- Implement adaptive throttling: read the ratelimit-remaining header on every response and proactively slow down as you approach the limit, rather than waiting for a 429. Structure concurrency pools per-account since rate limits are typically per-tenant with the upstream provider.