How to Build a Coupa API Integration: Developer Tutorial & Code Examples
A highly technical guide for B2B SaaS engineers on building a Coupa API integration. Includes code examples for OAuth 2.0, offset pagination, and rate limit backoff.
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/tokenThe response contains an access_token that 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');
}
}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.
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.
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);
}
);
}It 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.
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.
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.