Skip to content

How to Publish a Runnable Sample Repo for Headless vs iFrame Integrations

Build a runnable sample repo covering headless and iFrame integrations with concrete Sage Business Cloud Accounting API examples - invoices, journals, chart-of-accounts mapping, and validation errors.

Nachi Raman Nachi Raman · · 20 min read
How to Publish a Runnable Sample Repo for Headless vs iFrame Integrations

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.

Info

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/contacts proxy. Both UI branches hit the same backend.
  • Two UI implementations in parallel directories (/apps/iframe-demo and /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.example with sandbox credentials for one or two providers. CRMs work well because every evaluator understands contacts.
  • A make demo or pnpm demo script 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.

Tip

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/).

Sample JSON Payloads: Sales Invoices

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"
      }
    ]
  }
}

Two things trip up every new implementer. First, Sage expects decimal values as strings, not numbers. 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.

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.

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.

Unbalanced journal (the most common write error):

{
  "$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:

{
  "$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.

More from our Blog