How the model fits together
Environments, customers, plans, features and entitlements — and which of them is yours.
Read this before designing a real catalog. Most integration trouble comes from mapping these onto the wrong things.
The shape
Organization your company
└── Project one of your products
└── Environment sandbox | production
├── Customer an account you bill
├── Meter a raw thing you count
├── Feature something a customer buys
├── Plan a price, and what it entitles
│ └── PlanVersion immutable, so terms never change under a subscriber
└── Subscription a customer on a plan versionEverything below Environment is scoped to it. That is the isolation boundary, and it is enforced on every read: an id from a request body is always resolved inside the caller's environment first, so one tenant cannot reach another's data by guessing.
Customer, and your own id
A Customer is an account you bill. It carries a Base316 id (cus_…) and
your own externalId.
Use externalId everywhere. It is the id your systems already have, it means
the same thing on both sides, and it makes an import re-runnable. Quote the
cus_… id on a support call — externalId is unique per environment, so the
same value legitimately exists in sandbox and production.
Every customer has an owner
A customer belongs to one of:
- a tenant — a runtime organization (
runtimeOrganizationId, or its ownexternalIdasruntimeOrganizationExternalId); - a person — a runtime user (
runtimeUserId), for a solo end user; - a tenant in your own system — its
externalId, when you run your own auth and use Base316 for billing.
A customer with none of these is refused unless you create it with
standalone: true, which says it deliberately belongs to nobody — a walk-up
customer on a service invoice, for instance. The rule exists because an
unowned customer is how one account ends up with two: the one your checks
read, and the one somebody upgraded.
A customer linked to a tenant carries the tenant's externalId or none, so the
two can never disagree. And a customer has one live subscription: a second
is refused (409) while the first is trialing, active or past due. Change its
plan instead.
Accounts created before this rule can still be unowned, doubled up, or
carrying two live subscriptions. GET /v1/customers/health lists them — so
does the card at the top of Customers in the dashboard — and
POST /v1/customers/{id}/merge (after /merge-preview) folds one account
into another: subscriptions, credits, wallet balance and unbilled usage move,
while issued invoices and payments stay on the merged account, which no longer
resolves. A customer.merged webhook names both.
To check access for a tenant without knowing its customer, pass
organizationId or organizationExternalId to check() — it resolves to the
same customer the dashboard shows and the user's token describes.
Feature versus meter
This is the distinction that trips people up.
- A meter is a raw thing you count:
api_call,tokens_used,seat_assigned. It aggregates events. - A feature is something a customer buys:
api_calls,advanced_reports. A metered feature is backed by a meter; a boolean feature is not.
You check features. You record events, which meters aggregate. Your application never asks about a meter directly.
Plan versions are immutable
Editing a price would silently re-price everybody already on it. So a plan has versions, a subscription points at one, and publishing a new version leaves existing subscribers exactly where they were until you move them. See Move subscribers to a new plan version.
Where identity fits
Alongside the billing graph is a runtime identity graph: your application's
own users, organisations, roles and permissions. RuntimeUser is your end user;
Customer is the account that pays.
They are linked, and that link matters: a permission check
(check({ userId, permission })) can only answer NOT_A_MEMBER for an account
with no tenant behind it. Entitlement checks work without it.
The account is opened for you
Creating a tenant — POST /v1/organizations, or an end user signing up through
a hosted page — also opens a Customer linked to it. Without that, a tenant
exists that billing cannot see: nothing to subscribe, and every permission
check against it answering NOT_A_MEMBER.
It does not get in your way if you create your own. The account opened for a tenant is a placeholder until something uses it, so the usual pattern still works unchanged:
POST /v1/organizations { "name": "Globex", "externalId": "org_42" }
POST /v1/customers { "name": "Globex Inc", "externalId": "org_42" }The second call fills in the account already opened rather than colliding
with it — externalId is unique per environment and the tenant link is unique
outright, so a second row is not possible. You get one account, with your
details, already linked.
An account you created before its tenant is adopted the same way, matching on the id both carry. One that already bills a different tenant is left alone, because taking a unique link would move somebody's billing without saying so.
This is on for every environment, and there is no setting to turn it off yet. An integration that provisions its own customers loses nothing to it — its customer claims the account opened for the tenant, as above. One that meters without tenants never creates a tenant, so nothing is opened.
Tenants that run other tenants
A tenant can be created under another with parentId — a firm over its
client companies, an agency over its brands. One level deep: a managed tenant
cannot be a parent.
Hartley & Co parent: its own members, plan and invoice
├── Acme Ltd managed: its own members and data, billed to the firm
└── Globex Inc managedThree things follow, each covered in Managed tenants:
- Access. A parent role mapped to a child role lets the firm's staff act inside every client. Their direct memberships always win; an unmapped role reaches nothing.
- Billing. The client's customer bills to the firm's (
billToCustomerId): the firm's plan entitles the client, the allowance is shared, and the client's usage lands on the firm's invoice, broken down per client. - Brand. The client's hosted pages and the components show the firm's branding unless the client sets its own.
What Base316 does not interpret
A permission is a string your code checks for. Base316 stores reports.export,
matches it, and has no opinion about what it means. Declaring one in the
dashboard gives it a name and makes it pickable rather than retyped — it does
not grant it. A role grants it.

