FinzBooksDevelopers

Multi-org guide

Every PAT is bound to exactly one organisation. Every id you pass on a request — customer_id, item_id, invoice_id, bank_account_id — must belong to that same org. This page explains what happens when they don't, and how to build integrators that serve multiple FinzBooks tenants.

The single-org rule

A PAT (aibk_pat_…) carries the org binding inside its hash. The server resolves the org on every request and scopes every query to it — there's no header you can pass to switch contexts.

# whoami echoes back the resolved org
curl -H "Authorization: Bearer $PAT" "$BASE/whoami"
# → token.org_id is the one you can act on

Cross-org error responses

When you pass an id from a different org (or simply a non-existent id), you get a clean 404 with a field hint:

HTTP/1.1 404 Not Found

{
  "code":  "not_found.resource",
  "message": "Customer 'ct_abc123' not found in this org.",
  "field": "customer_id",
  "request_id": "..."
}

The message does not revealwhether the id exists in another org — that would leak cross-tenant information. From your side it's indistinguishable from a typo.

Where these fire

The 404 lookup runs upfront on every mutating call that takes a foreign id:

  • POST /invoicescustomer_id, salesperson_id, each line_items[].item_id
  • POST /billsvendor_id, each line's item_id
  • POST /paymentscontact_id, bank_account_id
  • POST /credit_notes + /apply-to-bill, /apply-to-invoice — same set

Integrator pattern for multi-tenant apps

If you're building a SaaS that integrates with multiple FinzBooks organisations, store one PAT per FinzBooks org in your DB, and select the right one at request time:

// Your DB
table integration_tokens {
  customer_id        uuid           // your customer
  ai_books_org_id    string         // returned by /whoami
  pat                string         // encrypted at rest
  scopes             string[]
  minted_at          timestamp
}

// At request time
async function aiBooksClient(yourCustomerId: string) {
  const row = await db.query(
    "SELECT pat FROM integration_tokens WHERE customer_id = $1",
    [yourCustomerId],
  );
  return new Client({
    baseUrl: "https://api.finzbooks.com/api/public/v1",
    pat: decrypt(row.pat),
  });
}

OAuth 2.0 for end-user authorisation

For partner apps where the FinzBooks user grants permission to your app (rather than the user copying a PAT), use the OAuth flow. The access token returned is also scoped to one org. See OAuth 2.0.

OAuth differs from PATs in one way relevant here: the user can revoke at any time (they'll get a 401 next call), and access tokens expire — refresh them with the standard grant_type=refresh_token exchange.

What to do when a customer switches FinzBooks orgs

Stale tokens for the old org will fail with 401 (revoked) or return ids your code doesn't recognise. Don't cache the org_id from one call to the next — pull it fresh from /whoamiat the start of a session and refuse to act if it changed. Easy mistake: copying a PAT into prod that's bound to the wrong org.

Common mistakes (real ones)

  • Copying ids out of one org's UIand POSTing them with a different org's PAT. The 404 is your friend here — before this guard existed, the same scenario surfaced as a confusing “Organisation or Contact details missing” 500.
  • Hardcoding bank_account_id in a config, then the customer disables that bank account, then your next call 404s. Re-fetch from /accounts if you cache.
  • Using a sandbox PAT (aibk_pat_test_) against prod data.Sandbox tokens live in their own isolated org; ids won't cross over. Mint a fresh prod PAT.

Cookbook: detecting org-mismatch in your error handler

function handleAIBooksError(err: ApiError) {
  if (err.status === 404 && err.body.code === "not_found.resource") {
    // The 'field' tells you which input was the wrong tenant
    log.error("FinzBooks cross-org id", {
      field:  err.body.field,
      value:  payload[err.body.field],
      org_id: ourCachedOrgId,
    });
    // Re-fetch /whoami and bail
    throw new IntegrationStaleError("token org changed?");
  }
  // ... other handlers
}