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 onCross-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 /invoices—customer_id,salesperson_id, eachline_items[].item_idPOST /bills—vendor_id, each line'sitem_idPOST /payments—contact_id,bank_account_idPOST /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
/accountsif 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
}