10 Questions to ask your unified API provider
Ask your unified API provider these 10 key questions, and see how declarative config normalizes pagination and error handling across 50+ APIs.
Integrations will form a core part of your product offering and its customer-facing. It's paramount that it just works 100% of the time. Once you have decided to use a unified API provider and shortlisted some, use the questions below to ensure you don't end up firefighting one day.
Questions to ask your Unified API provider
- What is your plan for the next 1,2 and 3 years?
Look for stability and clarity in thinking.
2. What is your product roadmap?
Look for features that will add value as time goes by. You don't want to use many SaaS products to get integrations done.
3. What's your plan for adding to your integrations catalogue?
Look for steady building activity on integrations. Make sure they will continue to support newer integrations. Better yet, ask them for custom integrations.
4. Who are your current customers?
Look for big well-known names here.
5. How do you plan to scale the tech?
Identify any gaps and make sure the tech stack is built for scale.
6. What happens if you choose to shut down the company?
Look for open-source solutions or solutions which have open-source elements in them. There are other solutions to alleviate this fear as well such as sharing the code base and self-hosted solutions.
7. Is a price increase on the horizon?
Since will be a fundamental part of your product offering, you want to make sure it doesn't become a burden later on. Make sure you have a long-term contract or a commitment to pricing caps.
8. Do you do multi-year contracts?
Any vendor will appreciate it when you ask them this question. The benefit you want to look for here for your team is a good price and/or services geared for you.
9. Will there be any issues when we scale?
This will bring out any gaps in the tech stack that may have been missed earlier.
10. How can we switch to another vendor?
If you are speaking with the right vendor, they will love this question and happily suggest how best to prepare for this event. Solutions you want to look for - help with migration, architecture design, and switching timelines.
How unified APIs handle pagination differences across REST APIs
When you ask Questions 5 and 9 above, pagination is one of the most revealing areas to dig into. Every SaaS API paginates differently - offset/limit, page numbers, cursor tokens, RFC 5988 Link headers, GraphQL relay connections - and a unified API must normalize all of them behind a single interface.
A well-designed unified API exposes just two pagination parameters to the caller:
limit- how many records per pagenext_cursor- an opaque token representing the next page
You never need to know the provider's native pagination scheme. Here's how each common pattern works under the hood, with concrete request/response examples.
Example: Translating limit/next_cursor to offset/limit
Many APIs use classic offset pagination - offset=20&limit=10 means "skip 20 records, return 10." NetSuite's SuiteQL API is a good example: it takes offset and limit query parameters and returns totalResults in the response body, with a max of 1,000 records per page.
Your request to the unified API:
GET /unified/accounting/journal-entries?integrated_account_id=acct_abc&limit=10What the unified API sends to the provider:
GET /query/v1/suiteql?offset=0&limit=10Unified API response to you:
{
"result": [
{ "id": "je_001", "memo": "Monthly depreciation", "amount": 1500.00, "date": "2024-06-01" },
{ "id": "je_010", "memo": "Payroll accrual", "amount": 4200.00, "date": "2024-06-15" }
],
"next_cursor": "eyJvZmZzZXQiOjEwfQ==",
"result_count": 10
}The next_cursor is an opaque token that encodes the next offset. Pass it back to get the next page:
GET /unified/accounting/journal-entries?integrated_account_id=acct_abc&limit=10&next_cursor=eyJvZmZzZXQiOjEwfQ==The unified API decodes the cursor and sends offset=10&limit=10 to the provider. When no more results exist, next_cursor comes back as null.
The offset pagination config specifies which query parameters the provider expects (offset_param, limit_param) and what offset to start at (start_offset). Some providers enforce hard ceilings - NetSuite caps at 1,000 per page, and certain resource types may use lower limits (like 50 per page when each record requires extra enrichment calls). The unified API respects those automatically.
Example: Translating to page-number pagination
Some APIs paginate with page numbers - page=2&per_page=25. The unified API abstracts this identically.
Your request:
GET /unified/hris/employees?integrated_account_id=acct_xyz&limit=25What the unified API sends to the provider:
GET /api/v1/employees?page=1&per_page=25Unified API response:
{
"result": [
{ "id": "emp_001", "first_name": "Jane", "last_name": "Smith", "department": "Engineering" },
{ "id": "emp_025", "first_name": "Carlos", "last_name": "Rivera", "department": "Sales" }
],
"next_cursor": "eyJwYWdlIjoyfQ==",
"result_count": 25
}Behind the scenes, the config specifies page_param, page_size_param, start_page (usually 1), and page_increment. Your code doesn't change - you still just pass next_cursor.
Example: Handling Link header pagination
APIs like GitHub follow RFC 5988 and return pagination URLs in the HTTP Link header:
HTTP/1.1 200 OK
Link: <https://api.github.com/repos/acme/app/issues?page=3&per_page=10>; rel="next",
<https://api.github.com/repos/acme/app/issues?page=12&per_page=10>; rel="last"
Content-Type: application/json
[
{ "id": 301, "title": "Fix login timeout", "state": "open" },
{ "id": 310, "title": "Update dependencies", "state": "closed" }
]The unified API parses the Link header, extracts the next URL, and encodes it into next_cursor:
{
"result": [
{ "id": "301", "title": "Fix login timeout", "status": "open" },
{ "id": "310", "title": "Update dependencies", "status": "closed" }
],
"next_cursor": "eyJ1cmwiOiJodHRwczovL2FwaS5naXRodWIuY29tL3JlcG9zL2FjbWUvYXBwL2lzc3Vlcz9wYWdlPTMmcGVyX3BhZ2U9MTAifQ==",
"result_count": 10
}When you pass that next_cursor back, the unified API follows the extracted URL directly. You never parse Link headers yourself.
Example: GraphQL relay-style pagination
GraphQL APIs commonly use relay-style cursor pagination. A typical provider query looks like this:
query($cursor: String, $first: Int) {
issues(first: $first, after: $cursor) {
nodes {
id
title
state { name }
}
pageInfo {
hasNextPage
endCursor
}
}
}With variables { "first": 10, "cursor": null } on the first request.
The unified API abstracts this behind the same interface:
Your request:
GET /unified/ticketing/tickets?integrated_account_id=acct_linear&limit=10Unified API response:
{
"result": [
{ "id": "LIN-401", "title": "Refactor auth module", "status": "in_progress" },
{ "id": "LIN-410", "title": "Add rate limit docs", "status": "todo" }
],
"next_cursor": "WyIyMDI0LTA2LTIwVDE0OjE1OjAwWiJd",
"result_count": 10
}Under the hood, the unified API extracted pageInfo.endCursor from the GraphQL response and packaged it as next_cursor. On your next request, it injects that value as the after variable in the GraphQL query. A dynamic pagination config uses expressions to locate the cursor in the response, check whether hasNextPage is true, and apply the cursor to the outgoing request.
Note that on the first request (no cursor provided), the cursor variable resolves to null - which is the correct "start from the beginning" value for most GraphQL pagination schemes.
Provider-native cursor tokens
Some REST APIs already use opaque cursors natively - HubSpot returns paging.next.after, Salesforce returns a nextRecordsUrl, Slack uses response_metadata.next_cursor. These are the simplest case: the unified API extracts the provider's cursor from whatever response field it lives in and returns it as next_cursor.
The cursor pagination config specifies cursor_field (where to find the cursor in the response) and cursor_query_param (which parameter to send it back in). Some providers expect the cursor in the request body or a header rather than a query parameter - the config handles that too, using cursor_in_body or cursor_in_header flags.
How all six strategies map to one interface
| Provider pattern | Example providers | Under the hood | What you see |
|---|---|---|---|
| Offset/limit | NetSuite SuiteQL, Zendesk | offset=N&limit=M |
next_cursor + limit |
| Page number | BambooHR, Personio | page=N&per_page=M |
next_cursor + limit |
| Cursor | HubSpot, Slack, Salesforce | after=xyz or cursor=xyz |
next_cursor + limit |
| Link header | GitHub, GitLab | RFC 5988 Link: <url>; rel="next" |
next_cursor + limit |
| Range | APIs using HTTP Range headers | Range: items=0-9 |
next_cursor + limit |
| Dynamic (expression-based) | GraphQL relay, custom APIs | JSONata expressions compute next cursor | next_cursor + limit |
Every pattern collapses to the same two fields. Pagination behavior is defined as data (configuration and expressions), not as per-integration code. Adding a new integration with a different pagination scheme means adding config - not writing new pagination logic.
What "declarative" actually looks like: a real pagination config
Buyers hear "declarative pagination" and reasonably want to know what that means in a file. Here's a real config for a cursor-based provider (HubSpot's CRM API, which returns the next cursor at paging.next.after):
{
"pagination": {
"format": "cursor",
"config": {
"cursor_field": "paging.next.after",
"cursor_query_param": "after",
"limit_param": "limit"
}
}
}That's it. No pagination code. The format picks the strategy, and config tells the runtime engine three things:
- Where to find the next cursor in the provider's response (
paging.next.after) - Which query parameter to send it back in (
after) - Which parameter controls page size (
limit)
A page-number provider like BambooHR gets the same treatment with different fields:
{
"pagination": {
"format": "page",
"config": {
"page_param": "page",
"page_size_param": "per_page",
"start_page": 1,
"page_increment": 1
}
}
}An offset-based provider like NetSuite:
{
"pagination": {
"format": "offset",
"config": {
"offset_param": "offset",
"limit_param": "limit",
"start_offset": 0
}
}
}And for GraphQL relay pagination (Linear, Shopify), a dynamic strategy uses expressions to pick up pageInfo.endCursor, check pageInfo.hasNextPage, and inject the cursor as the after variable of the next query. Same engine, different config.
Adding a fiftieth integration means writing a JSON blob like this. It does not mean writing a new pagination handler, deploying it, and hoping the existing forty-nine still work.
Translation flow: unified request → provider request → normalized cursor
Here's the full lifecycle of a paginated request as it moves through the unified layer, using the HubSpot cursor config above:
sequenceDiagram
participant Client
participant UnifiedAPI as "Unified API"
participant Engine as "Generic Engine"
participant Provider as "Provider API (HubSpot)"
Client->>UnifiedAPI: GET /unified/crm/contacts?limit=25&next_cursor=abc123
UnifiedAPI->>Engine: Load integration + pagination config
Note over Engine: format = cursor<br>cursor_query_param = after<br>cursor_field = paging.next.after
Engine->>Engine: Decode next_cursor → after=abc123
Engine->>Provider: GET /crm/v3/objects/contacts?limit=25&after=abc123
Provider-->>Engine: 200 OK<br>{ results: [...], paging: { next: { after: def456 } } }
Engine->>Engine: Extract cursor from paging.next.after
Engine->>Engine: Apply response mapping (JSONata)
Engine-->>UnifiedAPI: { result: [...], next_cursor: def456 }
UnifiedAPI-->>Client: { result: [...], next_cursor: def456, result_count: 25 }Or as a step list, provider-agnostic:
- Client sends
limitand (optionally)next_cursorto the unified endpoint. - Engine loads the integration's pagination config from the database.
- Engine translates unified params into provider-native params based on
formatandconfig:cursor→ setscursor_query_paramto the decoded cursor valuepage→ computes the page number from the cursor and setspage_param/page_size_paramoffset→ computes the offset from the cursor and setsoffset_param/limit_paramlink_header→ uses the full URL encoded in the cursordynamic→ evaluates a JSONata expression that produces the request params
- Engine calls the provider's native endpoint with those params.
- Engine extracts the next cursor from the response using
cursor_field(or a Link header, or a JSONata expression for dynamic strategies). - Engine returns
next_cursorin the unified response, ornullif the last page.
The same six-step flow runs for every provider. The only per-integration artifact is the config JSON.
Code sample: Generic pagination loop using unified next_cursor
Because the pagination interface is identical regardless of the underlying provider, your pagination code is a single loop that works everywhere.
Python:
import requests
def fetch_all_records(base_url, resource, account_id, api_key, limit=100):
"""
Paginate through any unified API resource.
Works the same whether the provider uses offset, page numbers,
Link headers, cursor tokens, or GraphQL relay pagination.
"""
url = f"{base_url}/unified/{resource}"
headers = {"Authorization": f"Bearer {api_key}"}
params = {
"integrated_account_id": account_id,
"limit": limit,
}
all_records = []
while True:
resp = requests.get(url, headers=headers, params=params)
resp.raise_for_status()
data = resp.json()
all_records.extend(data["result"])
if not data.get("next_cursor"):
break
params["next_cursor"] = data["next_cursor"]
return all_records
# Same function for every provider - no pagination-specific logic needed
contacts = fetch_all_records(
"https://api.truto.one",
"crm/contacts",
"acct_abc123",
"your_api_key"
)Node.js (ES modules):
async function fetchAllRecords(baseUrl, resource, accountId, apiKey, limit = 100) {
const headers = { Authorization: `Bearer ${apiKey}` };
const records = [];
let nextCursor = null;
do {
const params = new URLSearchParams({
integrated_account_id: accountId,
limit: String(limit),
});
if (nextCursor) params.set('next_cursor', nextCursor);
const res = await fetch(
`${baseUrl}/unified/${resource}?${params}`,
{ headers }
);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const data = await res.json();
records.push(...data.result);
nextCursor = data.next_cursor ?? null;
} while (nextCursor);
return records;
}
// Works for HubSpot (cursor), NetSuite (offset), GitHub (Link header), Linear (GraphQL relay)
const contacts = await fetchAllRecords(
'https://api.truto.one',
'crm/contacts',
'acct_abc123',
'your_api_key'
);This single function handles every provider behind the unified API. You never write provider-specific pagination logic.
Pagination edge cases worth planning for
Real production pagination is never as clean as "loop until next_cursor is null." The failure modes below are where naive implementations break and where a good unified API earns its keep.
-
Deleted records mid-sync. If a provider deletes a record between two page fetches, offset-based APIs will silently skip a record - everything shifts up by one - and you miss data. Cursor-based APIs are more resilient because the cursor points to a stable position, not an index. When evaluating a unified API, ask how it handles cursor invalidation, whether it retries with a fresh cursor, and whether it exposes a sync watermark you can use to catch missed updates on the next run.
-
Duplicate records across pages. The mirror problem: if a record is created during a sync, offset-based APIs return it twice, once at its original position and once shifted by the new record. Downstream you need idempotent writes keyed by the record ID. Some unified APIs de-duplicate on the way out, but treating dedupe as your own responsibility is safer, especially across long-running syncs.
-
Cursor expiry. Cursors are opaque, but not necessarily forever. Some providers expire cursors after minutes (Slack), others after hours. If a sync job pauses and resumes past the TTL, the cursor is invalid and the page range has to be re-fetched. Check whether the platform detects expired-cursor errors and restarts pagination gracefully instead of erroring out mid-loop.
-
GraphQL quirks. Relay-style pagination looks uniform on paper, but providers still find ways to differ. Some return
hasNextPage: trueeven when the next page is empty. Some place the cursor inside each node rather than at the connection level. Some omit the cursor entirely on the last page rather than returningnullexplicitly. A dynamic pagination config with expression support handles all three by letting you locate the cursor and the "is there more?" signal wherever they live, without patching per-integration code. -
Nested pagination. When you list a resource with paginated children (contacts with paginated activity histories, deals with paginated line items), each child list needs its own pagination loop. Ask whether the platform orchestrates nested pagination internally through related-resource fetching, or whether you have to loop from your code.
-
Rate limits hit mid-pagination. A sync job that fetches 50 pages will almost certainly hit a rate limit somewhere. The unified API should back off using the provider's
Retry-After(or equivalent) and resume from the same cursor, not restart from page one.
What to look for when evaluating providers
When you ask a unified API vendor Question 5 ("How do you plan to scale the tech?") and Question 9 ("Will there be any issues when we scale?"), pagination is a concrete area to probe:
- How many pagination strategies does the platform support? If it only handles cursor-based pagination, it'll struggle with older APIs that use offsets or page numbers.
- Is pagination behavior data-driven or hard-coded per integration? A data-driven approach - where pagination is defined in configuration, not per-integration code - means adding new integrations doesn't require new pagination logic.
- Does the platform respect provider-imposed limits? Some APIs cap page sizes or throttle differently for heavy list operations. Ask whether the unified layer handles those constraints transparently.
- Is pagination stateless? For APIs that already use cursors, the unified API should pass through the native token rather than inventing server-side state. Stateless pagination scales better and avoids stale-cursor bugs.
How unified APIs normalize error handling across 50+ providers
Pagination is only half the story. If you're integrating dozens of APIs, error handling is where most of the pain actually shows up, because every provider signals errors differently and few of them agree with the HTTP spec.
A concrete sample of the mess:
- Slack returns
200 OKfor every response, with{ "ok": false, "error": "token_expired" }in the body when something went wrong. - Freshdesk returns
429 Too Many Requestswhen your account plan doesn't include API access - not when you're actually rate-limited. - Highlevel returns
401 Unauthorizedfor scope permission failures, which is semantically a403. - Salesforce signals rate limits with
errorCode: "REQUEST_LIMIT_EXCEEDED"and a non-429 status. - Xero sometimes returns permission errors as plain-text HTML instead of JSON.
- GraphQL providers (Linear, Fireflies) return
200 OKwith anerrorsarray containing provider-specific codes. - Zscaler ZIA returns
401during service maintenance, which is not an auth failure at all.
If you're building integrations yourself, every one of these needs a bespoke error handler and a bespoke reauth trigger. If you're using a unified API, it should collapse all of them into predictable outcomes:
- Real authentication failures (any provider, any weird original status) surface as
401with a clear message and trigger a reauth event on the connected account. - Rate limits (any provider, any weird original signal) surface as
429with a standardRetry-Afterheader andratelimit-limit/ratelimit-remaining/ratelimit-resetheaders. - Permission errors surface as
403. - Not-found errors surface as
404. - The original provider response is always preserved (as
remote_dataorraw_response) so you can inspect what actually happened.
Declarative error normalization: what it looks like
The mechanism is the same as pagination: a small expression per integration describing how that provider signals errors. Here's what a Slack error expression looks like:
{
"error_expression": "$not(data.ok) ? { \"status\": $mapValues(data.error, { \"token_expired\": 401, \"token_revoked\": 401, \"invalid_auth\": 401, \"missing_scope\": 403, \"ratelimited\": 429, \"channel_not_found\": 404, \"internal_error\": 500 }), \"message\": data.error } : null"
}The runtime evaluates this against the response body, headers, and status. When data.ok is false, it maps Slack's error code to the correct HTTP status and throws a normalized error. When data.ok is true, the expression returns null and the response passes through untouched. Same code path for every integration, whether the provider is Slack, HubSpot, Salesforce, or a GraphQL API.
For rate limits, a separate config describes how each provider communicates limits:
{
"rate_limit": {
"is_rate_limited": "status = 429 or (data.ok = false and data.error = 'ratelimited')",
"retry_after_header_expression": "headers.`retry-after` ? $number(headers.`retry-after`) : 60",
"rate_limit_header_expression": "{ \"limit\": headers.`x-ratelimit-limit`, \"remaining\": headers.`x-ratelimit-remaining`, \"reset\": headers.`x-ratelimit-reset` }"
}
}Your code always sees 429 plus Retry-After, no matter whether the provider used a Retry-After seconds header, an X-RateLimit-Reset epoch timestamp, or a ratelimited string in the body.
Standardization table
| Aspect | Integration-specific reality | Standardized for your client |
|---|---|---|
| "Is it rate limited?" | 429, or 200 with body flag, or custom | Always 429 |
| When to retry | Retry-After, X-RateLimit-Reset, custom |
Standard Retry-After (seconds) |
| Rate limit info | X-RateLimit-*, custom names |
ratelimit-limit, ratelimit-remaining, ratelimit-reset |
| Auth failure | 401, 403, 200 with body flag, plain text | Always 401 + reauth event |
| Permission denied | 401, 403, 200 with scope error | Always 403 |
| Provider raw error | Different shape per API | Preserved as remote_data / raw_response |
What to ask a vendor about error handling
- How do you normalize errors across providers? Look for a declarative approach that doesn't require per-integration code changes.
- How do you detect authentication failures? A
401from one provider shouldn't require you to write logic to figure out if it's actually an auth failure, a permission problem, or a maintenance error. - How do rate limits surface? Standardized
429plusRetry-Afteris the minimum bar. Bonus points forratelimit-*headers that let you back off proactively. - What happens on token expiry? The vendor should detect it, mark the connected account as needing reauth, and fire a webhook you can react to - without you writing that logic per integration.
- What happens to the original error? You need access to the raw provider response for debugging, but shouldn't have to parse it during happy-path handling.
A unified API that gets pagination and error handling right takes the two most tedious parts of integration work off your plate. You write one pagination loop and one error handler, and they work across every provider the platform supports.
If you are looking for a comprehensive guide with parameters to get you started on choosing a unified API provider, read this post - How to choose a unified API provider
FAQ
- What should I ask a unified API provider about their roadmap?
- Ask about their plans for the next three years and their strategy for adding new integrations to ensure the platform evolves with your needs and supports custom requests.
- What happens if a unified API provider company shuts down?
- Look for providers with open-source elements, self-hosted options, or code-sharing agreements to ensure you can maintain your integrations even if the vendor ceases operations.
- How can I prepare for switching to a different unified API vendor?
- Ask prospective vendors how they support migrations, architecture design, and switching timelines to ensure you are not locked into a single service and can transition if needed.