Skip to content

Tenancy and cross-org behaviour

Every /api/v1/orgs/{org_id}/... request is scoped to the org_id in the URL. This page documents what happens when that scoping doesn't line up — a token that isn't connected to the org, a policy that blocks the app, or a resource id that belongs to a different org.

A token carries no org claim

An OAuth token identifies (application, user) only — it does not carry an org. Org authority is re-derived on every request from your app's live connection record for (application, org_id-in-the-URL, granting user), never from anything baked into the token at issue time. This is why revoking a connection, or a grantor losing their role, takes effect on the very next request without needing a new token.

Calling an org the app isn't connected to

If no active, unrevoked connection exists for (application, org_id, user), the request fails 403 org_not_connected — regardless of whether the org exists, or whether the app is connected to some other org. There is no way to distinguish "org doesn't exist" from "app isn't connected to this org" from this response alone, by design (see Resource ids belonging to another org below for the same principle applied to individual resources).

Org policy blocks the app

Even with an active connection, the organisation's own policy is re-evaluated on every request (allow_third_party_apps, allow_unverified_apps). If the org has since turned third-party or unverified apps off, and this app is affected, the request fails 403 org_policy. Nothing is revoked when policy flips — the connection still exists — but every subsequent request 403s until policy allows the app again. First-party apps are never affected by this check.

Resource ids belonging to another org

If a resource id in the URL path (a bill id, an invoice id, a pay run id, a migration batch id, …) refers to a real row that belongs to a different organisation than the one your token is scoped to for this request, the response is a 404-class code — not_found for the bills/invoices/credit-notes/connections endpoints, pay_run_not_found for payroll, batch_not_found for migration — not 403, and not a different code that would confirm the resource exists elsewhere.

Line items inside the request body don't carry internal ids at all — tax is specified as a rate (vat_percentage, a number) and accounts as a free-text category_name, both resolved by a lookup scoped to your own org. There is no cross-org id in a request body for RLS to hide, so this scenario doesn't arise there the way it does for a path id. An unmatched vat_percentage (no tax rate configured at that rate for the org) is a plain 422 validation error (tax_rate_not_configured). An unmatched category_name, by contrast, does not error at all. LuciBook first tries a learned mapping and a fuzzy name match, then falls back to an auto-created "Uncategorised" account for that side (expense or income). A typo or unrecognised category therefore never fails the request, but it may fuzzy-match a different account or land in the fallback account instead of the one you meant.

This is deliberate. Row-level security is enforced at the database level for every query this API runs on your behalf: a query scoped to your org simply never sees rows belonging to another org, so from the application's point of view a cross-org id and a nonexistent id are indistinguishable — both produce zero rows. The API preserves that indistinguishability rather than special-casing "found, but wrong org" into a more informative error, because doing so would let a caller probe whether an id exists in an org they have no access to. Don't build client logic that tries to tell these two cases apart from the response; there is no signal to do so.

org_mismatch is reserved, not emitted

The error catalogue documents org_mismatch (403) as "the referenced resource belongs to a different organisation," and the machinery to produce that code exists in the codebase, wired to a response. However, nothing in the platform currently triggers it — no request can reach org_mismatch today. The resource-belongs-to-another-org case is instead handled by the 404-class and 422 responses described above. org_mismatch is kept in the catalogue as a reserved code for a future case that needs to distinguish "wrong org" from "not found" explicitly; treat it as currently unreachable rather than building handling logic around it.

See also

  • Errors — the full error catalogue and the insufficient_role live-check rule, which is a related but distinct per-request tenancy check (role tier, not org connection).
  • Connections — how a connection is created and revoked.