How to Publish a Runnable Sample Repo for Headless vs iFrame Integrations
A runnable Sage Business Cloud Accounting API sample repo covering OAuth, X-Business, sales_invoices, incremental polling with retry/backoff, and 422 fixtures.
When engineering teams evaluate your B2B SaaS integration platform, they face an immediate architectural decision: do they drop in your pre-built embedded iFrame, or do they build a custom UI against your headless API? If they cannot quickly test both approaches in a local environment, your platform fails the technical evaluation. Providing a runnable tutorial and sample repo for headless vs iframe implementations is the highest-leverage asset your product team can ship to accelerate developer onboarding and close enterprise deals.
If you ship an integration platform, the fastest way to win developer trust is to publish a runnable tutorial and a sample repository that demonstrates both a headless API implementation and an embedded iFrame implementation side by side. One repo. Two branches. Same backend. The developer clones it, sets two environment variables, runs npm install && npm run dev, and sees a working OAuth connection to a real third-party API in under five minutes. That is the bar in 2026.
Developers do not read documentation top-to-bottom. They look for a GitHub repository, clone it, run npm install, and expect to see a working application on localhost:3000. If your integration documentation consists only of isolated curl snippets and abstract architecture diagrams, you are forcing evaluating engineers to write boilerplate authentication logic, reverse-engineer undocumented OAuth state parameters, and guess how your UI components interact with your backend.
This guide provides a concrete framework for DevRel leaders and senior product managers to compress evaluation cycles, cut support tickets, and stop losing technical buyers. We will cover how to structure the codebase, how to map out the exact differences between embedded integration UIs and custom UIs, and how to handle the painful realities of rate limits and unified webhooks directly in your code examples.
The Integration UX Dilemma: Headless API vs. Embedded iFrame
Every B2B SaaS product manager eventually hits the same crossroads when shipping customer-facing integrations. You need to connect your application to your users' third-party tools. You have two ways to expose this connection flow to your users.
Short answer: Embedded iFrames give you a working connect flow in an afternoon at the cost of UX control. Headless APIs give you total UX control at the cost of building auth, error states, scope screens, and field mapping yourself.
The first option is the embedded iFrame (commonly called a "Link UI" or "Connect SDK"). This is the fastest path to market. You drop a vendor-hosted JavaScript snippet into your frontend, and the integration provider renders a pre-built modal handling the entire OAuth flow, provider selection, scope selection, and basic field mapping. Your engineers wire up a session token, and you get a callback when the account is connected. The tradeoff is rigidity. iFrames operate in isolated browser contexts, frequently trigger third-party cookie blocking in modern browsers like Safari and Brave, and rarely match your host application's design system perfectly.
The second option is the headless API. You expose the underlying primitives—integrations list, OAuth initiation URL, callback handler, account status—as raw API endpoints. You build the entire integration catalog, connection buttons, and configuration forms natively in your own React or Vue application. This is what teams reach for when they want the connector UX to match their own design system, embed it inside an onboarding wizard, or surface custom field mapping screens that an iFrame cannot accommodate. The tradeoff is developer friction. Your engineering team must build and maintain the UI state, error handling, and callback routing.
Enterprise buyers demand the latter. Studies show that 64% of companies experience regret after choosing SaaS due to a lack of flexibility, driving the massive shift toward headless integration options. B2B SaaS customers require highly scalable, deeply embedded integration solutions due to tool sprawl. Analysis shows small firms use an average of 102 distinct SaaS applications, while mid-market companies use 137. A pre-built iFrame that cannot render your brand's typography, cannot show a help link to your own docs, and cannot inject a custom consent screen for a regulated industry creates exactly the kind of rigidity that drives buyer regret.
Evaluating architects know this. They usually start with the iFrame to prove the connection works, then immediately pivot to evaluating the headless API to see if they can build a native experience. If your documentation does not explicitly show them how to transition from the embedded UI to a custom headless UI, they will assume your platform is too rigid for their needs.
The right answer is rarely "one or the other." Most mature B2B SaaS products ship the iFrame as the default for fast-moving customers and expose the headless API for enterprise tenants that need a bespoke flow. Your sample repo should reflect that reality.
Why Time to First Call (TTFC) Dictates Integration Success
Time to First Call (TTFC) is the developer experience metric that measures the elapsed time from a developer signing up for your platform to executing their first successful, authenticated API request that returns a non-error response. A long TTFC kills API adoption faster than missing features or pricing concerns.
When a staff engineer is tasked with evaluating a unified API or integration platform, they usually timebox the spike to a single afternoon. For integration platforms, the "first call" usually means listing contacts, employees, or tickets from a connected third-party account. They want to authenticate with a provider they already use, pull a list of records, and verify the data schema. If they spend that afternoon debugging invalid_grant OAuth errors, fighting CORS policies, or trying to understand how your platform handles pagination, the evaluation ends in failure.
TTFC is the most important metric you'll need for a public API, and the data on runnable examples is unambiguous. Developers spend significantly less time making their first API request when they fork a collection—they make a successful API call 1.7 to 56 times faster when using a forked collection or sample repo. In one case study, PayPal reduced their time to first call from 60 minutes to one minute and shortened testing time from hours to minutes with runnable collections. A runnable sample repo bypasses the friction of environment configuration. It provides a working baseline where the developer only has to swap out placeholder API keys for their own.
The lesson is not "publish a Postman collection." As we've detailed in our guide to developer API recipes, the lesson is that executable artifacts beat prose. A senior engineer evaluating your platform will spend the first ninety seconds scanning your docs for something they can run. If they find it, they run it. If they have to copy-paste five curl commands, edit headers, and read three OAuth pages first, they bounce.
There is a subtler trap to avoid. Be careful of artificially hacking a TTFC, perhaps by hiding away the tricky parts or ignoring the gotchas, as you may be shifting the friction to the implementation stage. A sample repo that mocks OAuth, hardcodes a fake bearer token, or papers over rate limiting will produce a beautiful first call and a painful week two. The whole point of the runnable repo is to show developers what production actually looks like.
This is why one repo with two implementations beats two separate quickstarts. The headless branch shows the developer what they would build. The iFrame branch shows them the shortcut. They get to compare both with a single auth setup and a single dataset.
Bridging the Gap: The Power of a Runnable Sample Repo
A high-converting sample repo does not force the developer to choose between the iFrame and the headless API. It demonstrates both, side-by-side, in a single application. This proves the flexibility of your architecture and collapses the headless-versus-iFrame decision from a multi-day spike into a fifteen-minute evaluation.
flowchart LR
A[Developer clones repo] --> B[Sets API key<br>and OAuth client]
B --> C{Picks UI Toggle}
C -->|Embedded iFrame| D[Runs Next.js demo<br>with Truto Link SDK]
C -->|Headless API| E[Runs Next.js demo<br>with custom connect UI]
D --> F[Truto Unified API Engine]
E --> F
F --> G[Connects sandbox<br>HubSpot/Salesforce]
G --> H[Lists contacts<br>via unified API]
H --> I[Receives webhook<br>on contact update]The ideal sample repo is a monorepo containing a minimal backend (Node.js/Express or Python/FastAPI) and a modern frontend (Next.js or React). The repo should ship with:
- A single backend that handles the OAuth callback, exchanges the code for tokens, and exposes a thin
/api/contactsproxy. Both UI branches hit the same backend. - Two UI implementations in parallel directories (
/apps/iframe-demoand/apps/headless-demo) or a simple toggle switch in the same Next.js app: "View Embedded UI" vs "View Custom Native UI". Developers see that switching is a UI decision, not an architecture decision. - Pre-seeded
.env.examplewith sandbox credentials for one or two providers. CRMs work well because every evaluator understands contacts. - A
make demoorpnpm demoscript that runs the entire flow end to end, including a webhook receiver bound to a public tunnel. - Honest error states. When the upstream returns a 429 or a 401, the UI should show the real error, not a smoothed-over toast.
This is where the underlying architecture of your integration platform becomes highly visible. Most embedded iPaaS platforms struggle with the headless approach because their internal systems are heavily coupled to their visual workflow builders. They maintain separate code paths for every integration.
Truto operates differently. Truto's runtime is a generic execution engine that takes a declarative configuration describing how to talk to a third-party API, and executes it without any awareness of which integration it is running. The entire platform contains zero integration-specific code.
For the developer building the headless UI, this is a massive advantage. Your sample repo can demonstrate how a single, generic frontend component can handle authentication and data fetching for 100+ integrations. The developer does not need to write if (provider === 'hubspot') logic in their custom UI. They write one code path that dynamically renders connection fields based on the generic metadata returned by the Truto API.
This structure also lets you produce a follow-on tutorial for Post-Connection Configuration UI Patterns for SaaS Integrations (2026 Guide) without rewriting the foundation. The same backend can power the field mapping screen, the sync schedule picker, and the disconnect flow.
The payoff compounds. Paddle's API-first strategy reduced TTFC by 50%, with the improved developer experience leading to a shorter sales cycle and faster onboarding—now onboarding takes only hours. That is the order of magnitude PMs should target when scoping this work.
Step-by-Step: Structuring Your Developer Tutorial
The accompanying tutorial—the markdown file that lives in the repo's README and on your docs site—should follow a five-section structure. Each section should have a runnable artifact next to it. Assume the developer has a locked-down corporate laptop, strict firewall rules, and zero patience for complex build steps.
1. Environment Setup (Target: 60 Seconds)
The root of your sample repo must contain a docker-compose.yml file or a simple package manager script that boots the entire stack. List exact versions (e.g., Node 20, pnpm 9). Provide a .env.example file that clearly documents exactly where the developer needs to paste their API keys.
Never ask a developer to globally install databases or caching layers just to run your tutorial. Use Docker to containerize any required state, or use in-memory SQLite for the sample backend. Avoid the "you will also need a sandbox account at Provider X, ask their sales team for access" trap. If sandbox provisioning is gated, document a workaround or ship with a mock provider in dev mode.
2. Demonstrating the Authentication Harness
Authentication is the most complex part of any integration. Your sample repo must clearly separate the frontend trigger from the backend token exchange. Show how to obtain a connection. With a unified API platform, this is a session token exchange:
// server.ts
const { token } = await trutoClient.linkTokens.create({
end_user: { id: 'user_123', email: 'evaluator@example.com' },
integrations: ['hubspot', 'salesforce', 'pipedrive'],
});
return Response.json({ token });The iFrame branch uses that token to render a connect modal. The headless branch uses it to call the OAuth initiation endpoint directly, capture the state parameter, handle the callback route, and exchange the authorization code itself. Because Truto handles the automated token refreshes proactively behind the scenes, your sample repo code remains incredibly clean. The developer only needs to store the Truto integrated_account_id in their local database, mapped to their internal user_id.
3. The First Call
This is where TTFC is won or lost. The example should be one function:
const contacts = await trutoClient.unified.crm.contacts.list({
integrated_account_id: accountId,
page_size: 50,
});
console.log(`Pulled ${contacts.data.length} contacts`);No polymorphic field discovery. No manual pagination cursor handling on the first call. Get the developer to a 200 OK and a list of records. Then layer in complexity.
4. Writing Data Back
Show a POST next. Reads are easy. Writes expose every quirk of the upstream API—required fields, validation rules, custom objects. A sample repo that only reads is a sample repo that lies. Include a create contact example that handles the 422 response when a required field is missing.
5. Subscribing to Changes
Wire up one webhook receiver. Show the unified event shape. Demonstrate signature verification with a real HMAC check, not a TODO comment. If you skip this section, every developer who clones your repo will reinvent it badly.
For a deeper treatment of how to structure the tutorial document itself, see our guide on How to Publish End-to-End Developer Tutorials with Runnable API Examples.
A detail that matters more than it sounds: pick one provider for the headline path, but make sure the same code works against a second provider with only an integration ID change. This is the proof point for any unified API. If your sample only ever talks to HubSpot, evaluators will assume the abstraction leaks.
Handling the Hard Parts: Rate Limits, Webhooks, and GraphQL
A sample repo that only shows the "happy path" is entirely useless for an enterprise evaluation. Senior engineers want to know what happens when the system fails. This is the section every other tutorial skips. Do not skip it. Your code must explicitly demonstrate how to handle rate limits and asynchronous webhooks.
Architecting the Retry Interceptor
Many integration platforms obscure rate limit errors, resulting in silent failures or unpredictable latency spikes when background workers retry requests endlessly. Radical honesty wins technical evaluations.
A serious sample repo must demonstrate retry logic. Truto does not magically absorb rate limit errors. When an upstream API returns an HTTP 429 Too Many Requests, Truto passes that error directly back to the caller. That design is intentional: the caller knows whether the request is idempotent, whether the user is waiting, and whether to fail fast or queue.
However, Truto normalizes the chaotic upstream rate limit information into standardized headers (ratelimit-limit, ratelimit-remaining, ratelimit-reset) following the IETF specification. Your sample repo must include a global HTTP interceptor (using Axios or Fetch) that catches 429 errors, reads the ratelimit-reset header, and applies an exponential backoff with jitter before retrying.
async function withRetry<T>(fn: () => Promise<Response>, max = 5): Promise<T> {
for (let attempt = 0; attempt < max; attempt++) {
const res = await fn();
if (res.status !== 429) return res.json();
const reset = Number(res.headers.get('ratelimit-reset') ?? 1);
const jitter = Math.random() * 500;
await new Promise(r => setTimeout(r, reset * 1000 + jitter));
}
throw new Error('Rate limited after retries');
}Because the headers are normalized, the same interceptor works whether the underlying provider is HubSpot, Salesforce, or NetSuite. Showing this interceptor in your sample repo proves that you understand production realities. It gives the developer copy-pasteable code for handling one of the hardest parts of API integration. For a deeper understanding of this pattern, review Best Practices for Handling API Rate Limits and Retries Across Multiple Third-Party APIs.
Unified Webhook Ingestion
Webhooks from third-party providers are notoriously inconsistent. Some send nested JSON (Salesforce), others send flat form data or arrays (HubSpot). Some sign payloads with HMAC-SHA256, others use asymmetric RSA keys, and some send flat objects with snake_case keys (Pipedrive).
If a developer is building a custom headless integration, they dread having to write 50 different webhook handlers. Your sample repo must demonstrate how Unified Webhooks solve this. Truto uses JSONata-based configuration for provider-specific event normalization to expose a single contract so your sample repo can ship one handler:
app.post('/webhooks/truto', async (req, res) => {
if (!verifySignature(req.headers['x-truto-signature'], req.rawBody)) {
return res.status(401).end();
}
const { event_type, integrated_account_id, data } = req.body;
switch (event_type) {
case 'record:created':
case 'record:updated':
await upsertContact(integrated_account_id, data);
break;
}
res.status(200).end();
});In your sample backend, expose this single POST /api/webhooks/truto route. Show how this single endpoint receives a normalized, standardized event payload, regardless of whether the original event came from Salesforce, BambooHR, or Jira. Demonstrate how to verify the single X-Truto-Signature header to secure the endpoint. Show idempotency by storing the event ID. Be honest about delivery guarantees—at-least-once means handlers must be safe to run twice. This "aha" moment—realizing they only need to write one webhook handler for their entire application—is often the exact moment an architect decides to buy your platform.
Normalizing GraphQL and REST
One of the most frustrating aspects of building a custom headless UI is dealing with the differing architectures of third-party APIs. Some providers (like Zendesk) use REST. Others (like Linear, Monday, Shopify) use complex GraphQL schemas.
Developers do not want to learn three query languages to evaluate your platform. Your sample repo should demonstrate how to fetch data from both types of providers using a single pattern. Truto's GraphQL to REST proxy API allows developers to treat complex GraphQL APIs as simple CRUD REST resources. In your sample code, you can show a single GET /crm/contacts request that successfully pulls data from a REST provider, and an identical request that pulls data from a GraphQL provider, with the proxy layer handling the translation automatically. This proves to the evaluating architect that your headless API genuinely abstracts away provider-level complexity. Read more about this mechanism in our deep dive on Converting GraphQL to REST APIs: A Deep Dive into Truto's Proxy Architecture.
Concrete Example: Sage Business Cloud Accounting API Integration
CRM contacts make for easy demos, but accounting integrations are where the real complexity lives. Sage Business Cloud Accounting is one of the most common providers your customers will ask you to support - it serves small and mid-market businesses across multiple regions with country-specific tax rules, strict double-entry enforcement, and a ledger-centric data model that differs from what most developers expect.
This section walks through the actual JSON payloads, validation pitfalls, and schema mapping decisions you will face when adding Sage to your sample repo. Every code example here works against the Sage Accounting API v3.1 (https://api.accounting.sage.com/v3.1/).
Repo Overview and Prerequisites
The reference implementation for this section lives in a single monorepo. Publish yours under an obvious slug like github.com/<your-org>/sage-accounting-sample and structure it so that a developer who has never touched Sage can go from git clone to a posted invoice in under fifteen minutes.
Suggested repo layout:
sage-accounting-sample/
├── apps/
│ ├── iframe-demo/ # Next.js app using the Link SDK
│ └── headless-demo/ # Next.js app with a custom connect UI
├── packages/
│ ├── sage-client/ # Thin wrapper around the Sage REST API
│ └── unified-client/ # Same operations via a unified accounting API
├── server/
│ ├── oauth.ts # /auth and /callback routes
│ ├── tokens.ts # Rotating refresh-token store with locks
│ ├── invoices.ts # POST /sales_invoices example
│ └── sync.ts # Incremental polling worker
├── fixtures/
│ ├── 422-unbalanced-journal.json
│ ├── 422-missing-ledger-account.json
│ └── token-rotation-race.json
├── .env.example
├── docker-compose.yml
└── README.md
Before anything runs, the developer needs three things. Document each of them at the top of the README with links, not prose:
- A Sage developer account. Visit developer.sage.com to create a free developer account. Sage provides a client ID and client secret, which act like your application's username and password for API access. During registration, specify your redirect URI - the web address where Sage sends users after they grant permission. This URI must match exactly what you use in your authorization requests.
- A registered app with callback URLs. In the Sage developer portal, to send authenticated requests to the Sage Accounting API, you need to register your app. You will receive a client_id and client_secret in return, which you will use to obtain access tokens during authentication. The Callback URLs field accepts multiple entries, so register both
http://localhost:3000/api/sage/callback(for local dev) and your staging URL up front. Sage requires callback URLs to be full HTTPS URLs in production, withlocalhostallowed for development. - A sandbox business. Sage provides sandbox environments for testing integrations without affecting real accounting data. The sandbox includes sample companies with test data for contacts, invoices, and transactions. Use the sandbox for the first pass. Never test invoice creation against a customer's live ledger.
Sage Business Cloud Accounting has no client-credentials flow. Sage Business Cloud Accounting uses OAuth 2.0 authorization code flow exclusively. There is no client credentials (server-to-server) flow - every integration requires initial interactive user authorization. Your sample repo cannot ship a "headless CI" mode that bypasses the browser. Plan for a one-time manual authorize step in your test setup script.
Step-by-Step Local Run Instructions
The README should get an evaluator to a working invoice in five commands. Anything more and they bounce.
# 1. Clone and install
git clone https://github.com/<your-org>/sage-accounting-sample.git
cd sage-accounting-sample
pnpm install
# 2. Copy the env template
cp .env.example .env
# 3. Boot the local stack (Postgres for token storage + tunnel)
docker compose up -d
# 4. Start the app
pnpm dev
# 5. Open the connect page and authorize your sandbox
open http://localhost:3000/connect/sageThe .env.example file must be exhaustively commented. Nothing kills TTFC faster than an unlabeled env var:
# Sage OAuth app credentials from developer.sage.com > My Apps
SAGE_CLIENT_ID=
SAGE_CLIENT_SECRET=
# Must EXACTLY match a Callback URL registered on the Sage app.
# For local dev, register: http://localhost:3000/api/sage/callback
SAGE_REDIRECT_URI=http://localhost:3000/api/sage/callback
# Space-separated OAuth scopes. full_access is what most integrations need.
SAGE_SCOPES=full_access
# Where the app persists rotating refresh tokens. SQLite works for demos;
# use a durable store in production.
DATABASE_URL=postgres://sage:sage@localhost:5432/sage_sample
# Set to "unified" to route calls through the Truto unified accounting API
# instead of the raw Sage client. Both code paths are wired in.
BACKEND_MODE=directOne detail that trips up first-time integrators: the redirect URI you send in the authorization request must be byte-for-byte identical to one of the callback URLs registered on the Sage app. During registration, specify your redirect URI - the web address where Sage sends users after they grant permission. This URI must match exactly what you use in your authorization requests. Trailing slashes, http vs https, and port numbers all matter.
Code Walkthrough: Auth, Token Refresh, and the X-Business Header
This is the section that pays the rent. Sage's OAuth flow looks textbook, but three operational realities make it hostile to naive implementations: a 5-minute access token TTL, refresh tokens that rotate on every use, and a required per-request business header. Your sample repo has to handle all three or it will silently break the moment a second tenant connects.
Step 1: Kick off authorization.
Register an application at developer.sage.com to obtain a client_id and client_secret. Redirect the user to https://www.sageone.com/oauth2/auth/central?response_type=code&client_id=...&redirect_uri=...&scope=... A minimal Express handler:
// server/oauth.ts
import crypto from 'node:crypto';
const AUTHORIZE_URL = 'https://www.sageone.com/oauth2/auth/central';
const TOKEN_URL = 'https://oauth.accounting.sage.com/token';
app.get('/api/sage/authorize', (req, res) => {
const state = crypto.randomBytes(24).toString('hex');
req.session.oauthState = state;
const url = new URL(AUTHORIZE_URL);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.SAGE_CLIENT_ID!);
url.searchParams.set('redirect_uri', process.env.SAGE_REDIRECT_URI!);
url.searchParams.set('scope', process.env.SAGE_SCOPES!);
url.searchParams.set('state', state);
res.redirect(url.toString());
});Step 2: Exchange the code and persist tokens.
POST the authorization code to the token endpoint to receive an access token and refresh token.
app.get('/api/sage/callback', async (req, res) => {
const { code, state } = req.query as { code: string; state: string };
if (state !== req.session.oauthState) return res.status(400).send('bad state');
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: process.env.SAGE_CLIENT_ID!,
client_secret: process.env.SAGE_CLIENT_SECRET!,
redirect_uri: process.env.SAGE_REDIRECT_URI!,
});
const r = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const tokens = await r.json();
// tokens: { access_token, refresh_token, expires_in: 300, refresh_token_expires_in, resource_owner_id }
await tokenStore.save(req.session.userId, {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
// Refresh proactively 60s before the 5-minute TTL ends.
expires_at: Date.now() + (tokens.expires_in - 60) * 1000,
});
res.redirect('/connect/sage/select-business');
});Step 3: Discover businesses and pin an X-Business.
After the token exchange, the API does not know which business the caller wants. Every request to the Sage Accounting API must specify which business it is targeting. This is done via the X-Business header, which takes the business GUID as its value. X-Business header is required for multi-business users: After OAuth, you cannot determine business access from the token itself. You must call GET /businesses and present a selection to the user.
const r = await fetch('https://api.accounting.sage.com/v3.1/businesses', {
headers: { Authorization: `Bearer ${accessToken}` },
});
const { $items: businesses } = await r.json();
// Present businesses to the user, store the chosen business.id per tenant.Store the chosen business_id next to the tokens. Every subsequent request must set both headers:
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Business': businessId,
'Content-Type': 'application/json',
}If you skip X-Business, Sage silently falls back to the user's "lead" business, which can change under the account. That is how customer data ends up written to the wrong ledger.
Step 4: Refresh tokens without a rotation race.
This is the part every rushed integration gets wrong. Sage access tokens live for roughly five minutes, and every refresh call invalidates the previous refresh token and issues a new one. If two workers try to refresh the same account at the same moment, one wins and the other permanently invalidates itself, forcing the user to re-authorize.
The sample must use a per-tenant lock around the refresh:
// server/tokens.ts
async function getAccessToken(userId: string): Promise<string> {
const t = await tokenStore.get(userId);
if (Date.now() < t.expires_at) return t.access_token;
// Only one refresh at a time per user.
return await withLock(`sage:refresh:${userId}`, async () => {
const fresh = await tokenStore.get(userId);
if (Date.now() < fresh.expires_at) return fresh.access_token; // won a race
const body = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: fresh.refresh_token,
client_id: process.env.SAGE_CLIENT_ID!,
client_secret: process.env.SAGE_CLIENT_SECRET!,
});
const r = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
if (!r.ok) throw new ReauthorizationRequiredError(userId);
const next = await r.json();
await tokenStore.save(userId, {
access_token: next.access_token,
refresh_token: next.refresh_token, // rotated - overwrite atomically
expires_at: Date.now() + (next.expires_in - 60) * 1000,
});
return next.access_token;
});
}Ship a fixture (fixtures/token-rotation-race.json) that simulates two concurrent refresh calls and asserts that only one hits the network. This is the single most valuable test in the repo, because it catches the class of bugs that only show up in production under load.
If you build against Truto instead, the platform refreshes OAuth tokens shortly before they expire and serializes rotation per account under the hood. Your sample repo can then drop the entire tokens.ts module and read a stable access token from a single accessor. The token-rotation-race fixture becomes a regression test that never fires.
Creating a Sales Invoice: Full Payload and Validations
Creating a sales invoice in Sage requires three things up front: a contact_id (with type CUSTOMER), a ledger_account_id for each line item (from the INCOME account group), and a tax_rate_id. You cannot skip any of these - Sage will reject the request.
Here is the minimum viable POST /sales_invoices request:
{
"sales_invoice": {
"contact_id": "14d93840783b11e8990a122c8428e4b2",
"date": "2026-03-15",
"invoice_lines": [
{
"description": "Monthly SaaS subscription - Pro plan",
"ledger_account_id": "4195173e75db11e8990a122c8428e4b2",
"quantity": "1",
"unit_price": "249.00",
"tax_rate_id": "GB_STANDARD",
"tax_amount": "49.80"
}
]
}
}Wrap it in the Sage client from your packages/sage-client module:
// packages/sage-client/invoices.ts
export async function createSalesInvoice(ctx: SageCtx, invoice: SalesInvoiceInput) {
return sageFetch(ctx, 'POST', '/sales_invoices', {
sales_invoice: invoice,
// Optional but recommended: idempotency for safe retries.
// Sage v3.1 supports the Idempotency-Key header on POSTs.
}, { 'Idempotency-Key': invoice.external_id });
}Two things trip up every new implementer. Ensure you round the result to two decimal places. This is the only precision supported by Accounting. Attention: Floating point representations in JSON can be tricky. Please send decimal values as strings! Sending "unit_price": 249.00 instead of "unit_price": "249.00" will cause floating-point precision issues. Second, you must calculate tax_amount yourself using the formula net_amount * tax_percentage / 100, rounded to two decimal places. Sage does not auto-calculate tax for you on invoice lines when the amount is provided.
A successful 201 Created response returns the full invoice representation, including server-generated fields like invoice_number, due_date (derived from the contact's credit_days), and the transaction reference that ties the invoice to the general ledger.
To create draft invoices that don't hit the ledger, set "status_id": "DRAFT". Pro-forma invoices use "status_id": "PRO_FORMA". Both bypass transaction creation until you explicitly release them with POST /sales_invoices/{id}/release.
Ordered validation checklist before sending:
contact_idresolves to a contact withCUSTOMERin itscontact_type_ids.- Every
ledger_account_idoninvoice_lines []belongs to a ledger account withvisible_in=sales(i.e., anINCOMEgroup account). The other difference are the ledger accounts being used on the invoice items. For purchase invoices, the ledger accounts will have to be from the EXPENSE ledger account group, for sales invoices from the INCOME ledger account group. - Every
tax_rate_idmatches a rate returned byGET /tax_ratesfor the business's country. - All decimal values (
quantity,unit_price,tax_amount) are serialized as strings with at most two decimal places. sum(net_amount)per line reconciles tounit_price * quantityafter rounding.- Every retryable POST carries an
Idempotency-Keyheader so a network timeout does not create duplicate invoices.
Sample JSON Payloads: Journals
Journal entries are where Sage enforces double-entry bookkeeping strictly. Every journal must balance - total debits must equal total credits, or the API rejects the request outright.
Here is a balanced journal entry for recording an asset depreciation:
{
"journal": {
"date": "2026-03-31",
"reference": "DEP-2026-03",
"description": "Monthly depreciation - office equipment",
"journal_lines": [
{
"ledger_account_id": "bf1586614a9111e797950a57719b2edb",
"debit": "0",
"credit": "500",
"details": "Office equipment - accumulated depreciation"
},
{
"ledger_account_id": "bf15a6664a9111e797950a57719b2edb",
"debit": "500",
"credit": "0",
"details": "Depreciation expense"
}
]
}
}Each journal line also supports four boolean flags that affect reporting: include_on_tax_return, tax_reconciled, cleared, and bank_reconciled. Getting these wrong does not cause a validation error - the journal will post fine - but it will silently corrupt your customer's VAT return or bank reconciliation. Your sample code should default include_on_tax_return to false for programmatic journals unless you are certain the entry should appear on a tax return.
One sharp edge: Sage does not support editing journals via PUT. If you need to correct a journal, you must DELETE /journals/{id} and re-create it. Your sample repo should demonstrate this delete-and-recreate pattern explicitly.
Chart of Accounts and Tax Rate Mapping
Before you can create any invoice or journal, you need to resolve your product's internal categories to Sage's ledger account IDs and tax rate IDs. This is the mapping step that most integration guides gloss over, and it is where most production bugs originate.
Fetching ledger accounts by context:
Sage filters ledger accounts by where they are visible. You must use the right filter for the right transaction type:
// For sales invoice line items - only INCOME group accounts
const salesAccounts = await sage.get('/ledger_accounts?visible_in=sales&items_per_page=200');
// For purchase invoice line items - only EXPENSE group accounts
const expenseAccounts = await sage.get('/ledger_accounts?visible_in=expenses&items_per_page=200');
// For journal entries - the broadest set
const journalAccounts = await sage.get('/ledger_accounts?visible_in=journals&items_per_page=200');Using an expense account on a sales invoice, or a sales account on a purchase invoice, will trigger a validation error. This is the most common mistake when teams try to use a single hardcoded account ID across all transaction types.
Fetching tax rates:
Tax rates in Sage are region-specific. A UK business will have GB_STANDARD (20%), GB_LOWER (5%), and GB_EXEMPT. A US business will have state-level rates. For region-specific filtering:
// List all tax rates with name and percentage included
const taxRates = await sage.get('/tax_rates?attributes=name,percentage&items_per_page=200');
// Filter by region for multi-state US businesses
const caTaxRates = await sage.get('/tax_rates?attributes=name,percentage&address_region_id=CA-QC');Cache both ledger accounts and tax rates aggressively. They change infrequently, and querying them before every transaction is a waste of your rate limit budget.
Building the mapping table:
Your sample repo should include a mapping configuration that translates your product's categories to Sage's IDs:
// Example mapping config stored per-tenant
const sageMapping = {
revenue: {
'saas_subscription': { ledger_account_id: '4195173e...', tax_rate_id: 'GB_STANDARD' },
'consulting': { ledger_account_id: '4195196a...', tax_rate_id: 'GB_STANDARD' },
'interest_income': { ledger_account_id: '41951b86...', tax_rate_id: 'GB_EXEMPT' },
},
expense: {
'office_supplies': { ledger_account_id: 'a3f21c88...', tax_rate_id: 'GB_STANDARD' },
'travel': { ledger_account_id: 'b7e44d12...', tax_rate_id: 'GB_STANDARD' },
}
};This mapping must be per-tenant because each Sage business has its own chart of accounts with different IDs. Your onboarding flow should present the customer's actual ledger accounts (fetched via the API) and let them map each category themselves.
Polling and Incremental Sync with Retry/Backoff
Sage Business Cloud Accounting has no webhooks. There is no SOAP API, no Bulk API, and no native webhook/event-driven surface - polling is the only change detection mechanism. Your sample repo has to ship a polling worker that actually works under real-world conditions: incremental fetches, pagination, retry on 429/5xx, and a durable checkpoint per resource.
Filtering with updated_or_created_since.
Every list endpoint on v3.1 supports an updated_or_created_since query parameter. Store the last successful sync timestamp per tenant and per resource type, and pass it on the next run:
GET /v3.1/contacts?updated_or_created_since=2026-04-15T10:00:00Z&items_per_page=100
Pagination.
Sage lists return an envelope with $items, $total, $page, and a $next link. Iterate until $next is null. Do not trust $total for termination - iterate on the presence of $next instead.
A complete incremental sync worker:
// server/sync.ts
type Checkpoint = { since: string };
export async function syncSalesInvoices(ctx: SageCtx, cp: Checkpoint) {
const startedAt = new Date().toISOString();
let path: string | null =
`/sales_invoices?updated_or_created_since=${encodeURIComponent(cp.since)}` +
`&items_per_page=100&attributes=all`;
while (path) {
const page = await sageFetchWithRetry(ctx, 'GET', path);
for (const invoice of page.$items) {
await upsertInvoice(ctx.tenantId, invoice); // idempotent by invoice.id
}
path = page.$next; // Sage returns a relative path or null
}
await checkpointStore.save(ctx.tenantId, 'sales_invoices', { since: startedAt });
}Retry with exponential backoff on 429 and 5xx.
2,500 requests/day per company is extremely low compared to enterprise ERPs. A full data sync can exhaust the daily limit in a single run. A sample repo that fires off unbounded requests will hit the ceiling on its first tenant. The retry helper handles the transient failures; the sync scheduler handles the daily budget.
async function sageFetchWithRetry(
ctx: SageCtx,
method: string,
path: string,
body?: unknown,
maxAttempts = 6,
) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const accessToken = await getAccessToken(ctx.userId); // handles refresh
const res = await fetch(`https://api.accounting.sage.com/v3.1${path}`, {
method,
headers: {
Authorization: `Bearer ${accessToken}`,
'X-Business': ctx.businessId,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (res.status === 401) {
// Force a refresh on the next attempt in case the token was rotated elsewhere.
await tokenStore.markExpired(ctx.userId);
continue;
}
const retryable = res.status === 429 || (res.status >= 500 && res.status < 600);
if (!retryable) {
if (!res.ok) throw await sageError(res);
return res.json();
}
// Prefer server hint, otherwise exponential backoff with jitter.
const retryAfter = Number(res.headers.get('retry-after'));
const base = Number.isFinite(retryAfter)
? retryAfter * 1000
: Math.min(60_000, 1000 * 2 ** attempt);
const jitter = Math.random() * 500;
await new Promise(r => setTimeout(r, base + jitter));
}
throw new Error(`Sage request failed after ${maxAttempts} attempts: ${method} ${path}`);
}A few production notes to bake into the README:
- Start backoff at 1 second and cap it. Not implementing rate limit backoff: The 100/min per-company limit is easy to hit during sync. Fix: Implement exponential backoff starting at 1 second, doubling up to 60 seconds.
- Serialize POSTs against the same ledger. Parallel writes to
sales_invoices,purchase_invoices, andpaymentscan collide with Sage's sequential invoice numbering. Use a per-tenant write queue. - Persist the checkpoint before the sync window ends, not after. Store the timestamp you captured at the start of the run. If the worker crashes mid-page, the next run replays overlapping records, which your idempotent upsert handles safely.
Validation Errors and Remediation
Sage returns structured error responses that tell you exactly what went wrong. Knowing the common failure modes saves hours of debugging. Ship these as fixtures in fixtures/ so tests can assert against real bytes.
Unbalanced journal (fixtures/422-unbalanced-journal.json):
{
"$severity": "error",
"$dataCode": "ValidationFailed",
"$message": "Validation has failed",
"$source": "Sage",
"$errors": [
{
"$severity": "error",
"$message": "The total debits must equal the total credits",
"$source": "journal"
}
]
}Remediation: Before sending any journal, validate locally that sum(debit) === sum(credit) across all lines. This is a check your sample repo should enforce in a helper function.
Missing required field on invoice (fixtures/422-missing-ledger-account.json):
{
"$severity": "error",
"$dataCode": "ValidationFailed",
"$message": "Validation has failed",
"$source": "Sage",
"$errors": [
{
"$severity": "error",
"$message": "can't be blank",
"$source": "sales_invoice/invoice_lines/0/ledger_account_id"
}
]
}The $source field uses a path-like syntax that tells you exactly which line item failed. Parse this in your error handler to surface the issue to the user.
Wrong ledger account group:
If you use an expense-group ledger account on a sales invoice, Sage returns a 422 with a message indicating the account is not valid for that transaction type. Your sample repo should validate account group compatibility before sending the request.
Tax number format validation (EU regions):
For businesses operating in EU countries, Sage validates tax registration numbers against country-specific formats. A malformed VAT number on a contact triggers a 422 immediately. Your sample should include a regex pre-validation for the most common EU VAT formats (e.g., GB [0-9]{9} for the UK, DE [0-9]{9} for Germany).
Mapping Sage to a Unified Accounting Schema
When you access Sage through a unified API like Truto, the provider-specific payloads shown above get normalized into a standard schema that works identically across Sage, QuickBooks, Xero, and NetSuite. This is where the value of the sample repo pattern becomes clear for accounting integrations.
flowchart LR
A[Your App] -->|POST /unified/accounting<br>/invoices| B[Truto Unified API]
B -->|Sage| C[POST /sales_invoices<br>with contact_id,<br>ledger_account_id,<br>tax_rate_id]
B -->|QuickBooks| D[POST /invoice<br>with CustomerRef,<br>ItemRef,<br>TaxCodeRef]
B -->|Xero| E[POST /Invoices<br>with ContactID,<br>AccountCode,<br>TaxType]The unified accounting model maps Sage's concepts to provider-agnostic entities:
| Sage Concept | Unified Entity | Notes |
|---|---|---|
contacts (CUSTOMER) |
contacts (type: customer) |
Sage separates customer/vendor by contact_type_ids; unified model uses a contact_type field |
contacts (VENDOR) |
contacts (type: vendor) |
Same endpoint in Sage, same endpoint in unified |
ledger_accounts |
accounts |
Sage uses ledger_account_group (ASSET, LIABILITY, etc.); unified normalizes to standard classifications |
sales_invoices |
invoices (type: sales) |
Sage splits sales/purchase invoices into separate endpoints; unified model uses a type discriminator |
purchase_invoices |
invoices (type: purchase) |
Same unified endpoint, different type |
journals |
journal_entries |
Direct mapping; unified enforces balanced debits/credits just like Sage |
tax_rates |
tax_rates |
Sage's region-specific rates get normalized with percentage and jurisdiction |
products / services |
items |
Sage separates products and services; unified model treats both as items with a type field |
The sample repo should demonstrate the key payoff: your app writes one POST /unified/accounting/invoices call, and the unified layer translates it to the correct Sage-specific payload (with the right ledger_account_id, tax_rate_id, and string-formatted decimals) or the equivalent QuickBooks/Xero payload - without your code changing.
For journal entries specifically, the unified model preserves Sage's strict double-entry enforcement. If you send an unbalanced journal through the unified API targeting a Sage account, you get back the same validation error - the unified layer does not paper over accounting rules that exist for good reason.
The Tradeoffs: Maintenance and Provider Drift
The honest read on the headless-versus-iFrame question is that you should ship both, document both, and let the evaluator pick. A sample repository with two parallel implementations against a shared backend is the highest-leverage artifact a DevRel team can publish, because it answers the buyer's question without requiring a sales call.
However, you must keep your eyes open about the trade-offs:
- Maintenance cost. Two UI implementations means two surfaces to keep current when the underlying API evolves. Budget for it.
- Provider drift. Sandbox accounts at HubSpot, Salesforce, and Pipedrive expire, throttle, and change auth flows. CI against live sandboxes is non-negotiable. Treat the repo as production code, not a one-time marketing asset.
- The abstraction's limits. Unified APIs are a productivity multiplier, not a magic wand. The sample repo should be honest about where the unified model ends and passthrough begins. A short "escape hatch" example—making a raw upstream call through the proxy layer for a field the unified schema does not cover—earns more trust than pretending the abstraction is complete.
Stop Forcing Developers to Guess
Publishing a reference page and hoping developers figure out the implementation details is a failing strategy. The technical evaluation is won or lost in the first five minutes of interaction.
By providing a runnable sample repo that explicitly contrasts embedded iFrame UIs with custom headless implementations, you respect the architect's time. You acknowledge the real-world complexities of rate limits, pagination, and token management, and you provide working code that solves them.
The ROI on this work is direct. Faster TTFC means more activated accounts, fewer support tickets, and shorter enterprise sales cycles. The cost is two engineers for two weeks. The competitive landscape—Postman, Paragon, Appmixer, and every other DX-forward platform—has set the expectation. Match it or watch your evaluators churn.
A runnable tutorial turns a theoretical API reference into a practical, high-converting developer onboarding tool. It moves the conversation away from "Can this platform do what we need?" to "How quickly can we push this to production?"
FAQ
- What JSON fields are required to create a sales invoice via the Sage Business Cloud Accounting API?
- A sales invoice POST to /sales_invoices requires contact_id (with type CUSTOMER), date, and an invoice_lines array. Each line needs description, ledger_account_id (from the INCOME account group), quantity, unit_price, and tax_rate_id. Decimal values must be sent as strings, and you must calculate tax_amount yourself using net_amount * tax_percentage / 100.
- How do I create a balanced journal entry in Sage Accounting API?
- POST to /journals with a date, reference, and journal_lines array. Each line specifies a ledger_account_id with debit and credit amounts. Total debits must exactly equal total credits or Sage rejects the request. Sage does not support editing journals via PUT - to correct one, delete it and re-create it.
- How do I map my product's categories to Sage's chart of accounts?
- Fetch ledger accounts using the visible_in filter (?visible_in=sales for income accounts, ?visible_in=expenses for expense accounts, ?visible_in=journals for the broadest set). Store the mapping per-tenant since each Sage business has unique account IDs. Cache the results aggressively as they change infrequently.
- What are common Sage Accounting API validation errors?
- The most common errors include unbalanced journal entries (debits not equaling credits), missing required fields like ledger_account_id on invoice lines, using a ledger account from the wrong group (e.g., an expense account on a sales invoice), and malformed EU tax registration numbers. Sage returns a structured error response with a $source path that identifies the exact field.
- What is the difference between a headless API and an embedded iFrame integration?
- An embedded iFrame (Link UI) drops a vendor-hosted connect modal into your app for fast setup but limited UX control. A headless API exposes raw endpoints so you build the integration UI natively in your own app, giving full design control but requiring more engineering work. Most mature products ship both options.