Managed tenants
One tenant runs others — a firm and its clients, an agency and its brands — and its people work inside each.
Some products are used by a business on behalf of other businesses. An accounting firm keeps the books for forty client companies; an agency runs campaigns for a dozen brands. Each client is a tenant of its own, with its own data and, often, its own people. The firm's staff need to work inside all of them without being added to each one by hand.
A managed tenant is a tenant created under another. The parent is an ordinary tenant with members of its own. Which of those members reach into the children, and with what role, is decided by one mapping on the parent.
Managed tenants are included in the Scale and Enterprise plans. Creating a child and changing the mapping need the plan; detaching a child and the access already granted do not, so a downgrade never locks a firm out of the clients it runs.
Create a client tenant
Pass the parent's id when you create the tenant.
const firm = await base316.organizations.create({ name: "Hartley & Co", externalId: "firm_812" });
const client = await base316.organizations.create({
name: "Acme Ltd",
externalId: "client_4471",
parentId: firm.id,
});
const { data } = await base316.organizations.children(firm.id);It is one level deep. A tenant that is itself managed cannot be a parent, so
"who may act here" is always one lookup rather than a walk up a chain. Creating
a child under a child is refused with 400 naming parentId.
In the dashboard, open Users, switch to the Organizations tab, press Manage on the parent, and use Create client tenant.
Decide who reaches in
A member of the parent gets nothing in the children until their role is mapped.
await base316.organizations.setInheritedRoles(firm.id, [
{ parentRoleId: staff.id, childRoleId: editor.id },
{ parentRoleId: partner.id, childRoleId: owner.id },
]);Read it as: a member holding staff in the firm acts as editor in every
client. A parent role you leave out grants nothing below — the firm's
receptionist does not become an admin of every client because the firm was
made a parent. The call replaces the whole mapping; an empty list removes all
inherited access at once.
The roles are your own. Map to whatever role your application already checks for; the permissions a staff member has inside a client are that role's, not the role they hold in the firm.
What wins
| The person is… | Inside the client they act as |
|---|---|
| a member of the client | their role in the client — always |
| a member of the firm, with a mapped role | the mapped role |
| a member of the firm, with an unmapped role | nobody — no access |
A direct membership always wins, so a firm member who is also a member of a client in their own right is whatever that client made them.
Checking access
Nothing changes in your checks. Ask about the client exactly as before:
const decision = await base316.access.check({
externalId: "client_4471",
externalUserId: "staff_19",
permission: "books.write",
});An allowed check that named a permission says how it was reached:
{
"allowed": true,
"permission": {
"role": { "id": "rrole_…", "key": "editor" },
"source": "inherited",
"viaOrganizationId": "rorg_…"
}
}Log source and viaOrganizationId if your clients will ask which outside
staff touched their records.
In the browser
A signed-in person's teams include every client they reach through a parent,
marked inherited with the parent in via. A firm can manage hundreds of
clients, so page through them:
const first = await browser.teams.page(sessionToken, { limit: 50 });
const next = await browser.teams.page(sessionToken, { limit: 50, cursor: first.nextCursor! });teams.list still returns everything at once. memberCount counts a team's own
members only: somebody reaching a client through the firm is not one of its
members and does not take one of its seats.
Seats for the firm and for its clients
A firm's plan prices seats for the firm's own people, and a client's people are counted separately:
- The firm's tenant is held to the plan's
seatsfeature, as any tenant is. - Each client is held to the firm's plan's
client_seatsfeature instead, counted per client. So "3 client seats" means up to three members in every client, not three spread across all of them. Leaveclient_seatsoff the plan (or give it no limit) and clients are unlimited. - People who reach a client through the firm count against neither.
Create a client_seats feature of type Quantity and add it to the firm's
plan like any other feature. A refusal when adding a member to a full client
says it is the client seats that ran out. After a detach, the client is counted
as a standalone tenant against its own plan again.
The OIDC organizations claim (scope roles) lists inherited tenants the same
way, after the direct ones. A token rides on every request, so the list stops
at 50 and organizations_truncated: true says so; the full list is
GET /v1/me/organizations. Inherited roles are not added to the roles claim,
which lists the groups a person is actually in.
Billing the firm for its clients
A client tenant's billing account bills to its firm's. You do not set this:
it follows the tenant tree, whichever was linked first, and it shows on the
client's customer record as billToCustomerId.
- The firm's plan entitles every client.
access.checkand the entitlement snapshot for a client answer from the firm's subscription — and so do theplanandentitlementsclaims on a client member's token. A client's own subscription, if it has one, does not apply while it is managed. - Allowances are shared. A firm on 10,000 documents a month has 10,000 across itself and all of its clients, not 10,000 each.
- Usage lands on the firm's invoice. Send events for a client exactly as
before, by its own id; they are recorded against the firm, with the client
kept as the source. Each usage line on the firm's invoice carries a
breakdownof which client sent how much.
const { data } = await base316.customers.billed(firmCustomerId);
// [{ id, externalId: "client_4471", name: "Acme Ltd", usage: [{ featureKey: "documents", quantity: 412, … }] }, …]The firm's customer page lists the accounts it pays for with this period's usage, and each client's page says who it is billed to.
What a client's own people see
A client's members still have billing screens — SubscriptionManager,
PricingTable, /v1/me/subscription — and those show the firm's plan,
read-only, with the firm named: "Your plan is provided by Hartley & Co".
Buying, changing and cancelling are refused with 403 billed_by_parent. A
plan bought on the client's own account would grant it nothing while the firm
pays, and the firm's subscription reaches its other clients too. A server
cannot open a checkout for the client's account either.
/v1/me/invoices shows a client's members only the client's own invoices,
never the firm's: those list every client's usage.
Charging per client
Mark one addon Counts managed tenants (countsManagedTenants: true on
addons.create or addons.update) and the firm's subscription carries it at
a quantity equal to its number of clients — attached with the first client,
adjusted with each one created or detached, removed with the last. Price it
PER_UNIT and a firm with twelve clients pays twelve times the addon's price,
without your integration counting anything. One addon per environment can do
this; marking another moves the flag.
Clients included in the plan
"10 clients included, then $12 each" is includedQuantity: 10 on the same
addon (addons.create or addons.update, or Included units in the
dashboard's addon editor). It applies to PER_UNIT addons only. The charge is
max(0, clients − included) × amount:
| Clients | On the subscription | Invoice line | Charged |
|---|---|---|---|
| 9 | quantity 9 | Client company (10 included, 0 billed) | $0 |
| 10 | quantity 10 | Client company (10 included, 0 billed) | $0 |
| 11 | quantity 11 | Client company (10 included, 1 billed) | $12 |
The addon is still attached at the first client and its quantity is still
the real count; only the charge subtracts what is included. That keeps the
count a plain mirror of the firm's clients — editing the included number never
attaches or removes anything — and lets the firm see, on its subscription and
its invoice, how close it is to paying for the next one. The invoice line
records the included units (allowance on the line, shown in the dashboard as
11 − 10 included = 1 billed), so a bill can be explained from the invoice
itself after the number changes.
A change to includedQuantity applies from the next invoice, like a new
amount: the addon is billed in advance, so the period already invoiced
stands. The included number is per addon, so it is the same for every plan in
the environment. A pricing page reads it with the publishable key from
GET /v1/public/addons (billing.addons() in the browser SDK), which lists
each addon's amount, model, includedQuantity and countsManagedTenants.
A firm that subscribes after creating its clients is counted when it subscribes, and firms that already had clients when you marked the addon are counted as soon as you mark it. An hourly sweep keeps every firm in step if a change was ever missed.
Turning the flag off — countsManagedTenants: false, or marking a different
addon, which moves it — takes the addon off every firm's subscription at
once. Nothing is refunded or credited: a recurring addon is billed in
advance with the plan, so the period it covers is already invoiced and stays
paid; the next renewal simply has no line for it. Marking a different addon
puts that one on at the firms' client counts in its place, so a firm is never
billed for both. Only attachments the count made are removed (they show
tracksManagedTenants: true in addons.attached); an addon you attached by
hand is left for you to remove. If the flag is ever cleared some other way,
the hourly sweep removes them.
The firm's brand, on the firm's domain
A firm's clients should sign in under the firm's name, not yours. Each tenant can carry its own branding — name, tagline, logo, colours — and its own sign-in domain.
await base316.organizations.setBranding(firm.id, {
brandName: "Hartley & Co",
primaryColor: "#0a5c36",
logo: "data:image/png;base64,…", // PNG, JPEG, GIF or WebP; SVG is refused
});
const domain = await base316.organizations.addDomain(firm.id, "login.hartley.co");
// Publish domain.records (a TXT and a CNAME), then:
await base316.organizations.verifyDomain(firm.id);- Precedence is tenant, then parent, then environment, field by field. A
client with no branding of its own signs in under its firm's; set only a
colour on a client and it keeps the firm's name and logo.
nullclears a field back to the next level. - Only presentation changes. Which sign-in methods are offered, where a code may be sent and the legal pages stay the environment's.
- On the tenant's verified domain, every page renders in its brand — sign-in, sign-up, MFA, password reset, checkout, the customer portal and the OIDC authorize page — and so do the emails those pages send. The links in those emails (verify, reset, magic link) point back at the tenant's domain, so a person finishes where they started. A domain nobody has verified shows your environment's branding, never the tenant's, and never gets links built on it.
- The issuer does not follow the domain. Tokens are issued by your environment whichever hostname the person signed in on, so one verifier and one JWKS serve every tenant.
- Emails about a tenant — an invitation into it, for instance — use its branding wherever they are sent from.
Branding needs the Pro plan or above; a custom domain, Scale or above. In the dashboard, press Branding on the tenant's row.
In your own screens
The hosted pages pick the brand from the domain. Screens you render yourself
with @base316/react take it from
the provider:
<Base316Provider publishableKey={key} tenant={{ organizationExternalId: firm.id }}>Without React, billing.tenantBranding({ organizationId }) on Base316Browser
returns the same resolved branding.
Letting a client go
await base316.organizations.detach(client.id);The client becomes a standalone tenant. Its own members, data and billing
account stay; the firm's members lose access the moment the call returns, and
organization.detached is sent. From then on it pays for itself: it is
entitled by its own subscription — none, until it subscribes — its new events
are its own, and the firm's per-client addon drops by one. Usage it sent while
managed stays on the firm's bill, where it was incurred.
A parent cannot be deleted while it still has children — detach them first.
Moving a client to another firm is detach, then create it again under the new one.
Every surface
| Action | API | SDK |
|---|---|---|
| Create a client tenant | POST /v1/organizations with parentId | organizations.create({ parentId }) |
| List a parent's clients | GET /v1/organizations/{id}/children | organizations.children(id) |
| Detach | POST /v1/organizations/{id}/detach | organizations.detach(id) |
| Read the mapping | GET /v1/organizations/{id}/inherited-roles | organizations.inheritedRoles(id) |
| Replace the mapping | PUT /v1/organizations/{id}/inherited-roles | organizations.setInheritedRoles(id, mappings) |
| List the accounts a customer pays for | GET /v1/customers/{id}/billed | customers.billed(id) |
| Set a tenant's branding | PUT /v1/organizations/{id}/branding | organizations.setBranding(id, patch) |
| Claim a tenant's domain | POST /v1/organizations/{id}/domain | organizations.addDomain(id, hostname) |
| Verify it | POST /v1/organizations/{id}/domain/verify | organizations.verifyDomain(id) |
The Python, Ruby, Go, Java, PHP and C# clients have the same methods under
their own naming. From the CLI, base316 organizations [parent] lists tenants
and base316 billed <customer> lists what a firm pays for.
Changes made with an API key are recorded in the audit log.

