How to Build a Coupa API Integration: Developer Tutorial & Code Examples
A technical guide for engineers building a Coupa API integration, plus a full bulk extraction ETL pipeline with checkpointing, Postgres, and S3 sinks.
You are sitting in a pipeline review meeting, staring at a stalled six-figure enterprise deal. The prospect loves your B2B SaaS product, the technical evaluation went perfectly, and their security team approved your architecture. Then procurement steps in with a hard requirement: your platform must read and write data directly to their Coupa instance before they will sign the contract.
If your engineering team has never built a procurement API integration, you are about to discover why enterprise spend management systems are notoriously difficult to connect with. Coupa is not a modern, lightweight REST API you can wire up in an afternoon. It is a massive, complex ERP-adjacent platform designed to handle the financial operations of Fortune 500 companies.
If you're a senior PM or lead engineer who needs to ship this integration to unblock a deal, here is what you actually need to know up front: Coupa's Core REST API uses OAuth 2.0 with the Client Credentials grant, issues access tokens that expire in roughly 24 hours, caps pagination at 50 records per page, publishes no public rate-limit numbers, and still defaults to XML responses unless you explicitly request JSON.
This tutorial walks through the exact architectural blueprints and working code needed for authentication, offset pagination, and 429 backoff handling. We will also examine why treating integrations as declarative, data-only operations is the most sustainable way to scale.
If you are earlier in your evaluation phase, read our detailed technical guide for 2026 for broader architectural decisions.
Why Enterprise Deals Depend on Coupa Integrations
The demand for procure-to-pay integrations is driven by enterprise buyers who refuse to manually reconcile financial data between their spend management system and the SaaS tools their teams actually use. That is the entire business case in one sentence.
The numbers behind this demand are not subtle. The global procurement software market was evaluated at USD 8.96 billion in 2025 and is predicted to hit approximately USD 22.88 billion by 2035, growing at a CAGR of 9.83%. North America alone is projected to reach USD 10.18 billion by 2035. Every enterprise sourcing, AP automation, contract intelligence, or supplier risk vendor is either already integrated with Coupa or losing deals to competitors that are.
The pressure is also coming from inside Coupa's own roadmap. In November 2025, Coupa launched its Navi AI agents across its source-to-pay solution suite, allowing autonomous sourcing and collaboration with suppliers using predictive analytics and natural language processing. AI-driven procurement agents need clean, programmatic access to upstream SaaS data. Whether you are building traditional syncs or evaluating an MCP server for Coupa to connect AI agents, this pushes the integration burden squarely back onto your engineering team. For a deeper dive into these AI-specific challenges, see our Coupa MCP integration guide.
Faced with this demand, many product managers default to evaluating legacy iPaaS (Integration Platform as a Service) tools. However, these platforms often fail when confronted with the complexity of enterprise procurement APIs. For example, Tray.io provides a standard Coupa connector, but their documentation explicitly states developers must use the "Universal Operation" (Raw HTTP Request) for endpoints not covered by standard operations. If your engineering team has to write raw HTTP requests and manually parse payloads anyway, you are taking on the maintenance burden of an in-house build while still paying heavy iPaaS licensing fees.
Building a custom connector badly is worse than not building it at all, because brittle integrations break in customer environments at the worst possible moment—mid-quarter, during a financial close, or against a custom field nobody documented.
Understanding the Coupa Core REST API Architecture
Before writing a single line of code, you need to understand how to integrate with the Coupa API at an architectural level. The Coupa Core REST API is a tenant-scoped HTTP API hosted at https://{your_instance}.coupahost.com, secured by OAuth 2.0/OIDC, with resources organized into reference, transactional, and shared data. Each enterprise customer instance is a separate deployment with its own auth, scopes, and structural quirks.
A few architectural details set the tone for everything that follows:
- OAuth 2.0 Only: API keys are no longer supported. Coupa uses OpenID Connect (OIDC), which extends OAuth 2.0 for an improved level of security. API keys are deprecated and any existing keys must be transitioned to OAuth clients.
- Scope-Based Permissions: Coupa scopes take the form of
service.object.right. For example,core.accounting.readorcore.invoice.write. You configure these at the client level, and every missing scope translates to an HTTP 403 in production. - XML by Default, JSON on Request: You must send
Accept: application/jsonon every single API call. If you forget, Coupa will return heavily nested XML payloads. - No Published Rate Limits: Coupa does not document strict quotas in its public reference. You discover them at runtime via opaque HTTP 429 errors, usually during a historical backfill job.
When you query a simple resource like Invoices, Coupa does not just return the core fields. It expands related entities aggressively and returns the entire object graph, including custom fields defined by that specific enterprise customer.
graph TD
A[B2B SaaS Backend] -->|Request| B(Coupa API Gateway)
B -->|XML/JSON Payload| C{Custom Field Bloat}
C -->|Tenant A| D[Standard + 10 Custom Fields]
C -->|Tenant B| E[Standard + 50 Custom Fields]
C -->|Tenant C| F[Standard + 200 Custom Fields]Because every Coupa instance is highly customized, your integration must be capable of dynamically mapping fields. If you rely on rigid, pre-compiled SDKs, you will spend your entire sprint updating types to accommodate a single enterprise customer's custom procurement workflow. For more details on handling this drift, see our 2026 engineering guide for B2B SaaS.
Tutorial Step 1: Handling OAuth 2.0 Client Credentials
The first hurdle in this Coupa API integration tutorial is authentication. Because there is no end user in the loop for system-to-system integrations, you must use the OAuth 2.0 Client Credentials flow. Your application authenticates as a machine user.
You request an access token using a Client ID and Client Secret provided by the Coupa administrator. A minimal working request looks like this:
curl -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$COUPA_CLIENT_ID" \
-d "client_secret=$COUPA_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
-d "scope=core.accounting.read core.invoice.read" \
https://acme.coupahost.com/oauth2/tokenA successful response from /oauth2/token looks like this:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 86399,
"scope": "core.invoice.read core.invoice.write",
"created_at": 1735660800
}The access_token expires in roughly 24 hours (86,399 seconds). That number matters: any production integration needs a refresh strategy that runs ahead of the 24-hour boundary. If you fetch a new token for every API request, Coupa will rate limit your authentication endpoint.
Here is a production-grade Node.js implementation using TypeScript and Redis to fetch, cache, and safely refresh a Coupa access token:
import axios from 'axios';
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
interface CoupaTokenResponse {
access_token: string;
expires_in: number;
token_type: string;
scope: string;
}
export async function getCoupaAccessToken(tenantId: string, clientId: string, clientSecret: string, coupaDomain: string): Promise<string> {
const cacheKey = `coupa_token:${tenantId}`;
// Check if we have a valid token in cache
const cachedToken = await redis.get(cacheKey);
if (cachedToken) {
return cachedToken;
}
// If not, fetch a new token from Coupa
const tokenUrl = `https://${coupaDomain}/oauth2/token`;
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
params.append('client_id', clientId);
params.append('client_secret', clientSecret);
params.append('scope', 'core.invoice.read core.invoice.write');
try {
const response = await axios.post<CoupaTokenResponse>(tokenUrl, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const token = response.data.access_token;
// Cache the token for 23 hours & 55 minutes (86100 seconds)
// This ensures we refresh safely before the 24-hour expiry, absorbing clock skew
await redis.setex(cacheKey, 86100, token);
return token;
} catch (error) {
console.error(`Failed to fetch Coupa access token for tenant ${tenantId}`, error);
throw new Error('Coupa authentication failed');
}
}For Python-based backends, the equivalent implementation uses requests with an in-memory cache (swap for Redis in production). Note the two safety margins baked in: a 5-minute buffer before the 24-hour expiry, and a 5-second sleep after minting a fresh token.
import time
import requests
_TOKEN_CACHE = {} # In-memory; replace with Redis in production
def get_coupa_access_token(tenant_id: str, client_id: str, client_secret: str, coupa_domain: str) -> str:
cached = _TOKEN_CACHE.get(tenant_id)
if cached and cached["expires_at"] > time.time():
return cached["access_token"]
resp = requests.post(
f"https://{coupa_domain}/oauth2/token",
headers={"Content-Type": "application/x-www-form-urlencoded"},
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "core.invoice.read core.invoice.write",
},
timeout=10,
)
resp.raise_for_status()
payload = resp.json()
# Refresh ~5 minutes ahead of the 24-hour boundary
_TOKEN_CACHE[tenant_id] = {
"access_token": payload["access_token"],
"expires_at": time.time() + payload["expires_in"] - 300,
}
# Coupa requires waiting ~5 seconds before using a freshly minted token
time.sleep(5)
return payload["access_token"]Watch the post-token race condition. Coupa explicitly warns that developers must include at least a five-second buffer between generating a new token and submitting an API call using that token. Otherwise, the call may reach the resource server before the token is fully registered across Coupa's infrastructure, resulting in an unauthorized error. This trips up almost every team building their first Coupa integration.
Two more operational details worth burning into your runbook:
- Scope changes invalidate scripts: Changing the scopes in a Coupa Client will impact your token generation script since scopes are explicitly passed in the request. Do not let an admin "clean up" scopes without coordinating with engineering.
- JWT length is unbounded: Tokens are provided in JWT format. By design, there is no limit to the length of a JWT token, and it scales based on the number of requested scopes. If you store tokens in a database column with a tight
VARCHARlimit, your integration will fail unexpectedly.
Tutorial Step 2: Navigating the 50-Record Pagination Ceiling
Once authenticated, you will immediately hit Coupa's data extraction limits. The Coupa API enforces a strict 50-record pagination ceiling. You cannot pass limit=1000 to speed up a historical data sync. If you set it to 500, you still get 50. If an enterprise customer has 50,000 purchase orders, your system must make 1,000 sequential HTTP requests.
Coupa uses offset-based pagination. You must increment the offset parameter by the limit (maximum 50) until the API returns fewer than 50 records. In practice, your loop iterates offset=0, 50, 100, 150, ... and stops when the response array is empty or shorter than 50.
Here is a robust generator function in TypeScript that safely extracts records without dropping data or blowing up your server's memory:
import axios from 'axios';
interface CoupaRequestOptions {
domain: string;
accessToken: string;
resource: string; // e.g., 'invoices', 'users'
updatedAfter?: string;
}
export async function* paginateCoupaResource(options: CoupaRequestOptions) {
const { domain, accessToken, resource, updatedAfter } = options;
const limit = 50;
let offset = 0;
let hasMore = true;
const client = axios.create({
baseURL: `https://${domain}/api`,
headers: {
'Authorization': `Bearer ${accessToken}`,
'Accept': 'application/json'
}
});
while (hasMore) {
try {
const params: Record<string, any> = {
limit,
offset,
// Crucial: Only request the fields you actually need
fields: '["id","invoice-number","total","status","updated-at"]'
};
if (updatedAfter) {
params['updated-at[gt]'] = updatedAfter;
}
const response = await client.get(`/${resource}`, { params });
const records = response.data;
if (!Array.isArray(records) || records.length === 0) {
hasMore = false;
break;
}
// Yield the batch of records to the caller for memory-efficient processing
yield records;
if (records.length < limit) {
hasMore = false;
} else {
offset += limit;
}
} catch (error) {
// Rate limit handling is delegated to an interceptor (see Step 3)
console.error(`Pagination failed at offset ${offset}`, error);
throw error;
}
}
}A few non-obvious traps to plan for during pagination:
- Always pass a
fieldsfilter: Default Coupa responses include hundreds of nested attributes per record. Restricting fields cuts payload sizes by an order of magnitude and is the single biggest performance lever you have. - Use server-side filters: Pulling everything and filtering in memory will exhaust both your runtime and Coupa's patience. Coupa supports operators like
updated-at [gt],id [gt_or_eq], andstatus [in]. Use them. - Don't rely on stable ordering for long syncs: Coupa does not guarantee deterministic ordering across pages. For massive historical syncs, paginate by
id [gt]={lastSeenId}instead of pureoffset, because new records inserted mid-sync will shift offsets and cause duplicates or skips.
Trimming Payloads with fields and return_object
Coupa exposes two complementary knobs for shrinking responses: fields (whitelist specific attributes) and return_object (control how deeply nested objects are expanded). Combining both is the difference between a 200 KB response and a 4 MB response.
fieldstakes a JSON array of attribute names. Only those attributes are returned on the top-level resource.return_object=shallowreturns nested associations as ID references only, instead of fully inlined objects. This is the single biggest payload win when a resource has many linked entities (supplier, department, chart-of-accounts segments, and so on).
A raw curl call showing both parameters, with the mandatory Accept: application/json header:
curl -G "https://acme.coupahost.com/api/invoices" \
-H "Authorization: Bearer $COUPA_TOKEN" \
-H "Accept: application/json" \
--data-urlencode "limit=50" \
--data-urlencode "offset=0" \
--data-urlencode "return_object=shallow" \
--data-urlencode 'fields=["id","invoice-number","total","status","updated-at"]' \
--data-urlencode "updated-at[gt]=2025-01-01T00:00:00Z"The equivalent Python generator, iterating offset=0, 50, 100, ... until the batch comes back short:
import requests
from typing import Iterator, List, Dict, Optional
def paginate_coupa(
domain: str,
token: str,
resource: str,
updated_after: Optional[str] = None,
) -> Iterator[List[Dict]]:
limit = 50
offset = 0
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {token}",
"Accept": "application/json", # Without this, Coupa returns XML
})
while True:
params = {
"limit": limit,
"offset": offset,
"return_object": "shallow",
"fields": '["id","invoice-number","total","status","updated-at"]',
}
if updated_after:
params["updated-at[gt]"] = updated_after
resp = session.get(f"https://{domain}/api/{resource}", params=params, timeout=30)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
yield batch # offsets iterate 0, 50, 100, ...
if len(batch) < limit:
break
offset += limit
# Usage
for page in paginate_coupa("acme.coupahost.com", token, "invoices", "2025-01-01T00:00:00Z"):
for invoice in page:
process(invoice)Tutorial Step 3: Managing Rate Limits and Retries
Coupa publishes zero documentation on their exact rate limits. Your client must treat HTTP 429 Too Many Requests responses as a normal control signal and back off exponentially. There is no other safe strategy.
When building a procurement API integration, you must implement exponential backoff with jitter to prevent the "thundering herd" problem where multiple background workers retry at the exact same time.
Here is how you can implement this as an Axios interceptor to wrap the pagination logic we built above:
import axios, { AxiosError } from 'axios';
const MAX_RETRIES = 5;
function calculateBackoffWithJitter(attempt: number, retryAfterHeader?: string): number {
// Honor the Retry-After header if Coupa provides it
if (retryAfterHeader) {
const seconds = parseInt(retryAfterHeader, 10);
if (!isNaN(seconds) && seconds > 0) return seconds * 1000;
}
// Otherwise, fallback to exponential backoff with jitter
const baseDelay = 1000 * Math.pow(2, attempt);
const jitter = Math.floor(Math.random() * 1000);
return Math.min(60000, baseDelay + jitter); // Cap at 60 seconds
}
export function applyRetryInterceptor(client: axios.AxiosInstance) {
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const config = error.config as any;
if (!config || !config.retryCount) {
config.retryCount = 0;
}
const shouldRetry = error.response && (error.response.status === 429 || error.response.status >= 500);
if (shouldRetry && config.retryCount < MAX_RETRIES) {
config.retryCount += 1;
const retryAfter = error.response?.headers['retry-after'];
const delay = calculateBackoffWithJitter(config.retryCount, retryAfter);
console.warn(`Coupa rate limited (429). Retrying attempt ${config.retryCount} in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
return client(config);
}
return Promise.reject(error);
}
);
}The same logic in Python, wrapped around a requests.Session. The pattern is identical: honor Retry-After when present, otherwise compute 2^attempt + jitter, capped at 60 seconds.
import random
import time
import requests
MAX_RETRIES = 5
RETRYABLE_STATUS = {429, 500, 502, 503, 504}
def call_with_backoff(session: requests.Session, method: str, url: str, **kwargs) -> requests.Response:
for attempt in range(MAX_RETRIES):
resp = session.request(method, url, **kwargs)
if resp.status_code not in RETRYABLE_STATUS:
return resp
# Prefer server guidance when available
retry_after = resp.headers.get("Retry-After")
if retry_after and retry_after.isdigit():
delay = int(retry_after)
else:
# Exponential backoff with full jitter, capped at 60s
delay = min(60, (2 ** attempt) + random.random())
print(f"Coupa returned {resp.status_code}; retry {attempt + 1}/{MAX_RETRIES} in {delay:.1f}s")
time.sleep(delay)
resp.raise_for_status()
return respIt is important to understand how rate limits are handled if you are calling Coupa through an abstraction layer. If you use a unified API platform like Truto, the contract is explicit:
- Truto does not retry, throttle, or absorb 429 errors on your behalf. The 429 is passed straight through to your code.
- Truto does normalize whatever rate-limit information the upstream returns into standardized IETF headers (
ratelimit-limit,ratelimit-remaining,ratelimit-reset). - Your retry and backoff logic remains your responsibility.
This is deliberate. Hiding 429s inside an integration platform makes integrations feel magical until they aren't—background queues balloon, callers retry blindly, and the actual upstream quota stays invisible. Surfacing the error with normalized headers lets you build a single retry policy that works across Coupa, Workday, Salesforce, and anything else.
Read more about cross-vendor retry policies in our guide on handling API rate limits and retries.
Sample Error Responses and Handling
Coupa's error shapes are inconsistent across endpoints. Here are the responses you will see most often and how to react to each. Match on status code first, then inspect the body only when you need a human-readable message.
401 - invalid or expired token
{
"error": "invalid_token",
"error_description": "The access token expired"
}Root cause: cache miss, clock skew, or a token minted with different scopes than the current request needs. Fix: force a token refresh and retry once. If it fails again, surface as a reauth error - do not loop.
403 - missing scope
{
"errors": {
"error": ["You do not have permission to access this resource."]
}
}Root cause: the OAuth client was not granted the scope for that resource, or the Coupa admin removed it. Fix: add the scope in the Coupa admin console and re-mint the token. Existing tokens do not gain new scopes retroactively.
422 - validation error on POST or PUT
{
"errors": {
"supplier": ["can't be blank"],
"line-items": ["is invalid"]
}
}Root cause: bad payload shape or a required field missing for that tenant's configuration. Fix: log the full error object, surface to the caller, and do NOT retry. Validation errors are deterministic.
429 - rate limited
{ "error": "Too Many Requests" }Root cause: undocumented throughput ceiling tripped, often during a backfill. Fix: honor Retry-After if present; otherwise fall back to exponential backoff with jitter as shown above.
500 / 502 / 503 - transient upstream
Coupa occasionally returns HTML instead of JSON for these. If your parser blows up, that is your signal. Fix: retry with backoff. If it persists beyond a few minutes, check the Coupa status page before paging on-call.
A minimal Python handler that classifies each of these into an internal exception type keeps your retry logic clean:
class CoupaError(Exception):
def __init__(self, status, body, retry_after=None):
self.status = status
self.body = body
self.retry_after = retry_after
class ReauthRequired(CoupaError): pass # 401
class ScopeError(CoupaError): pass # 403
class ValidationError(CoupaError): pass # 422 - do not retry
class RetryableError(CoupaError): pass # 429, 5xx
def classify_coupa_error(resp: requests.Response) -> CoupaError:
try:
body = resp.json()
except ValueError:
body = {"raw": resp.text[:500]}
if resp.status_code == 401:
return ReauthRequired(401, body)
if resp.status_code == 403:
return ScopeError(403, body)
if resp.status_code == 422:
return ValidationError(422, body)
if resp.status_code == 429 or resp.status_code >= 500:
return RetryableError(resp.status_code, body, resp.headers.get("Retry-After"))
return CoupaError(resp.status_code, body)Wire this into your retry loop by catching RetryableError (retry with backoff), ReauthRequired (refresh token, retry once), and letting ValidationError and ScopeError propagate straight to the caller.
The Faster Alternative: Using a Unified API for Procurement
Building the authentication caching, offset pagination, and exponential backoff logic shown above is a multi-quarter commitment. Maintaining it as Coupa updates their API endpoints and enterprise customers add custom fields is a permanent tax on your engineering resources.
A unified API collapses the same work into a single normalized resource. Through Truto, fetching invoices from Coupa looks identical to fetching invoices from any other accounting or procurement system:
curl https://api.truto.one/api/v1/unified/accounting/invoice \
-H "Authorization: Bearer $TRUTO_API_KEY" \
-H "x-integrated-account-id: $ACCOUNT_ID"The response comes back as clean JSON in a common data model, with cursor pagination managed internally, and the IETF rate-limit headers attached.
Instead of writing custom boilerplate, Truto manages the execution pipeline generically. This architecture provides distinct advantages:
- Authentication Abstraction: The platform automatically manages token state and schedules refreshes shortly before they expire, completely removing the need for you to maintain a Redis caching layer or worry about the 5-second Coupa race condition.
- Declarative Pagination: The execution pipeline automatically traverses Coupa's 50-record offset limits. You request the data you need, and the platform handles the sequential extraction loops behind the scenes.
- Schema Normalization via JSONata: Instead of forcing your system to ingest bloated payloads, Truto transforms Coupa's responses into clean, normalized JSON using JSONata expressions. This mapping configuration links unified fields to provider-specific fields, handling custom tenant data without requiring code deployments.
| Capability | Direct Coupa build | Legacy iPaaS | Truto Unified API |
|---|---|---|---|
| OAuth + Token Refresh | Maintained in your code | Connector config | Managed by platform |
| 50-Record Pagination | Maintained in your code | Connector or raw HTTP | Normalized cursor |
| XML to JSON | Maintained in your code | Partial | JSONata transform |
| 429 Backoff | Maintained in your code | Connector retries | Caller responsibility, headers normalized |
| Per-Tenant Custom Fields | Maintained in your code | Often unsupported | JSONata overrides |
| Time to First Call | Days to weeks | Hours to days | Minutes |
Honest trade-off: A unified API is not a free lunch. Highly Coupa-specific features (exotic approval chains, instance-specific extensions) may still require dropping down to Truto's Passthrough API, which gives you raw authenticated access to the underlying Coupa endpoint. For 80% of integration work (invoices, POs, suppliers), the unified model wins. For the last 20%, you fall back to passthrough on a per-resource basis.
By treating integrations as declarative data operations rather than custom codebases, your team can ship a production-ready Coupa integration in days, not quarters. Read more about how this architecture works in our deep dive on shipping API connectors as data-only operations.
Bulk Data Extraction and ETL Workflows Through Unified APIs
Everything up to this point has been about a single API call at a time. Real production integrations are ETL pipelines: extract millions of records from Coupa, transform them against your internal schema, load them into a warehouse or object store, and do it again tomorrow without re-pulling yesterday's data.
This section shows the full pattern - tenant-aware auth, paginated extraction with safe parallelism, checkpointing for incremental syncs, mid-run token refresh, and loading into Postgres and S3 - using the unified API as the extraction layer.
The Problem: Why Bulk Extraction Is Hard
A naive Coupa backfill is a while loop that paginates from offset 0 to the end of the table. That works for a demo. It breaks in production for four reasons:
- Token expiry mid-run. A backfill of 500k invoices at 50 records per page takes hours. Your access token expires at hour 24. Without a refresh strategy that runs mid-extraction, the job dies at record 1.2 million and you restart from zero.
- No checkpointing. If the job crashes at offset 847,350 and you have no idea which records already landed downstream, you either re-pull everything (expensive and slow) or accept data duplication (worse).
- Fan-out across tenants. You are not extracting from one Coupa instance. You are extracting from every customer's Coupa instance, on independent tokens, with independent rate-limit budgets. A single global worker pool is the wrong shape.
- Loading is not free. Writing 500k rows to Postgres row-by-row will destroy your primary. Writing 500k tiny files to S3 will destroy your Athena query performance. The loader shape has to match the downstream.
Here is the architecture that actually works:
flowchart LR
A["Scheduler<br>(cron/queue)"] --> B["Extractor Worker<br>(per tenant)"]
B -->|"OAuth token<br>(refreshed ahead of expiry)"| C["Unified API"]
C -->|"Cursor pagination<br>+ 429 backoff"| D["Batch buffer<br>(1000 records)"]
D --> E{"Downstream"}
E -->|"Warm rows"| F["Postgres<br>(upsert)"]
E -->|"Cold rows"| G["S3 Parquet<br>(partitioned)"]
B --> H["Checkpoint store<br>(last_synced_at per tenant/resource)"]
H -.->|"read on start"| B
H -.->|"write on batch commit"| DEvery arrow in that diagram maps to a concrete piece of code below.
Prerequisites and Tenant-Aware Auth Setup
Before any extraction runs, you need one integrated account per Coupa tenant, each with its own credentials. When you use the unified API, the token lifecycle is managed for you - the platform refreshes tokens ahead of expiry so a long-running extraction never trips over the 24-hour boundary. Concurrent requests against the same account are serialized through a per-account mutex so you never double-refresh.
You identify which tenant a call belongs to with the x-integrated-account-id header. A minimal setup checklist:
- One integrated account per customer Coupa instance, connected through your link UI
- Each integrated account tagged with a
tenant_idthat maps to your internal customer ID - Unified Accounting API model installed in your Truto environment
- A checkpoint table in your database keyed by
(tenant_id, resource, last_synced_at) - Webhook subscription to
integrated_account:authentication_errorso you know when a tenant needs reauth
For an interactive smoke test, use the Truto CLI. It calls the same unified API endpoints your workers will hit:
# Log in once
truto profile add --name prod --token $TRUTO_API_TOKEN
# List integrated accounts and grab the one you want to extract from
truto integrated-accounts list -o table
# Pull the first page of invoices to confirm the connection is healthy
truto unified accounting invoice -a $ACCOUNT_ID -q "limit=10"
# Same call with a filter, to confirm incremental filtering works
truto unified accounting invoice -a $ACCOUNT_ID \
-q "updated_at[gt]=2025-01-01T00:00:00Z,limit=50"If those three commands work against a real tenant, the auth + unified mapping layer is confirmed working. You can now build the extractor.
Step-by-Step: A Paginated Extractor with Safe Parallelism
A production extractor has three loops nested inside each other:
- Outer: iterate tenants (bounded concurrency).
- Middle: for each tenant, paginate through the resource using cursor pagination.
- Inner: for each page, transform and buffer records, flushing to the downstream in batches.
Here is the Node.js shape, using p-limit for tenant-level concurrency and buffering records in batches of 1000 before flushing:
import axios from 'axios';
import pLimit from 'p-limit';
const TRUTO_BASE = 'https://api.truto.one';
const BATCH_SIZE = 1000;
const TENANT_CONCURRENCY = 5;
interface Checkpoint {
tenantId: string;
resource: string;
lastSyncedAt: string | null;
}
async function extractResourceForTenant(
tenantId: string,
accountId: string,
resource: string,
checkpoint: Checkpoint,
loader: (records: any[]) => Promise<void>,
saveCheckpoint: (cp: Checkpoint) => Promise<void>
) {
const client = axios.create({
baseURL: TRUTO_BASE,
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_TOKEN}`,
'x-integrated-account-id': accountId,
},
});
let cursor: string | null = null;
let buffer: any[] = [];
let maxUpdatedAt = checkpoint.lastSyncedAt;
do {
const params: Record<string, any> = { limit: 100 };
if (cursor) params.next_cursor = cursor;
if (checkpoint.lastSyncedAt) params[`updated_at[gt]`] = checkpoint.lastSyncedAt;
const resp = await client.get(`/api/v1/unified/accounting/${resource}`, { params });
const page = resp.data.result || [];
cursor = resp.data.next_cursor || null;
for (const record of page) {
buffer.push(record);
if (record.updated_at && (!maxUpdatedAt || record.updated_at > maxUpdatedAt)) {
maxUpdatedAt = record.updated_at;
}
}
// Flush in fixed-size batches so downstream writes are predictable
while (buffer.length >= BATCH_SIZE) {
const chunk = buffer.splice(0, BATCH_SIZE);
await loader(chunk);
// Advance the checkpoint after the batch is durably written
await saveCheckpoint({ tenantId, resource, lastSyncedAt: maxUpdatedAt });
}
} while (cursor);
// Flush any tail
if (buffer.length > 0) {
await loader(buffer);
await saveCheckpoint({ tenantId, resource, lastSyncedAt: maxUpdatedAt });
}
}
async function runBulkExtract(tenants: Array<{ tenantId: string; accountId: string }>) {
const limit = pLimit(TENANT_CONCURRENCY);
await Promise.all(
tenants.map(t =>
limit(async () => {
const checkpoint = await loadCheckpoint(t.tenantId, 'invoice');
await extractResourceForTenant(
t.tenantId,
t.accountId,
'invoice',
checkpoint,
batch => loadToPostgres(t.tenantId, batch),
saveCheckpoint
);
})
)
);
}Three things worth calling out:
- Concurrency is capped at the tenant level, not the request level. Each tenant paginates sequentially (Coupa's rate limits are per-tenant), but you extract up to 5 tenants at a time. This is the safe shape for bulk ETL over a unified API.
- The checkpoint advances only after a batch is durably loaded. If the process crashes mid-batch, the next run replays from the last committed
updated_at- some records are re-pulled, none are lost. Idempotency in the loader handles the overlap (see the Postgres upsert below). updated_at [gt]is a server-side filter. The unified API translates it into whatever Coupa expects (updated-at [gt]in raw Coupa). Incremental syncs never touch the full table.
Checkpointing and Incremental Sync Patterns
Checkpointing has one job: make sure a re-run picks up exactly where the last run left off, without gaps or duplicates. The naive version stores a single last_synced_at per resource. The production version stores it per (tenant_id, resource) because tenants finish syncs at different times.
Here is the minimal Postgres schema for a checkpoint table:
CREATE TABLE sync_checkpoint (
tenant_id TEXT NOT NULL,
resource TEXT NOT NULL,
last_synced_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, resource)
);
CREATE INDEX sync_checkpoint_updated_idx ON sync_checkpoint (updated_at);And the read/write helpers:
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function loadCheckpoint(tenantId: string, resource: string): Promise<Checkpoint> {
const { rows } = await pool.query(
`SELECT last_synced_at FROM sync_checkpoint WHERE tenant_id = $1 AND resource = $2`,
[tenantId, resource]
);
return {
tenantId,
resource,
lastSyncedAt: rows[0]?.last_synced_at?.toISOString() || null,
};
}
export async function saveCheckpoint(cp: Checkpoint): Promise<void> {
if (!cp.lastSyncedAt) return;
await pool.query(
`INSERT INTO sync_checkpoint (tenant_id, resource, last_synced_at)
VALUES ($1, $2, $3)
ON CONFLICT (tenant_id, resource)
DO UPDATE SET last_synced_at = EXCLUDED.last_synced_at, updated_at = NOW()`,
[cp.tenantId, cp.resource, cp.lastSyncedAt]
);
}A few sharp edges to plan for:
- Watermark drift on failure. If you advance the checkpoint before the batch is loaded, a crash between the two writes loses data. Always load first, checkpoint second, and make the loader idempotent (upsert, not insert).
- Overlap on retry. Use
updated_at [gt](strictly greater than), not[gte]. If a record is re-pulled at the boundary, upsert handles the duplicate. If you use[gte], you re-pull the entire last-second's worth every run for no reason. - Clock skew. The unified API returns
updated_atfrom the upstream provider's clock, not yours. Never compare it toNOW()in your database - only to other values from the same source.
Handling Token Refresh and Rate Limits Mid-Run
When you use the unified API for extraction, token refresh happens outside your extractor code. The platform tracks each token's expiry, schedules a refresh shortly before it expires, and serializes concurrent refreshes on the same account through a per-account mutex so a burst of parallel requests never triggers duplicate refresh calls to the upstream provider.
What you still need to handle in your extractor:
- 429 responses from the upstream, passed through unchanged. The unified API surfaces 429s with normalized
ratelimit-limit,ratelimit-remaining, andratelimit-resetheaders regardless of what the upstream returned. Your retry loop reads the standard headers. - 401 responses when a token becomes invalid mid-sync. If a customer revokes access mid-extraction, the next call returns 401 and the platform emits an
integrated_account:authentication_errorwebhook. Your extractor should surface this as a reauth-required error and stop pulling for that tenant, not retry blindly.
A compact retry wrapper that handles both cases:
async function callWithRetry(
fn: () => Promise<any>,
maxAttempts = 5
): Promise<any> {
let attempt = 0;
while (attempt < maxAttempts) {
try {
return await fn();
} catch (err: any) {
const status = err.response?.status;
const headers = err.response?.headers || {};
if (status === 401) {
// Do not retry - tenant needs to reauth
throw new Error(`REAUTH_REQUIRED: account ${headers['x-integrated-account-id']}`);
}
if (status === 429 || (status >= 500 && status < 600)) {
attempt++;
// Prefer normalized IETF header, then Retry-After, then exponential
const reset = parseInt(headers['ratelimit-reset'] || headers['retry-after'] || '0', 10);
const backoff = reset > 0
? reset * 1000
: Math.min(60000, (2 ** attempt) * 1000 + Math.random() * 1000);
await new Promise(r => setTimeout(r, backoff));
continue;
}
throw err;
}
}
throw new Error(`Max retries (${maxAttempts}) exceeded`);
}Wrap every client.get(...) inside extractResourceForTenant with callWithRetry(() => client.get(...)) and the extractor tolerates transient failures without giving up on the whole run.
Loading Into Postgres: Idempotent Upserts
The simplest downstream is a Postgres table with an idempotent upsert. Because the extractor may replay records at the checkpoint boundary, the loader must be safe to call twice with the same input.
A schema that mirrors the unified accounting invoice shape:
CREATE TABLE coupa_invoice (
tenant_id TEXT NOT NULL,
id TEXT NOT NULL,
invoice_number TEXT,
supplier_id TEXT,
total NUMERIC(18, 4),
currency TEXT,
status TEXT,
issue_date DATE,
due_date DATE,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
remote_data JSONB,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, id)
);
CREATE INDEX coupa_invoice_updated_idx ON coupa_invoice (tenant_id, updated_at);
CREATE INDEX coupa_invoice_status_idx ON coupa_invoice (tenant_id, status);The remote_data JSONB column is where you stash the raw upstream response. The unified API returns it automatically alongside the normalized fields, so you never lose fidelity - a report that needs a Coupa-specific custom field can drop into remote_data->>'my_custom_field' without a schema migration.
The loader itself uses INSERT ... ON CONFLICT DO UPDATE for idempotency, and batches inserts with a single multi-row VALUES clause to keep transaction count low:
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
export async function loadToPostgres(tenantId: string, records: any[]) {
if (records.length === 0) return;
const cols = [
'tenant_id', 'id', 'invoice_number', 'supplier_id', 'total', 'currency',
'status', 'issue_date', 'due_date', 'created_at', 'updated_at', 'remote_data'
];
const values: any[] = [];
const placeholders: string[] = [];
records.forEach((r, i) => {
const base = i * cols.length;
placeholders.push(`(${cols.map((_, j) => `$${base + j + 1}`).join(',')})`);
values.push(
tenantId, r.id, r.invoice_number, r.supplier?.id, r.total, r.currency,
r.status, r.issue_date, r.due_date, r.created_at, r.updated_at,
JSON.stringify(r.remote_data || {})
);
});
const sql = `
INSERT INTO coupa_invoice (${cols.join(',')})
VALUES ${placeholders.join(',')}
ON CONFLICT (tenant_id, id) DO UPDATE SET
invoice_number = EXCLUDED.invoice_number,
supplier_id = EXCLUDED.supplier_id,
total = EXCLUDED.total,
currency = EXCLUDED.currency,
status = EXCLUDED.status,
issue_date = EXCLUDED.issue_date,
due_date = EXCLUDED.due_date,
updated_at = EXCLUDED.updated_at,
remote_data = EXCLUDED.remote_data,
ingested_at = NOW()
WHERE coupa_invoice.updated_at < EXCLUDED.updated_at
`;
await pool.query(sql, values);
}The WHERE coupa_invoice.updated_at < EXCLUDED.updated_at guard prevents an out-of-order retry from clobbering a fresher row - if two workers race, only the newer version wins.
Loading Into S3: Partitioned Parquet
For analytics workloads (Athena, Trino, Snowflake external tables, Spark), object storage is the right downstream. The pattern that works: write one Parquet file per batch, partitioned by tenant and ingestion date, so downstream query engines can prune partitions cheaply.
A Hive-style partition layout that lines up with what lakehouse crawlers expect:
s3://your-lake/coupa/invoice/
tenant_id=acme/
ingest_dt=2026-01-15/
ingest_hr=14/
invoice-01925f9a-b32c-4d8e.parquet
invoice-01925f9b-c81f-4321.parquet
ingest_dt=2026-01-16/
ingest_hr=03/
invoice-0192603a-9d21-4a55.parquet
tenant_id=globex/
ingest_dt=2026-01-15/
...
The Node.js loader that produces this layout, using parquetjs-lite and the AWS SDK:
import { ParquetSchema, ParquetWriter } from 'parquetjs-lite';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { randomUUID } from 'crypto';
const s3 = new S3Client({ region: process.env.AWS_REGION });
const BUCKET = process.env.LAKE_BUCKET!;
const schema = new ParquetSchema({
id: { type: 'UTF8' },
invoice_number: { type: 'UTF8', optional: true },
supplier_id: { type: 'UTF8', optional: true },
total: { type: 'DOUBLE', optional: true },
currency: { type: 'UTF8', optional: true },
status: { type: 'UTF8', optional: true },
issue_date: { type: 'UTF8', optional: true },
due_date: { type: 'UTF8', optional: true },
created_at: { type: 'UTF8', optional: true },
updated_at: { type: 'UTF8', optional: true },
remote_data: { type: 'UTF8', optional: true },
});
export async function loadToS3(tenantId: string, records: any[]) {
if (records.length === 0) return;
const now = new Date();
const ingestDt = now.toISOString().slice(0, 10);
const ingestHr = String(now.getUTCHours()).padStart(2, '0');
const tmpPath = `/tmp/invoice-${randomUUID()}.parquet`;
const writer = await ParquetWriter.openFile(schema, tmpPath);
for (const r of records) {
await writer.appendRow({
id: r.id,
invoice_number: r.invoice_number,
supplier_id: r.supplier?.id,
total: r.total,
currency: r.currency,
status: r.status,
issue_date: r.issue_date,
due_date: r.due_date,
created_at: r.created_at,
updated_at: r.updated_at,
remote_data: JSON.stringify(r.remote_data || {}),
});
}
await writer.close();
const key = `coupa/invoice/tenant_id=${tenantId}/ingest_dt=${ingestDt}/ingest_hr=${ingestHr}/invoice-${randomUUID()}.parquet`;
const body = await import('fs').then(fs => fs.promises.readFile(tmpPath));
await s3.send(new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: 'application/vnd.apache.parquet',
}));
}Hourly partitioning keeps each Parquet file bounded to one hour's worth of records, which lines up with how downstream lakehouse crawlers discover partitions.
Swap loadToPostgres for loadToS3 in the extractor and the same pipeline writes to a data lake instead of a warm database. In practice, most teams do both: warm rows to Postgres for operational queries, cold rows to S3 for analytics.
Failure Scenarios and Retry Runbook
Production ETL pipelines fail. The question is whether the failure is silent, corrupting, or recoverable. Here is the runbook we hand to on-call for the pipeline above.
| Symptom | Likely cause | Runbook action |
|---|---|---|
| Extraction stalls at 100% of records for one tenant | Tenant's OAuth token revoked | Check for integrated_account:authentication_error webhook; notify customer via in-app banner; do not retry until reauth |
| 429s across all tenants simultaneously | Global runaway (a bug queued 10k parallel requests) | Reduce TENANT_CONCURRENCY from 5 to 2; kill the currently running worker; the checkpoint will resume cleanly on next run |
| Postgres insert timeouts | Batch size too large for a wide table, or index bloat | Drop BATCH_SIZE from 1000 to 250; run VACUUM ANALYZE coupa_invoice |
| Duplicate rows in Postgres | Loader ran against a table without the composite PK | Add PRIMARY KEY (tenant_id, id), deduplicate with SELECT DISTINCT ON (tenant_id, id) ... ORDER BY updated_at DESC |
| Checkpoint stuck at old date, records keep re-pulling | Loader silently failed but extractor kept advancing maxUpdatedAt in memory |
Verify the checkpoint is only written after await loader(...) resolves; add a canary metric on records_loaded_total |
| S3 partition has zero-byte files | Extractor crashed after opening the writer but before appending | Add a size check before upload; skip uploads with 0 rows |
| One tenant's sync completes in 30 seconds; another takes 6 hours | Skewed tenant sizes | Move to per-tenant workers with their own concurrency budget rather than one shared pool |
| Extraction succeeds but downstream reports missing invoices | The tenant added a custom field the unified mapping doesn't cover | Query remote_data for the field; if it's needed structurally, add an override at the account level; falling back to the Passthrough API is the escape hatch |
A reasonable set of default alarms:
- Extractor emits
records_extracted_total{tenant, resource}andrecords_loaded_total{tenant, resource, sink}. Alert onextracted > loaded + 5%. checkpoint_lag_seconds{tenant, resource}(now minuslast_synced_at). Alert on lag > 24h for active tenants.sync_run_duration_seconds. Alert on P95 doubling week-over-week.- Webhook consumer alerts on
integrated_account:authentication_errorandintegrated_account:post_install_error.
CLI Examples and a Reproducible Demo
The fastest way to convince yourself the extraction pipeline works is to run a scaled-down version end-to-end from the CLI. This produces a working bulk extract to your local machine before you commit to writing service code.
1. Confirm your tenant list and pick one:
truto integrated-accounts list -o table \
| grep coupa2. Pull one page to confirm the unified schema:
truto unified accounting invoice -a $ACCOUNT_ID -q "limit=10" -o json \
| jq '.result[0]'You get a normalized invoice with remote_data preserving the raw Coupa payload.
3. Run an incremental extract using batch to fan out across resources:
cat > batch.json <<EOF
{
"integrated_account_id": "$ACCOUNT_ID",
"resources": [
{ "resource": "invoice", "method": "list", "query": { "updated_at[gt]": "2025-01-01T00:00:00Z", "limit": 50 } },
{ "resource": "purchase-order", "method": "list", "query": { "updated_at[gt]": "2025-01-01T00:00:00Z", "limit": 50 } },
{ "resource": "supplier", "method": "list", "query": { "limit": 50 } }
]
}
EOF
truto batch batch.json -o json > extract.jsonlOne request, three resources, one file out. This is the reproducible demo you can pull into a Jupyter notebook or straight into psql.
4. Pipe straight into Postgres to complete the end-to-end loop:
truto unified accounting invoice -a $ACCOUNT_ID \
-q "updated_at[gt]=2025-01-01T00:00:00Z,limit=50" -o json \
| jq -c '.result[]' \
| while read row; do
psql $DATABASE_URL -c "INSERT INTO coupa_invoice_staging (raw) VALUES ('$row'::jsonb)"
doneFor a real backfill, replace the shell loop with the Node.js extractor above - but this is enough to prove the shape works before writing production code.
5. Diff two runs to confirm incremental filtering:
# First run: watermark = 2025-01-01
truto unified accounting invoice -a $ACCOUNT_ID \
-q "updated_at[gt]=2025-01-01T00:00:00Z" -o json | jq '.result | length'
# Second run: watermark = today
truto unified accounting invoice -a $ACCOUNT_ID \
-q "updated_at[gt]=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o json | jq '.result | length'Second number should be 0 (or very small) if the incremental cut is working. If it matches the first, the updated_at filter is being ignored somewhere and you need to fix that before it costs you a re-pull of the entire dataset.
This is the loop you iterate on until you trust it. Once trusted, port it into a scheduler (cron, Temporal, whatever you already run) and the extraction pipeline is done.
Building a White-Labeled Integration Marketplace: From Coupa to a Full Catalog
The Coupa integration we just dissected is one spoke in the wheel. If your product roadmap includes more than one enterprise integration, the smart play is to build a white-labeled integration marketplace - an in-app portal where your customers browse, connect, and manage their integrations under your brand, without ever leaving your product.
This section gives you the hands-on implementation playbook to ship that marketplace, starting with Coupa as your first connector. For the strategic overview (build-vs-buy analysis, cost modeling, and architecture trade-offs), see our full guide on building a white-labeled integration marketplace.
MVP Technical Checklist
Before writing a single React component, pin down the infrastructure.
Folder structure for your marketplace MVP:
your-app/
├── src/
│ ├── integrations/
│ │ ├── api/
│ │ │ ├── truto-client.ts # Truto API wrapper
│ │ │ ├── link-token.ts # Backend: generate link tokens
│ │ │ └── integrated-accounts.ts # Backend: list/manage connections
│ │ ├── components/
│ │ │ ├── IntegrationCatalog.tsx # Marketplace grid UI
│ │ │ ├── ConnectButton.tsx # Triggers Truto Link SDK
│ │ │ ├── ConnectionStatus.tsx # Per-account status badge
│ │ │ └── ReauthBanner.tsx # Prompts reauthorization
│ │ ├── hooks/
│ │ │ ├── useIntegratedAccounts.ts # Poll/subscribe to account state
│ │ │ └── useLinkToken.ts # Request link token from backend
│ │ └── webhooks/
│ │ └── truto-handler.ts # Webhook endpoint for account events
│ └── ...
├── .env
└── package.json
Required environment variables:
# .env
TRUTO_API_TOKEN=your_truto_api_token
TRUTO_ENVIRONMENT_ID=your_environment_id
TRUTO_WEBHOOK_SECRET=your_webhook_signing_secret
APP_BASE_URL=https://yourapp.comMinimum launch requirements:
- Truto account with at least one environment configured
- Integrations installed in your environment (Coupa, Salesforce, etc.)
- Unified API models installed for each integration category
- Webhook endpoint registered to receive account lifecycle events
- Frontend component that embeds the Truto Link SDK
- Backend route that generates scoped link tokens
- Connection status UI that reflects
active,needs_reauth, andconnectingstates - Error handling for
integrated_account:authentication_errorwebhook events
Example OAuth Flow (Frontend + Backend)
The connection flow has two parts: your backend generates a scoped link token, and your frontend opens the Truto Link UI using that token. The end user sees the OAuth consent screen (or an API key form, depending on the provider) and never interacts with Truto directly.
Backend: Generate a link token
// src/integrations/api/link-token.ts
import express from 'express';
const router = express.Router();
router.post('/api/integrations/link-token', async (req, res) => {
const { customerId, integrationName } = req.body;
const response = await fetch('https://api.truto.one/link-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.TRUTO_API_TOKEN}`
},
body: JSON.stringify({
tenant_id: customerId,
// Optional: restrict to a specific integration
integrations: integrationName ? [integrationName] : undefined
})
});
const data = await response.json();
res.json({ linkToken: data.id });
});
export default router;The tenant_id is your customer's unique identifier. You can reuse their primary key or account ID - it does not need to be globally unique. One customer can connect multiple integrations under the same tenant_id.
Frontend: Open the connection UI
// src/integrations/hooks/useLinkToken.ts
import authenticate from '@truto/truto-link-sdk';
export async function connectIntegration(
customerId: string,
integrationName?: string
): Promise<{ result: string; integration: string }> {
// 1. Request a link token from your backend
const res = await fetch('/api/integrations/link-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customerId, integrationName })
});
const { linkToken } = await res.json();
// 2. Open the Truto Link UI (popup by default, iframe optional)
const result = await authenticate(linkToken, {
integrations: integrationName ? [integrationName] : undefined
});
// result: { result: 'success', integration: 'coupa' }
return result;
}The authenticate call opens a popup where your customer authorizes the connection. When it completes, Truto creates an integrated account and fires an integrated_account:active webhook to your backend.
Embeddable Link UI Code Snippets
Here is a minimal React component that turns the connection flow into a button inside your marketplace:
// src/integrations/components/ConnectButton.tsx
import { useState } from 'react';
import { connectIntegration } from '../hooks/useLinkToken';
interface ConnectButtonProps {
customerId: string;
integrationName: string;
onConnected: (integration: string) => void;
}
export function ConnectButton({ customerId, integrationName, onConnected }: ConnectButtonProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleConnect = async () => {
setLoading(true);
setError(null);
try {
const result = await connectIntegration(customerId, integrationName);
onConnected(result.integration);
} catch (err) {
setError('Connection failed. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div>
<button onClick={handleConnect} disabled={loading}>
{loading ? 'Connecting...' : `Connect ${integrationName}`}
</button>
{error && <p className="error">{error}</p>}
</div>
);
}For the marketplace catalog grid, render one card per integration with status:
// src/integrations/components/IntegrationCatalog.tsx
import { ConnectButton } from './ConnectButton';
import { ConnectionStatus } from './ConnectionStatus';
interface Integration {
name: string;
displayName: string;
category: string;
logo: string;
connectedAccountId?: string;
status?: 'active' | 'needs_reauth' | 'connecting';
}
export function IntegrationCatalog({
integrations,
customerId,
onConnected
}: {
integrations: Integration[];
customerId: string;
onConnected: (integration: string) => void;
}) {
return (
<div className="integration-grid">
{integrations.map((integration) => (
<div key={integration.name} className="integration-card">
<img src={integration.logo} alt={integration.displayName} />
<h3>{integration.displayName}</h3>
<p>{integration.category}</p>
{integration.connectedAccountId ? (
<ConnectionStatus
status={integration.status}
accountId={integration.connectedAccountId}
/>
) : (
<ConnectButton
customerId={customerId}
integrationName={integration.name}
onConnected={onConnected}
/>
)}
</div>
))}
</div>
);
}Because Truto is a headless API, this UI is entirely yours - your design system, your brand, your layout. No iframes with someone else's logo.
Connection Management UI Patterns
After a customer connects an integration, you need three things: a way to show connection status, a way to trigger reauthorization, and a way to fetch data.
Listing connected accounts:
// src/integrations/api/integrated-accounts.ts
export async function getConnectedAccounts(tenantId: string) {
const response = await fetch(
`https://api.truto.one/integrated-account?tenant_id=${tenantId}`,
{
headers: {
'Authorization': `Bearer ${process.env.TRUTO_API_TOKEN}`
}
}
);
return response.json();
}Each integrated account includes a status field. The states that matter for your UI:
| Status | What it means | UI action |
|---|---|---|
active |
Connected and working | Show green badge |
needs_reauth |
Token expired or revoked | Show reauth banner |
connecting |
Post-install steps running | Show spinner |
validation_error |
Setup validation failed | Show error with retry |
post_install_error |
Post-install action failed | Show error with support link |
Triggering reauthorization:
When a connection enters needs_reauth, generate a link token scoped to that integrated account:
export async function triggerReauth(integratedAccountId: string) {
const response = await fetch('https://api.truto.one/link-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.TRUTO_API_TOKEN}`
},
body: JSON.stringify({
integrated_account_id: integratedAccountId
// Do NOT pass tenant_id when reauthorizing
})
});
const data = await response.json();
return data.id; // Use this link token with the SDK
}Fetching data through the unified API:
Once connected, pulling data from Coupa - or any other integrated provider - uses the same call:
# List invoices from any connected accounting integration
curl https://api.truto.one/unified/accounting/invoice \
-H "Authorization: Bearer $TRUTO_API_TOKEN" \
-H "x-integrated-account-id: $ACCOUNT_ID"The response schema is identical whether the underlying provider is Coupa, QuickBooks, or NetSuite. That is the leverage that makes a marketplace viable - one integration surface for every provider.
Logging & Debugging Recipes
When an integration breaks in production, you need to answer three questions fast: Did the connection die? Did the API call fail? Did the data come back wrong?
1. Listen for account lifecycle webhooks:
Register a webhook endpoint in Truto and handle these events:
// src/integrations/webhooks/truto-handler.ts
import express from 'express';
const router = express.Router();
router.post('/webhooks/truto', async (req, res) => {
const event = req.body;
switch (event.event) {
case 'integrated_account:active':
// Connection successful - update your database
await markConnectionActive(event.payload.id, event.payload.tenant_id);
break;
case 'integrated_account:authentication_error':
// Token expired or revoked - notify the customer
await flagConnectionForReauth(event.payload.id, event.payload.tenant_id);
await sendReauthNotification(event.payload.tenant_id);
break;
case 'integrated_account:reactivated':
// Connection auto-recovered after a transient error - clear the warning
await markConnectionActive(event.payload.id, event.payload.tenant_id);
break;
case 'integrated_account:post_install_error':
// Post-install steps failed - log and alert
console.error('Post-install failed:', event.payload);
await flagConnectionError(event.payload.id, 'post_install_error');
break;
}
res.status(200).send('OK');
});
export default router;2. Check API logs in the Truto dashboard:
Every API call made through Truto is logged with the request URL, response status code, and response time. When a customer reports a data issue, pull up their integrated account and check the logs - no need to reproduce the issue locally.
3. Debug response mapping issues:
If the unified API returns unexpected data, make the same call with truto_response_format=raw to see the unprocessed response from the upstream provider. This immediately tells you whether the issue is in the provider's data or in the field mapping:
# See raw Coupa response, bypassing unified mapping
curl "https://api.truto.one/unified/accounting/invoice?truto_response_format=raw" \
-H "Authorization: Bearer $TRUTO_API_TOKEN" \
-H "x-integrated-account-id: $ACCOUNT_ID"Compare the raw response against the unified response to isolate mapping mismatches.
Sprint Plan: Ship Your First 5 Integrations in 2-4 Weeks
Here is a concrete sprint plan for a two-person engineering team to ship a white-labeled marketplace with five integrations.
Week 1: Foundation
- Set up a Truto account and install your first integration (Coupa, or whichever is blocking a deal)
- Install the relevant unified API model (e.g., Unified Accounting API)
- Build the backend route for link token generation
- Build the webhook endpoint for account lifecycle events
- Test the full connection flow end-to-end with a sandbox account
Week 2: UI + Second Integration
- Build the marketplace catalog page (integration grid with connect buttons)
- Build the connection status component with reauth handling
- Install and test a second integration in the same unified API category
- Verify that the same unified API call works identically across both providers
Week 3: Harden + Expand
- Add error handling, retry logic, and logging for webhook events
- Install integrations 3, 4, and 5
- Build the settings page where customers view and disconnect their integrations
- Wire up the
truto_response_format=rawdebug mode into your internal admin panel
Week 4: Ship
- QA the full flow across all five integrations
- Write customer-facing docs for each connection
- Gate integrations by plan tier if needed (standard integrations on base plans, complex ERP integrations on enterprise tiers)
- Deploy to production and monitor webhook events for the first connected customers
This timeline assumes you are using a unified API that handles auth, pagination, and data normalization. If you are building those layers per vendor, multiply each week by the number of integrations.
Where to Go From Here
If you are shipping a Coupa integration this quarter, take these three concrete next steps:
- Pin your token strategy first: A bad token cache will produce intermittent failures that look like rate-limit issues, scope issues, and clock-skew issues simultaneously. Get the refresh-ahead logic and the 5-second buffer right before you touch business endpoints.
- Build pagination and 429 handling as a single utility: Coupa is one of many enterprise APIs with these constraints. Treat the wrapper as platform code, not Coupa-specific code.
- Decide build-vs-buy with eyes open: If Coupa is the only enterprise procurement integration you will ever need, build it. If your roadmap includes SAP Ariba, Jaggaer, Ivalua, or NetSuite, the per-vendor cost of building stops making sense quickly.
The shortest path to a production Coupa integration that doesn't haunt your on-call rotation is to treat the upstream API as one of many, not as a special case.
FAQ
- What authentication method does the Coupa Core REST API use?
- Coupa Core REST API uses OAuth 2.0 with OpenID Connect. API keys have been deprecated and customer integrations must use OAuth clients with the Client Credentials grant type for system-to-system access. Tokens are issued as JWTs and expire in roughly 24 hours.
- What is Coupa's API pagination limit?
- Coupa's Core REST API enforces a strict 50-record maximum limit per API request using offset-based pagination. To extract larger datasets, you must implement a pagination loop, ideally combined with server-side filters like updated-at[gt] and a fields filter.
- Does the Coupa API publish its rate limits?
- No, Coupa does not publicly document its exact rate limits. Your client must treat HTTP 429 Too Many Requests responses as a normal control signal, honor the Retry-After header if present, and implement exponential backoff with jitter.
- Does Truto retry Coupa 429 errors automatically?
- No. Truto passes upstream 429 errors directly to the caller and normalizes any rate-limit information into the IETF-standard ratelimit-limit, ratelimit-remaining, and ratelimit-reset headers. The caller is responsible for implementing retry and backoff logic.
- Why does Coupa return XML instead of JSON by default?
- Coupa's Core REST API supports both XML and JSON, but defaults to XML for legacy compatibility reasons. To receive JSON responses, you must explicitly send an Accept: application/json header on every API request.