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_rolelive-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.