# Base316 Base316 answers one question on the request path of your application: > **May this account do this, and who is billed for it?** It holds your plans, what each one entitles an account to, how much they have used, and what they owe. Your code asks; Base316 decides. ## Two halves that share one model [#two-halves-that-share-one-model] **Billing** — plans, subscriptions, usage metering, invoices, proration, discounts, credit notes and dunning. Money is collected through Stripe Connect, where your customer stays the merchant of record. **Identity** — your application's own users, organisations, roles and permissions, with hosted sign-in, SSO, OIDC and SAML for the accounts that demand it. They share a model on purpose. A permission check and an entitlement check are the same question asked about different things, and answering them separately is how an application ends up entitling a user its billing system thinks left three months ago. ## Start here [#start-here] A key, a first call, and a gated feature — in about fifteen minutes. Environments, customers, plans, features. Read this before designing your catalog. The call you will make most often. Import customers and subscriptions without billing anybody twice. ## What it is not [#what-it-is-not] Base316 is not a payments company. Charges are created directly on your own Stripe account, you keep your Stripe dashboard, and a chargeback settles against your balance rather than ours. It is also not a data warehouse. Usage events are metered and rated; they are not a general event store, and Base316 will not answer analytics questions about them. # Using the API Base API: `https://api.base316.com` ## Authentication [#authentication] ```bash curl https://api.base316.com/v1/whoami \ -H "Authorization: Bearer $BASE316_SECRET_KEY" ``` The key decides which environment you touch — there is no environment parameter to get wrong. Most endpoints require a secret key; a publishable key reaches only the public catalog and sign-in flows. Confirm what a key points at before anything else: ```bash GET /v1/whoami ``` ## Errors [#errors] Every failure returns the same shape: ```json { "error": { "type": "invalid_request", "code": "INVALID_REQUEST", "message": "Provide either `customerId` or `externalId`." }, "requestId": "req_..." } ``` Branch on `code`. It is part of the contract and changes only additively; `message` is written for a human and may be reworded. | Code | Status | Meaning | | --------------------------- | ------ | ------------------------------------------------------------------ | | `MISSING_API_KEY` | 401 | No key was sent | | `INVALID_API_KEY` | 401 | Revoked, or not for this environment | | `SECRET_KEY_REQUIRED` | 403 | A publishable key cannot do this | | `FORBIDDEN` | 403 | The key is valid and this action is not allowed for it | | `NOT_FOUND` | 404 | No such resource in this environment | | `INVALID_REQUEST` | 400 | The request was malformed | | `CONFLICT` | 409 | Conflicts with the current state — a duplicate key, an overpayment | | `IDEMPOTENCY_KEY_REUSED` | 409 | The same key was sent with a different body | | `IDEMPOTENCY_KEY_IN_FLIGHT` | 409 | The first attempt is still running — retry, and it settles | | `PLAN_LIMIT` | 402 | The workspace's plan does not include enough of something | | `PLAN_FEATURE` | 402 | The workspace's plan does not include the capability at all | | `RATE_LIMITED` | 429 | Too many requests — honour `Retry-After` | | `SERVICE_UNAVAILABLE` | 503 | A service the call depends on did not answer — retry with backoff | | `INTERNAL` | 500 | Our fault | Quote `requestId` when asking for help; it identifies the exact call. ### When a plan runs out [#when-a-plan-runs-out] `PLAN_LIMIT` and `PLAN_FEATURE` both carry 402, and they mean different things. A limit is a number that ran out; a feature is one the tier never had. Waiting helps with the first only when the allowance resets monthly. `param` names the allowance, so you can tell them apart without reading the message: ```json { "error": { "type": "plan_limit", "code": "PLAN_LIMIT", "message": "Your plan allows 100 customers. Upgrade to add more.", "param": "customers" }, "requestId": "req_..." } ``` The calls that can return it are the ones that create something countable or consume an allowance: `POST /v1/customers`, `POST /v1/users`, `POST /v1/organizations` and `POST /v1/events`. Never retry a 402. The answer does not change until the plan does, or — for `meterEventsPerMonth` — until the month does. These limits were advertised before they were enforced. An integration written against an earlier version may meet a 402 for the first time without anything in it having changed. ## Idempotency [#idempotency] `POST /v1/events` requires an `idempotencyKey`. The same key records the event once, however many times you send it — a retry after a network timeout is safe by construction. It is required rather than optional because only you know what "the same work" means. A request id is usually the right answer. ## Pagination [#pagination] List endpoints return: ```json { "data": [ ... ], "nextCursor": "..." } ``` Pass `cursor` to continue and `limit` to size a page. A `nextCursor` of `null` means you have reached the end. ## Money [#money] **Amounts are strings, always.** `"1234.56"`, never `1234.56`. A float is how a total ends in `.30000000000000004` on somebody's invoice. Parse them with a decimal library, not `Number`, if you are going to do arithmetic on them. Currency is a three-letter code, and Base316 never sums across currencies. Adding dollars to euros needs an exchange rate, a rate needs a date, and a billing engine that quietly picks one produces a number nobody can reconcile. ## Timestamps [#timestamps] ISO 8601 in UTC. Send them the same way. # Endpoint reference All paths are relative to `https://api.base316.com`. See [Using the API](/api/overview) for authentication, errors and pagination. ## Access and metering [#access-and-metering] The two calls on your request path. | | | | ----------------------- | -------------------------------------------------- | | `POST /v1/access/check` | May this account do this? Also answers permissions | | `POST /v1/events` | Record usage. `idempotencyKey` required | ## Customers [#customers] | | | | ------------------------------------ | -------------------------------------------------------- | | `GET /v1/customers` | List | | `POST /v1/customers` | Create | | `GET /v1/customers/:id` | One customer | | `PATCH /v1/customers/:id` | Update name, email, your id, currency, terms, collection | | `GET /v1/customers/:id/billed` | The accounts it pays for, with usage this period | | `GET /v1/customers/:id/entitlements` | Every feature as `check` would answer it now, and why | ## Catalog [#catalog] | | | | ---------------------------------------------------- | ------------------------------------------------------------------------ | | `GET` · `POST /v1/meters` | The raw things you count | | `GET` · `PATCH` · `DELETE /v1/meters/:id` | | | `GET` · `POST /v1/features` | What a customer buys | | `GET` · `PATCH` · `DELETE /v1/features/:id` | | | `GET` · `POST /v1/plans` | Prices and entitlements; each version has `status` and `subscriberCount` | | `GET` · `PATCH` · `DELETE /v1/plans/:id` | | | `POST /v1/plans/:id/versions` | A new immutable version, or a draft with `publish: false` | | `PATCH` · `DELETE /v1/plans/:id/versions/:versionId` | Edit or discard a draft. 409 for a published version | | `POST /v1/plans/:id/versions/:versionId/publish` | Publish a draft. Idempotent | | `GET` · `POST /v1/product-families` | Products | | `PATCH` · `DELETE /v1/product-families/:id` | | | `PUT /v1/plans/:id/family` | File a plan under a product | ## Subscriptions [#subscriptions] | | | | -------------------------------------------------------------------- | --------------------------------------------------------- | | `GET` · `POST /v1/subscriptions` | | | `GET /v1/subscriptions/:id` | | | `GET /v1/subscriptions/:id/upcoming-invoice` | Preview the next invoice. Stores nothing | | `GET /v1/subscriptions/:id/plan-change-preview` | `?planVersionId=`. Now vs at period end | | `POST /v1/subscriptions/:id/plan-change` | `timing`: `NOW` or `PERIOD_END` | | `GET` · `PUT` · `DELETE /v1/subscriptions/:id/phases` | The schedule: a ramp, or its undo | | `POST /v1/subscriptions/:id/resume` | Undo a cancellation at period end | | `PATCH /v1/subscriptions/:id/quantity` | Change the quantity, prorated | | `GET /v1/subscriptions/:id/quantity/preview` | What that change would charge. Stores nothing | | `DELETE /v1/subscriptions/:id` | Cancel. `?atPeriodEnd=false` to cancel now | | `GET` · `POST` · `DELETE /v1/subscriptions/:id/discounts` | A recurring coupon | | `GET` · `POST /v1/subscriptions/:id/price-overrides` | Custom prices for this subscription | | `PATCH` · `DELETE /v1/subscriptions/:id/price-overrides/:overrideId` | Edit or remove one | | `PATCH /v1/subscriptions/:id/contract` | PO number, invoice memo, billing day | | `GET /v1/subscriptions/:id/contract/billing-anchor-preview` | `?billingAnchorDay=`. The stub it creates. Stores nothing | ## Invoices and payments [#invoices-and-payments] | | | | --------------------------------------------------- | ---------------------------------------- | | `GET` · `POST /v1/invoices` | | | `GET /v1/invoices/:id` | | | `POST /v1/invoices/:id/finalize` | Draft becomes owed | | `POST /v1/invoices/:id/pay` | Settle in full | | `POST /v1/invoices/:id/void` | | | `GET` · `POST /v1/invoices/:id/payments` | Record a payment received outside Stripe | | `POST /v1/invoices/:id/payments/:paymentId/reverse` | A bounced cheque | | `GET /v1/invoices/:id/pdf` | The invoice as a PDF | ## Quotes [#quotes] | | | | ----------------------------- | ---------------------------- | | `GET` · `POST /v1/quotes` | | | `GET /v1/quotes/:id` | | | `POST /v1/quotes/:id/send` | Freezes the terms | | `POST /v1/quotes/:id/accept` | **Creates the subscription** | | `POST /v1/quotes/:id/decline` | | | `GET /v1/quotes/:id/pdf` | The quote as a PDF | ## Billing extras [#billing-extras] | | | | ----------------------------------------------- | --------------- | | `GET` · `POST /v1/coupons` | | | `DELETE /v1/coupons/:id` | Archive | | `GET` · `POST /v1/credit-notes` | | | `POST /v1/credit-notes/:id/void` | | | `GET /v1/credit-notes/:id/pdf` | As a PDF | | `GET` · `POST /v1/tax-rates` | | | `DELETE /v1/tax-rates/:id` | Archive | | `GET /v1/wallets` · `/v1/wallets/:customerId` | Prepaid balance | | `POST /v1/wallets/credit` · `/v1/wallets/debit` | | ## Identity [#identity] Your application's own users and organisations. | | | | ------------------------------------------------------------------------------- | ---------------------------------------- | | `GET` · `POST /v1/users` | | | `GET` · `PATCH /v1/users/:id` | | | `POST /v1/users/:id/disable` | Keeps history attributable | | `GET` · `POST /v1/organizations` | Your customers' tenants | | `PATCH /v1/organizations/:id` | Rename, re-key, default role | | `DELETE /v1/organizations/:id` | Delete an empty tenant. `?exceptUserId=` | | `GET /v1/organizations/:id/children` | The tenants it manages | | `POST /v1/organizations/:id/detach` | Make a managed tenant standalone | | `GET` · `PUT /v1/organizations/:id/inherited-roles` | Which parent roles reach the children | | `GET` · `PUT /v1/organizations/:id/branding` | A tenant's own look | | `GET` · `POST` · `DELETE /v1/organizations/:id/domain` | A tenant's own sign-in domain | | `POST /v1/organizations/:id/domain/verify` | Check its DNS | | `POST` · `DELETE /v1/organizations/:id/members` | | | `GET` · `POST /v1/roles` · `GET` · `PATCH` · `DELETE /v1/roles/:id` | | | `GET` · `POST /v1/permissions` · `GET` · `PATCH` · `DELETE /v1/permissions/:id` | | | `GET /v1/custom-fields` | | ## Authentication [#authentication] End-user sign-in, if you use Base316 for it. | | | | ---------------------------------------------------------------------------- | ---------------------------------------------------------- | | `POST /v1/auth/signup` · `/signin` · `/signout` | Sign-up takes `invitationToken` to join a team instead | | `GET /v1/auth/session` · `/sessions` | | | `POST /v1/auth/sessions/revoke-all` | | | `POST /v1/auth/password/reset-request` · `/password/reset` | | | `POST /v1/auth/verify-email` | | | `POST /v1/auth/mfa/enroll` · `/mfa/confirm` · `/mfa/verify` · `/mfa/disable` | | | `POST /v1/auth/mfa/required/start` · `/mfa/required/complete` | Enrol a required factor mid-sign-in. Challenge, no session | | `GET /v1/auth/password-policy` | Password rules. Publishable key | | `POST /v1/auth/code/exchange` | Hosted sign-in callback | | `POST /v1/auth/impersonate` | Support access. Audited | | `GET /v1/auth/events` | Sign-in history | ## Customer-facing [#customer-facing] For a self-serve portal, called with the end user's own session. | | | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | `GET /v1/me/subscription` | | | `GET /v1/me/subscription/upcoming-invoice` | The user's next bill. 403 `billed_by_parent` for a client tenant | | `GET /v1/me/subscription/quantity/preview` · `POST /v1/me/subscription/quantity` | The user's own seat count: preview, then change | | `POST /v1/me/subscription/change` · `/cancel` | | | `GET /v1/me/subscription/plan-change-preview` | What a change would do. `?planVersionId=` | | `DELETE /v1/me/subscription/scheduled-change` | Undo a scheduled change | | `GET /v1/me/invoices` · `/me/invoices/:id` | | | `GET /v1/me/invoices/:id/pdf` | One of the user's own invoices, as a PDF | | `GET /v1/public/plans` · `/public/branding` | Publishable key. `?organizationId=` for a tenant's brand | | `GET /v1/me/organizations` | The person's teams, inherited ones included. Paged with `limit` | | `POST /v1/checkout/sessions` | | ## Import and export [#import-and-export] | | | | ----------------------------------- | -------------------------------------------------------------------------------------- | | `GET /v1/export/:resource` | `customers`, `subscriptions`, `invoices` as CSV | | `GET /v1/import/:resource/template` | An empty file with the right columns | | `POST /v1/import/:resource` | `customers`, `subscriptions`, `users`; `skipPastInvoices` for back-dated subscriptions | ## Payments [#payments] | | | | ----------------------------------- | ---------------------- | | `GET /v1/payment-accounts` | Stripe Connect status | | `POST /v1/payment-accounts/refresh` | Re-read it from Stripe | ## Utility [#utility] | | | | ---------------- | --------------------------------------------- | | `GET /v1/whoami` | Which project and environment this key is for | # Authentication Base316 holds **your application's users**, not just the people who log into your dashboard. They are a separate population from your own team, with their own tenants, roles and sign-in. Read [How the model fits together](/concepts/model) first if you have not. Everything here sits under **Environment**, the same isolation boundary as the billing half. ## The shape [#the-shape] ``` Environment ├── RuntimeUser a person who signs in to your product │ ├── credential a password, if they have one │ ├── passkeys registered authenticators │ ├── mfaFactors second factors │ └── sessions where they are signed in ├── RuntimeOrganization a tenant — one of your customers' companies │ └── RuntimeMembership a user's place in a tenant, holding one role └── RuntimeRole what a membership may do ``` Two things follow from this shape, and both catch people out. **A user is not a tenant.** Signing up creates both — a person, and a tenant of one for them to belong to — because billing hangs off the tenant and a user with no membership reads as "not a member" to every permission check. The exception is somebody signing up **to accept an invitation**. Pass the token from the invitation link as `invitationToken` on `POST /v1/auth/signup` (`auth.signUp({ …, invitationToken })`; `` reads `?invitation=` from the page for you) and the account is created straight into the inviting tenant instead: * no tenant of one is opened, and so no billing account beside it; * the address is marked verified — the token reached that mailbox, the same proof a magic link is — so `verificationToken` comes back `null` and an environment that requires a confirmed address lets them sign in at once; * the invitation is accepted in the same transaction, with the role it named. The token must be live and sent to the same address (compared without case). Otherwise the sign-up is refused — `INVITATION_INVALID` for a token that is unknown, withdrawn, used or expired, `INVITATION_EMAIL_MISMATCH` for one sent to somebody else — rather than quietly opening the workspace you were trying to avoid. A tenant with no free seat refuses with 409 `SEAT_LIMIT_REACHED` and creates nothing. An invitation also admits its address to an invitation-only environment; a blocked address stays blocked. The response always says where the person landed: `organizationId` and `invitationAccepted`. A tenant nobody needs any more — including a personal one opened before this existed — is removed with `DELETE /v1/organizations/:id` (`organizations.delete(id, { exceptUserId })`). It is refused while anybody but `exceptUserId` is a member, while a paid subscription is active, past due or paused, or while the tenant manages others, has a connected payment account or sells its own plans. A trial is cancelled as part of it. Memberships, invitations, branding and tenant-scoped records go with it; its billing account is unlinked and kept, because it holds invoices. A repeat answers `alreadyDeleted: true`. **A membership holds exactly one role.** Somebody in two groups at their identity provider has whichever role was applied last. See [Directory sync](/guides/directory-sync) for why that is the honest consequence rather than a limitation to work around. ## Ways in [#ways-in] Which of these your users see is set per environment, on the **Authentication** tab. | Method | Default | What it proves | | -------------- | ------- | ---------------------------------------- | | Password | on | Something they know | | Social sign-in | off | An account at Google, GitHub, and others | | Magic link | off | Control of the mailbox | | Passkey | off | Possession of a registered device | | Enterprise SSO | off | Whatever the customer's own IdP checked | Twelve social providers are supported, from Google and Microsoft to Discord and Roblox. [Social sign-in](/guides/social-sign-in) covers registering each one, which accounts are joined by email, and restricting Microsoft to your customers' directories. You can present these through **hosted pages** Base316 renders and brands, or build your own screens on the SDK, or use the components in `@base316/react`. All three reach the same endpoints. ### Signing in without a browser [#signing-in-without-a-browser] A CLI, a television, or anything with no browser uses the **device flow** (RFC 8628). The device shows a short code, the person approves it on their phone, and the device learns the outcome by polling. Enable it per client by adding the **Device code** grant under **OIDC clients**. Approval can happen on a page in your own application — `` in `@base316/react` is that page — or on Base316's hosted `/hosted/device` if you would rather not build one. The scopes a device asks for are narrowed to the ones its client is registered for, exactly as `/authorize` narrows them. A device asking for more than its client holds is granted the overlap rather than refused, so register the client for everything it legitimately needs. ## Sessions [#sessions] A session is an opaque token. Only its digest is stored, so a leaked database yields no usable sessions. Sessions are **per environment**. A sandbox password can never open a production session, even for the same person and the same address. ### Single sign-on, and single sign-out [#single-sign-on-and-single-sign-out] Signing in on the hosted pages also opens a session with Base316 itself, so a second application's trip through `/oidc/{env}/authorize` goes straight through without a form. `prompt=login` and `max_age` ask for credentials again anyway, and `prompt=none` answers `login_required` rather than showing a page. Signing out works the other way round. Give an OIDC client a **back-channel logout URL** (Settings → Applications → Edit) and Base316 POSTs it a signed `logout_token` whenever somebody who signed in to that application: * signs out at `/oidc/{env}/logout`, * signs out of every device, changes or resets their password, * is disabled or erased, * or disconnects that application. Only applications the person actually signed in to are told. The token names the person (`sub`), not a session, so end every session you hold for them. Verify it with `createLogoutTokenVerifier` from `@base316/sdk`: ```ts import { createLogoutTokenVerifier } from "@base316/sdk"; const logout = createLogoutTokenVerifier({ issuer: "https://api.base316.com/oidc/env_1a2b3c", clientId: "oidc_…", }); app.post("/auth/backchannel-logout", async (c) => { const form = await c.req.parseBody(); const result = await logout.verify(String(form.logout_token ?? "")); if (!result.ok) { return c.body(null, 400); } await endSessionsFor(result.subject); return c.body(null, 200); }); ``` A notice that cannot be delivered is retried over about two and a half hours, then given up on; the application's row in the dashboard says how many were delivered and why the last one failed. ### Signing algorithms [#signing-algorithms] Tokens are signed **RS256** unless a client asks for **ES256**, per client — an application that never said is assumed to expect RS256, as OIDC requires. Both keys are published in the environment's JWKS. **Rotate keys** on the Applications page replaces every key in use; the old ones stay published for a week so nothing already issued stops verifying. ## Second factors [#second-factors] A user can enrol an **authenticator app** (TOTP) or a **code to their inbox**. Both are offered by ``, and both produce single-use recovery codes shown exactly once. Where a second factor is enforced is worth knowing precisely: | Signing in with | Second factor asked for | | --------------- | ----------------------- | | Password | Yes | | Magic link | Yes | | Social sign-in | Yes | | Passkey | **No** | | Enterprise SSO | **No** | The two exceptions are deliberate. A **passkey** is already possession of a registered authenticator, usually behind a biometric, and cannot be phished — a code on top buys little and costs enough friction that people stop using the stronger thing. **Enterprise SSO** exists so the identity provider is the only door, and MFA policy belongs to the enterprise that bought it. See [Second factors](/guides/second-factors) for enrolment and recovery. ## What a person can ask for about themselves [#what-a-person-can-ask-for-about-themselves] Under GDPR — and as a matter of course for anyone else — a user may ask for a copy of what you hold, and ask to be forgotten. Both have endpoints, and both take the user's own session so your server never has to be trusted with somebody else's account. See [Privacy requests](/guides/privacy-requests). # Billing ## What an invoice is made of [#what-an-invoice-is-made-of] ``` subtotal the plan fee, usage past the allowance, addons, one-off charges − discountTotal a coupon, if one applies + taxTotal applicable rates = total ``` and separately: ``` amountDue = total − amountPaid − creditedTotal ``` `amountDue` is derived, never stored, so it cannot drift out of step with the payments and credit notes it is computed from. ### How a usage line was worked out [#how-a-usage-line-was-worked-out] Every usage line records what it was rated against: the plan's included allowance (scaled by quantity for a per-unit allowance) and the prepaid credits that covered usage past it. The invoice page in the dashboard writes the working out with the line's own numbers: ``` 1,500 used − (1,000 included + 200 credits) = 300 billable ``` That is the rule to quote when a bill is disputed: only usage past the allowance and credits is billed, so nothing `check` reported as inside the allowance is charged. An unlimited entitlement records no allowance and bills nothing. Lines rated before lines began recording these stored only the usage and the billable quantity, so for those the page shows what the allowance and credits covered together (`used − billable`) rather than splitting it from a plan that may have changed since. ### Addons with included units [#addons-with-included-units] A `PER_UNIT` addon can include units before it charges: `includedQuantity: 10` bills `max(0, quantity − 10) × amount`, so "10 clients included, then $12 each" is one addon. The subscription carries the real quantity; the invoice line shows it with what was included and what was billed (`11 − 10 included = 1 billed`). `FLAT` addons ignore quantity and cannot include units. The managed tenants guide covers the per-client case. ### Overage: soft limits and hard caps [#overage-soft-limits-and-hard-caps] A metered feature with a usage price on the plan version (`PER_UNIT`, `MATRIX`, a tiered model or `PACKAGE` carrying the feature's id) has a **soft limit**: `check` keeps allowing usage past `limit + credits`, says so with `usage.overage: true`, and the billing run charges that usage at the price. With such a price, `limit: 0` bills every unit. A feature with no usage price — or only a `FLAT` one, which does not charge per unit — keeps a **hard limit**: `check` refuses at `limit + credits` with `USAGE_LIMIT_EXCEEDED`, exactly as before. An entitlement can also carry a **hard cap** (`hardCap`), the ceiling on what a customer can run up. `check` refuses once usage would pass it, with `HARD_CAP_REACHED`. It is scaled by quantity like the limit when `limitPerUnit` is set, and credits never raise it. It needs a numeric limit and cannot be below it. The cap governs `check`, not ingestion: an event your application records past the cap is still metered and billed, like any other usage. Base316 cannot know whether the work behind an event happened, and refusing to record work that did would under-bill silently. ### Per-model pricing (MATRIX) [#per-model-pricing-matrix] When the same feature costs a different amount depending on what the event carries — tokens from a large model cost more than tokens from a small one, a request in one region more than in another — price it with a **`MATRIX`** price: a unit price per value of one or more of the meter's `groupBy` dimensions. You keep one feature, one meter and one allowance, rather than a feature per model kept in step by hand. ```json { "model": "MATRIX", "featureId": "feat_llm_tokens", "amount": "0.000002", "rows": [ { "dimensions": { "model": "opus" }, "unitAmount": "0.000015" }, { "dimensions": { "model": "sonnet" }, "unitAmount": "0.000003" }, { "dimensions": { "model": "haiku" }, "unitAmount": "0.000001" } ] } ``` The rules: * **Rows name the meter's dimensions.** Every key in a row's `dimensions` must be in the meter's `groupBy`, and every row names the same keys — so an event matches at most one row, whatever order the rows are in. A row may name several keys (`{ "model": "opus", "region": "eu" }`). Values are compared as the text the event carries. * **`amount` is the default row.** Usage whose values no row names is charged at it, on one line called "other values". **Leave it out** and that usage is *unpriced*: it is listed on the invoice — quantity, billable units, the values it carried — with `unpriced: true` and an amount of zero, flagged on the invoice page, the PDF, the upcoming-invoice preview and the customer's entitlement panel. It is never silently free. When new models can appear without warning, set a default. * **Per-unit rows.** Each row is a unit price with at most eight decimal places (a price per million tokens divided by a million: $3 per million is `0.000003`). Tiers or package sizes per row are not supported: a row's price would then depend on how much of it the allowance covered, and the allowance rule below could no longer promise the lowest bill. * **SUM or COUNT meters only.** The allowance is checked by `check` against the feature's total. Only for an additive aggregation do the rows add up to that total, so only then can the allowance be split across rows without billing anything `check` called included. A `MAX`, `MIN`, `LAST` or `UNIQUE_COUNT` meter is refused. * **The allowance covers the dearest usage first.** The plan's included allowance (`limit`), then prepaid credits, are applied to the rows in descending unit price — table order breaking a tie, unpriced usage last. Each unit is billed or covered exactly once, so the billable units across the lines equal `used − (limit + credits)` — exactly what `check` reports as `billableOverage` — and no other split would charge the customer less. `limit: null` is unlimited and bills nothing; `limit: 0` bills every unit. * **A matrix makes the limit soft**, like any usage price. * **One line per row with usage**, each rounded once, to the currency (`roundToCurrency`), so ¥ and KWD come out in whole yen and fils. Each line carries its `dimensions` (`{}` for the default row) and its own share of the allowance and credits, so the working on the invoice page reads per row: `quantity − (allowance + credits) = billable`. A table that cannot be rated is refused when the plan or version is written, with `error.param` naming the row: `prices[0].rows[2].dimensions.tier`. #### Worked example: AI tokens [#worked-example-ai-tokens] A meter `llm_tokens` sums `tokens` from `llm.completion` events and groups by `model`. The Pro plan includes 1,500,000 tokens a month and prices the matrix above (opus $0.000015, sonnet $0.000003, haiku $0.000001 a token, anything else $0.000002). In one period a customer sends: | model | tokens | | --------- | --------- | | opus | 1,000,000 | | sonnet | 1,000,000 | | haiku | 1,000,000 | | llama-405 | 500,000 | The 1,500,000 included tokens cover the dearest first: all of opus, then half of sonnet. The invoice has four usage lines: | line | used | included | billable | unit | amount | | ------------------------ | --------- | --------- | --------- | -------- | ------ | | LLM tokens — model: opus | 1,000,000 | 1,000,000 | 0 | 0.000015 | $0.00 | | … model: sonnet | 1,000,000 | 500,000 | 500,000 | 0.000003 | $1.50 | | … model: haiku | 1,000,000 | 0 | 1,000,000 | 0.000001 | $1.00 | | … other values | 500,000 | 0 | 500,000 | 0.000002 | $1.00 | 2,000,000 billable tokens, which is `3,500,000 used − 1,500,000 included` — the same number `check` reported — for $3.50. Without the default row the llama line would read *unpriced*, with 500,000 billable and $0.00, and the total would be $2.50. Send the dimension on every event: ```ts await base316.events.record({ customerId, event: "llm.completion", idempotencyKey: completion.id, payload: { tokens: usage.output_tokens, model: "opus" }, }); ``` ## The invoice page [#the-invoice-page] **Billing → Invoices & payments** lists every invoice, filterable by status — including *Overdue*, payable invoices past their due date — and searchable by invoice number or the customer's name, email or your own id. The filter lives in the URL, so a filtered list can be shared. Each number opens the invoice: header and amounts, the lines with their working, discounts, tax, wallet applied, payments (method, reference, and any reversal or declined attempt), credit notes against it, and a timeline of what happened — created, finalized, emails sent about it, pay links, payment attempts, credit notes, write-off, grace period end, paid or voided — built from the invoice's own records and the audit log. Actions that do not apply stay visible and say why: only a draft can be finalized, and a paid invoice cannot be voided (issue a credit note instead). ## Fee in advance, usage in arrears [#fee-in-advance-usage-in-arrears] The recurring fee is billed **in advance** — it pays for access about to begin. Usage is billed **in arrears**, because it can only be rated once it has happened. So a renewal invoice carries two things: the usage for the period that just closed, and the fee for the one opening. ## The upcoming invoice [#the-upcoming-invoice] `GET /v1/subscriptions/:id/upcoming-invoice` answers "what will this subscription's next invoice be?" It runs the billing run's own arithmetic — the same draft, the same discount and tax, the same credit and wallet rules — and writes nothing: no invoice row, no invoice number, no charge marked billed, no credit or discount period spent, no wallet movement and no webhook. Call it as often as a screen refreshes; asking twice gives the same answer. ```ts const next = await base316.subscriptions.upcomingInvoice("sub_..."); // next.issuesAt, next.lines, next.total, next.amountDue ``` Which invoice it previews follows what the billing run will actually do next: | `reason` | When | What is on it | | ----------- | ---------------------------------------- | ----------------------------------------------------------- | | `RENEWAL` | At the period end | Usage for the current period, plus the fee for the next one | | `FINAL` | At the period end, when cancelling there | Usage only — no further period opens, so no fee is charged | | `TRIAL_END` | When a trial converts | The first paid period's fee; usage during the trial is free | A reverse trial whose payer has no card raises nothing when the trial ends, so its preview is the downgrade plan's first renewal, with `planVersionId` naming that plan. * **Usage so far is included**, and `generatedAt` says when it was read. Usage between then and `issuesAt` still adds to the bill. * **One-off addons and pending charges are included.** They wait for the next invoice, so a "next bill" without them would under-report it. Previewing never consumes them; only raising the invoice does. * **Discount, then tax, then wallet**, exactly as at finalization: `subtotal − discountTotal + taxTotal = total`, and `amountDue = total − walletApplied`. `creditsApplied` lists the prepaid credit the usage lines will draw, per grant. * **`willBeIssued: false`** means the invoice would have no lines. The billing run raises nothing then — a free plan does not collect zero-value bills. * A canceled, paused or incomplete subscription answers `NOT_FOUND`: nothing will bill it again. For the signed-in end user's own bill, `GET /v1/me/subscription/upcoming-invoice` takes the publishable key and their session, and `` in `@base316/react` renders it. A managed tenant's members are refused with `billed_by_parent`: the firm's invoice lists every client's usage. ## Plan versions: draft, then published [#plan-versions-draft-then-published] A subscription points at the plan version it was sold against, so a published version never changes — repricing is a new version, and existing subscribers keep the terms they agreed to. A new version can start as a **draft** instead: | Status | Terms | Can be subscribed to | Deleted | | ----------- | ----------------- | -------------------- | ---------------- | | `DRAFT` | Editable in place | No | Yes — discard it | | `PUBLISHED` | Immutable | Yes | Never | | `ARCHIVED` | Immutable | No | Never | * **Nothing sells a draft.** Subscribing, checkout, quotes, plan changes, scheduled phases and ramps, the public catalog and ``, the CSV import, a trial's downgrade target and promotion to Production all take published versions only. * **One draft per plan.** Starting a second is a `409` naming the open one: edit it, publish it or discard it first. * **Publishing freezes it.** From then on it is exactly as immutable as a version created published. Publishing is idempotent. If a published version was added above the draft while it was open, the draft takes the next number, so versions are numbered in the order they went on sale. * **A hard cap rides along.** Sending `entitlements` to a draft edit replaces the list, but an entry that leaves `hardCap` out keeps the draft's current cap for that feature; `hardCap: null` removes it. Caps are checked as at creation: a cap needs a numeric `limit` and cannot be below it (`INVALID_REQUEST`, `param: entitlements[n].hardCap`). * **Leaving `publish` out publishes**, as it always has: `createPlan`, `createVersion` and `POST /v1/plans/:id/versions` only make a draft when asked with `publish: false`. ```ts const { planVersionId } = await base316.catalog.plans.createVersion(plan.id, { currency: "USD", interval: "MONTH", prices: [{ model: "FLAT", amount: "29.00" }], publish: false, }); // Change it as often as you like — nobody is on it yet. await base316.catalog.plans.updateDraft(plan.id, planVersionId, { prices: [{ model: "FLAT", amount: "25.00" }], trialDays: 14, }); await base316.catalog.plans.publishVersion(plan.id, planVersionId); // or: await base316.catalog.plans.deleteDraft(plan.id, planVersionId); ``` Every version in `GET /v1/plans` and `GET /v1/plans/:id` carries `subscriberCount`: its `TRIALING`, `ACTIVE` and `PAST_DUE` subscriptions. Canceled and paused ones are not counted, so the number is who would be affected if you stopped selling that version — not everyone who ever bought it. In the dashboard, **Catalog → Plans** lists each plan's versions with their status and subscriber count; **Edit** on a plan starts a draft (or reopens the open one), and the draft's row has **Publish** and **Discard draft**. ### Duplicating a plan [#duplicating-a-plan] To make a variant of a plan — a yearly price, a bigger limit, another trial — use **Duplicate** in the plan's menu under **Catalog → Plans**. It asks only for a new key and name, then creates a new plan whose version 1 is a **draft** holding everything the source's current version says: prices (including tiers, packages and per-model tables), entitlements with their caps and reset rules, the trial and what it ends in, the interval, and the API rate limits a subscriber gets. The description, product, tax code and propagation setting come across too. The copy opens in the plan editor straight away. Nothing is for sale until you publish it, so the two plans never sell identical terms by accident. Subscribers, the source's older versions and its migration history are not copied: the new plan starts at version 1 with nobody on it. The source is the latest *published* version, or the draft for a plan that has never been published. ## Changing plan: now, or at the period end [#changing-plan-now-or-at-the-period-end] A plan change happens **now** or **at the period end**, and the preview says what each would do before anything is changed: ```ts const preview = await base316.subscriptions.previewPlanChange("sub_...", { planVersionId: "pv_basic", }); // preview.direction "DOWNGRADE" // preview.defaultTiming "PERIOD_END" // preview.entitlements what is GAINED, LOST, INCREASED or DECREASED, limits before and after // preview.now credit for the unused fee, usage so far, the new plan's first charge, net // preview.atPeriodEnd when it takes effect, and the renewal invoice on the new plan await base316.subscriptions.changePlan("sub_...", { planVersionId: "pv_basic" }); ``` | Timing | What happens | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `NOW` | The subscription is closed and a replacement, with a **new id**, opens on the new plan. The unused share of the fee and addons already paid is credited, usage so far is invoiced on the old plan, and the new plan's first period is invoiced in advance. | | `PERIOD_END` | Nothing changes until the renewal. The **same subscription** then renews onto the new plan: the closing period's usage is rated on the old plan's allowances, the new period's fee on the new plan. | * **Without a `timing`, a downgrade waits and anything else happens now.** Direction is decided by the recurring fee per day at the subscription's quantity, so monthly and yearly plans compare fairly. During a trial a change always happens now — it costs nothing — and "at period end" is refused. * **One schedule at a time.** A new change, now or later, replaces whatever was scheduled. `DELETE /v1/subscriptions/:id/phases` withdraws it. * **A cancellation and a scheduled change never stand together.** Cancelling withdraws the schedule — leaving is always allowed — and scheduling is refused with `409` while a cancellation is pending: resume first (`POST /v1/subscriptions/:id/resume`). * **Same currency.** A scheduled change is billed on the same renewal invoice as the period before it, so the new plan must share the currency. Change now to move currency. * Only the fee and addons are prorated, never usage. End users can do the same for themselves: `POST /v1/me/subscription/change` takes the same optional `timing` (a downgrade is scheduled by default and needs no card), `GET /v1/me/subscription/plan-change-preview` previews it, and `DELETE /v1/me/subscription/scheduled-change` undoes it. `GET /v1/me/subscription` returns the pending `schedule`. A managed tenant's members are refused with `billed_by_parent`. ## Ramps [#ramps] A ramp is a schedule of phases: $500 a month for six months, then $800. Each phase names a plan version and the date it starts from, and takes effect at the **first renewal on or after that date** — never mid-period. ```ts await base316.subscriptions.setPhases("sub_...", [ { planVersionId: "pv_growth_800", startsAt: "2027-04-01" }, { planVersionId: "pv_scale_1200", startsAt: "2027-10-01" }, ]); ``` A period-end plan change is a ramp of one phase; the two share the machinery and the rules above. Setting phases replaces the pending schedule, and an empty list clears it. Dates must be in the future and increasing, consecutive phases must change the plan, and two phases that would land on the same renewal are refused because only one could ever apply. A move to a yearly plan changes where every later renewal falls, and `effectiveAt` on each phase accounts for it. A phase set during a trial counts from the first renewal after the trial converts. ## Changing the quantity [#changing-the-quantity] A subscription's `quantity` is what a per-unit price multiplies — seats, devices, agents. Changing it mid-period is a question of money, so preview it first: ```ts const quote = await base316.subscriptions.previewQuantity("sub_...", { quantity: 12 }); // quote.proratedAmount, quote.taxTotal, quote.total, quote.newRecurringAmount, quote.effectiveAt const changed = await base316.subscriptions.setQuantity( "sub_...", { quantity: 12 }, { idempotencyKey: "seats-acme-12" }, ); // changed.quantityChange.invoiceId ``` The preview is the change's own planning step with the writes left out, so the figure it shows is the figure the invoice is raised for. | Direction | Default | `proration` | `effective` | | --------- | ---------------------------------------- | ----------------------------------------------- | ------------------------------------- | | Increase | Now, invoiced for the rest of the period | `invoice_now` (default), `next_invoice`, `none` | `immediately` (default), `period_end` | | Decrease | At the period end, nothing credited | Refused — a decrease is not credited | `period_end` (default), `immediately` | * **Only the recurring per-unit fee is prorated.** Usage never is — it is measured over the real window — and a FLAT fee does not depend on the count. The plan is rated at the new count and at the old one and the difference is scaled by the fraction of the period left, so volume tiers stay honest. * **The fraction** is the time left over the interval the period started on, clamped to \[0, 1], and each amount is rounded once in the currency's own scale: 2 seats at $10 with 11 of 31 days left is $7.10; 3 at ¥1,000 is ¥1,065. * **`invoice_now`** raises an invoice at once, finalizes it with tax (but not the subscription's standing discount, which is spent on the period's own invoice), applies the wallet and attempts collection — an account on terms is invoiced, never charged. A decline leaves it open for dunning; the seats are not taken back. **`next_invoice`** adds the amount to the renewal as a pending charge. **`none`** charges nothing until the renewal bills the new count in full. * **A decrease waits for the period end** because the period was paid for at the current count, and shrinking it would also shrink a per-unit allowance already bought. It shows as `scheduledQuantity`; the renewal bills the next period at it, and the upcoming invoice already does. Asking for the current count again cancels it. * **The plan's seat limit is enforced first.** If the plan grants the environment's seat feature (`seats` unless you changed it in the authentication policy) with a limit, a larger count is refused before anything is charged. Credits extend the limit, as they do for `check()`. * **Retrying is safe.** The request names the target count, so a repeat finds nothing to change; two changes racing from the same count cannot both charge; and `Idempotency-Key` replays the first response. A signed-in end user changes their own count with `GET /v1/me/subscription/quantity/preview` and `POST /v1/me/subscription/quantity`, or `` from `@base316/react`. They choose only the number — the defaults always apply, because letting a browser choose `none` would make seats free. A managed tenant's members are refused with `billed_by_parent`. ## Custom prices for one subscription [#custom-prices-for-one-subscription] Negotiated pricing — "400 a month instead of 500", "0.002 a call instead of 0.003" — goes on the subscription, not into a new plan. The plan version is left exactly as every other subscriber has it; plan versions never change once published. ``` POST /v1/subscriptions/{id}/price-overrides { "priceId": "price_…", "model": "FLAT", "amount": "400.00", "note": "MSA 2026-14" } ``` A custom price has the same shape as a plan price — `model`, `amount`, `packageSize`, `tiers`, all decimal strings — in the subscription's currency, and targets either: * **`priceId`**: one price on the version the subscription is on — the recurring fee or a usage price; or * **`featureId`**: that feature's usage price on whatever version the subscription is on. Use this when the custom rate should outlive a plan change. **When it applies.** To an invoice line when the custom price is in effect at the start of the window that line bills: the period a recurring fee buys, the period usage was measured over, the rest of the period a seat increase is prorated for. It never splits a window, so `startsAt` in the middle of a period first applies to the next one. With no `startsAt` it applies from the start — including the current period's usage, which has not been invoiced yet. `endsAt` stops it the same way. **Every bill and every preview agrees.** The renewal, a subscription's first invoice, the upcoming invoice, both halves of the plan-change preview and a seat proration all resolve the price through one function, so a preview cannot show a different rate from the bill. The upcoming invoice marks each line rated on a custom price with `priceOverrideId`, and so does the issued invoice line. **A plan change ends it**, unless the new version still has the price it overrides. A price id belongs to one version, so a price-targeted custom price always ends; a feature-targeted one carries across when the new version prices the feature. It ends at the moment of the change — the closing period is still billed on it — and the plan-change preview lists what would end in `customPricesEnding`, which the dashboard shows as a warning before anyone confirms. * **A MATRIX price cannot be customised** (`400`, `MATRIX_NOT_SUPPORTED`), by `priceId` or by `featureId`, and a custom price cannot itself be a `MATRIX`. A matrix's per-dimension rates share one allowance, and a single custom rate cannot stand in for that table; publish a plan version with the negotiated rows instead. A fee override on a plan with a matrix usage price works as usual — the matrix lines are rated on their own rows. A feature-targeted custom price ends at a plan change onto a version that prices the feature by MATRIX, and the plan-change preview lists it. * Two custom prices for the same price cannot overlap in time (`409`). * Terms that cannot rate are refused: a tier table needs exactly one open-ended band, a `PACKAGE` needs `packageSize`. * A coupon on the subscription still applies on top: the custom price changes what a line costs; the coupon takes a share off the invoice. * Removing one (`DELETE /v1/subscriptions/{id}/price-overrides/{overrideId}`) returns every line not yet invoiced to the plan price. Issued invoices are untouched. On the dashboard, the subscription page's **Custom pricing** section lists each plan price with any custom price beside it, and adds, edits and removes them; the **Next invoice** panel beside it updates as you save. ## Contract terms: PO number, memo and billing day [#contract-terms-po-number-memo-and-billing-day] ``` PATCH /v1/subscriptions/{id}/contract { "purchaseOrderNumber": "PO-1001", "invoiceMemo": "Pay by wire to …", "billingAnchorDay": 1 } ``` Each field is optional; `null` (or an empty string) clears it. All three can also be set when the subscription is created (`POST /v1/subscriptions`). **PO number and memo** are printed on every invoice the subscription raises — renewals, the first invoice, seat prorations — and on its PDF: the PO among the invoice details, the memo after the totals. They are copied onto the invoice when it is generated (`purchaseOrderNumber`, `memo`), so changing them later never rewrites a document already sent. **Billing day** (`billingAnchorDay`, 1–31) fixes the day of the month every period ends on. A month shorter than the day ends on its last day — an anchor of 31 bills on 28 February and on 31 March, never drifting to the 28th. Only monthly and yearly plans can be anchored; a daily or weekly one is refused. **The stub rule.** Changing the billing day never touches the period already paid for. That period runs to its end as agreed; the period that opens there runs only to the next billing day — the stub — and the renewal bills its recurring fee prorated. The fraction is the stub's share of the full anchored period it ends: a subscription renewing on 10 February that moves to the 1st has a stub to 1 March, 19 of February's 28 days, so a 100.00 fee bills 67.86, ¥10,000 bills ¥6,786 and KWD 30.000 bills KWD 20.357, each rounded once in the currency's own scale. Only the recurring fee is prorated; usage is billed for the window it was measured over. Every period after the stub is a full one. A subscription created with a billing day starts with the same stub: from its start to the next billing day, prorated. An immediate plan change keeps the billing day, so the first period on the new plan runs to it as a stub too. Preview first — `GET /v1/subscriptions/{id}/contract/billing-anchor-preview?billingAnchorDay=1` returns the stub (`nextPeriod`, `stub`, `prorationFactor`), its fee and a full period's (`nextPeriodFee`, `fullPeriodFee`) and the renewal invoice it lands on, from the same function the renewal bills with. The dashboard's **Contract** section shows that preview as soon as a different day is picked, before saving. ## Trials, and the reverse trial [#trials-and-the-reverse-trial] A plan version's `trialDays` opens every new subscription to it as `TRIALING`. Nothing is invoiced during a trial. When it runs out, the version's **trial-end action** decides what happens: | `trialEndAction` | No payment method on file | A card on file | | ------------------- | -------------------------------------------------------------------------- | -------------------------------- | | `CONVERT` (default) | ACTIVE on this plan, first period billed (and chased by dunning if unpaid) | Same, and charged | | `DOWNGRADE` | Moved to `trialDowngradePlanVersionId`, ACTIVE, **no invoice** | Converts and bills, as `CONVERT` | **A trial cancelled before it ends simply ends.** Whatever the trial-end action, a `TRIALING` subscription with a cancellation scheduled becomes `CANCELED` when the trial runs out: no conversion, no downgrade and no invoice. `subscription.canceled` arrives with `reason: "trial_end"`, and the upcoming invoice for it is `NOT_FOUND`. `DOWNGRADE` is the reverse trial: every signup gets the paid plan for two weeks without a card, and whoever has not added one by the end lands on Free rather than on an invoice that can only go past due. The subscription keeps its id; `subscription.updated` arrives with `change: "trial_downgraded"`. ```ts const free = await base316.catalog.plans.currentVersion("free"); await base316.catalog.plans.create({ key: "team", name: "Team", currency: "USD", interval: "MONTH", trialDays: 14, trialEndAction: "DOWNGRADE", trialDowngradePlanVersionId: free!.id, prices: [{ model: "FLAT", amount: "49.00" }], }); ``` The downgrade target must be a **published** version of **another** plan in the same environment, in the same product (when the plan belongs to one) and the same currency. Plan versions are immutable, so the setting is part of each version: publish a new version to change it. "A card on file" means the **payer** has a default payment method that has not expired — for a managed tenant, that is its parent's account. An account invoiced on terms (auto-collection off) never needs a card, so it converts. The downgrade plan bills from its first renewal like any other plan; pick a free version unless you mean the first period after a trial to be free. ### What the pricing pages say [#what-the-pricing-pages-say] `` from `@base316/react` and the hosted checkout page both read the published version's `trialEndAction` and say what the trial turns into: | Where | `CONVERT` | `DOWNGRADE` (downgrading to Free) | | --------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `PricingTable` | 14-day free trial | 14-day free trial, then Free unless you add a card | | Hosted checkout | 14-day free trial. You will not be charged today. | 14-day free trial. You will not be charged today. When it ends, Team is billed to the card on file; with no card on file you move to Free instead. | `PricingTable` names the target when it is among the published plans it shows; when it is not, it says "no card needed. Add one to keep Team" instead. A pricing page of your own can read the same fields from `GET /v1/public/plans` (`trialEndAction`, `trialDowngradePlanVersionId`). The CLI's `base316 plans` shows them as the **TRIAL**, **TRIAL END** and **DOWNGRADE TO** columns. ### Starting a trial without checkout [#starting-a-trial-without-checkout] Checkout is not the only way in. `POST /v1/subscriptions` opens a subscription to a version with a trial as `TRIALING`, with no card, and takes `trialDays` to override the version's own length — `0` for none, for a returning customer: ```ts await base316.subscriptions.create({ externalId: newOrg.id, planVersionId: team.id, trialDays: 14, }); ``` The dashboard's **New subscription** dialog takes the same two overrides: **Trial days** (blank uses the plan's, `0` means none, at most 365) and **Starts**, the start of the first period (blank is now). ### Charging the first period now [#charging-the-first-period-now] A paid subscription's first invoice is raised at once and charged by the hourly collection run. To charge it before the call returns — ending a trial and starting the paid plan while the customer watches — pass `collect: true`: ```ts const subscription = await base316.subscriptions.create( { customerId, planVersionId: pro.id, trialDays: 0, collect: true }, { idempotencyKey: `upgrade-${customerId}` }, ); if (subscription.latestInvoice?.status !== "PAID") { // No card on file, or it was declined: ask for one. } ``` It is the collection run's own charge against the customer's default card, so a missing card leaves the invoice `OPEN`, and a decline makes the subscription `PAST_DUE`, sends `payment.failed` and starts dunning — the call itself still succeeds. `collect` is ignored when the subscription starts `TRIALING`. With an idempotency key a retried call returns the first response and charges nothing. ## Draft, then open [#draft-then-open] `generateInvoice` stops at `DRAFT` so a mistaken invoice can be discarded without burning a number. Finalizing makes it `OPEN` — owed, with a due date, and its line items stop changing. Invoice numbers are gapless within an environment. A voided invoice keeps its number rather than releasing it: a missing number is indistinguishable from a hidden one during an audit. ## Collection, and accounts on terms [#collection-and-accounts-on-terms] By default Base316 charges a stored card automatically. Set **auto-collection off** for an account that pays on terms — wire, cheque, purchase order. Nothing will attempt a card it was never given, and dunning will not chase it. You record the payment when it arrives. **Payment terms** are per customer. An account on net-30 gets a due date thirty days out; an account with none gets the default of fourteen. This matters because the accounts-receivable ageing buckets by due date, and treating a net-30 account as net-14 puts it in a collections queue two weeks early. ## Recording a payment that arrived outside Stripe [#recording-a-payment-that-arrived-outside-stripe] ``` POST /v1/invoices/:id/payments { "amount": "500.00", "method": "BANK_TRANSFER", "reference": "WIRE-9912" } ``` * **Part payments work.** $500 against a $2,000 invoice leaves it open for the rest. * **`receivedAt` is when the money cleared**, not when you typed it in. The ageing reflects the cleared date. * **Overpayment is refused**, with the amount actually owed. Base316 cannot know whether the surplus is a duplicate wire or the next invoice paid early, and swallowing it would lose money that belongs to your customer. * **A bounced cheque is reversed, not deleted.** The invoice reopens and both the receipt and its reversal stay in the ledger. The dashboard's **Payments** ledger, under Billing → Invoices & payments, lists every payment with its status: **Received** (the only one that counts towards what an invoice is paid), **Pending** (not yet sent to Stripe), **Processing** (accepted by Stripe and still settling), **Failed** (a card charge that did not go through, with Stripe's code) and **Reversed** (an offline payment backed out). ### Payment links [#payment-links] **Take card** on an open invoice mints a link and QR code for the customer to pay on their phone. The dialog also lists every link minted for that invoice — **Active**, **Expired**, **Voided**, or **Invoice settled** once nothing is owed — and **Void** withdraws one. Only the link just minted can be copied: a link's address is stored as a digest, so it cannot be shown again. Over the API, the same list and withdrawal are `GET /v1/invoices/:id/payment-links` and `DELETE /v1/invoices/:id/payment-links/:linkId`. ## Wallet reasons [#wallet-reasons] Every wallet movement carries a reason. A top-up made by hand is a **Purchase** (the default), **Promotional**, a **Refund** back to balance, or an **Adjustment**; a manual debit is an **Adjustment** (the default) or an **Expiry**. **Invoice payment** is written only when finalization draws the wallet down, and cannot be entered by hand. The customer screen shows recent movements with their reasons; over the API, pass `reason` to `POST /v1/wallets/credit` or `/debit`. ## When a card fails [#when-a-card-fails] Dunning retries on a schedule and gives up rather than retrying for ever. It skips accounts with auto-collection off — chasing a card a B2B customer never gave is noise, and pausing a subscription that is being paid perfectly well by wire is worse. What the customer experiences, with the defaults: | When | Subscription | Customer is emailed | | -------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------- | | A card is declined | `PAST_DUE` — still entitled | "Your payment didn't go through", with the next retry date and a pay link | | Retries on days 1, 3 and 7 | `PAST_DUE` | the same, after each decline | | Retries are exhausted | `PAST_DUE` for a **7-day grace period** | "Action needed", with the date access ends | | Grace ends unpaid | `PAUSED` (or your end action), invoice `UNCOLLECTIBLE` | "Your subscription is paused", with a pay link | A hard decline — a stolen card, a closed account — skips the remaining retries and goes straight to the grace period. **Paying at any point ends it all:** a payment during retries or grace returns the subscription to `ACTIVE`, and so does paying the written-off invoice after a pause, which also starts a fresh period rather than billing the months without access. A cancellation is final. Every part is set per environment under **Settings → Payments & failed payments → Failed payments**, or with `base316.dunning.update()`: the retry days, the grace period (0 acts at once), the end action (pause, cancel, or write off and keep the subscription), and whether Base316 sends the emails. The emails need a **verified sending domain** — Base316 does not send billing mail about your customer's bill from base316.com. A template of your own for `payment.failed` or `invoice.uncollectible` replaces the matching default, and those templates now reach the customer: billing events name a customer rather than an address, and the address is looked up. Advancing a sandbox's test clock past a grace period applies its end action, so the whole sequence can be rehearsed. ## Coupons and tax rates [#coupons-and-tax-rates] A coupon is `PERCENTAGE` (`percentOff`, at most 100) or `FIXED` (`amountOff` **and** `currency`). A fixed amount only ever comes off an invoice in its own currency: "10 off" is a different sum of money in dollars, yen and dinar, so a fixed coupon without a currency is refused when it is created. Send amounts as decimal strings (`"12.50"`). `duration` says how many of a subscription's invoices a standing discount comes off: `ONCE`, `REPEATING` with `durationInPeriods` (counted in invoices, so a paused month does not use one up), or `FOREVER`. `redeemBy` is the last moment it can be taken up, and `maxRedemptions` counts customers, not invoices. Neither a coupon nor a tax rate is ever deleted, because invoices refer to them. `DELETE /v1/coupons/:id` and `DELETE /v1/tax-rates/:id` **archive**: * An archived coupon cannot be redeemed on an invoice, a quote or a subscription. A subscription that already carries it keeps the discount it was promised — those terms were copied onto the subscription when it was applied. Remove it from a subscription with `DELETE /v1/subscriptions/:id/discounts` to stop it there. * An archived tax rate is not charged on anything finalized afterwards. Issued invoices copied the rate onto themselves and are unchanged. ## Test clocks [#test-clocks] A sandbox can hold one **test clock**: a time the whole environment believes it is. Advancing it runs the billing sweeps at the new time — trial ends, renewals, collection, dunning grace periods and quote expiry — and reports how many of each happened, so "does this renew?" takes one call instead of a month. * **Sandbox only.** Production refuses a clock: advancing it would renew every subscription and charge real cards. * **Forward only**, and at most a year per advance. * **Deleting a clock keeps what it produced.** Invoices dated in the future stay; the environment goes back to real time. In the dashboard it is **Billing → Test clock**, shown on Sandbox only: create a clock, advance it by a day, a week, a month or to a date you choose — or straight to the next renewal or trial end listed under **Coming up** — and read what the advance did. Over the API it is `/v1/test-clocks` (`GET`, `POST`, `POST /advance`, `DELETE`), secret key only. ## Credit notes [#credit-notes] A credit note reduces what is owed without deleting anything. Total credits can never exceed the invoice total. Credit is not a payment: `amountPaid` is cash received, and an invoice half written off records only the cash that actually arrived. ### Refunds [#refunds] A refund is always issued through a credit note — there is no separate refund action, so every refund has the document that says why the money went back. A credit note settles in up to three parts: ``` total = what it takes off the amount still owed min(total, amountDue) + what it refunds to the original payments the settlement's refund + what it credits to the customer's wallet the rest ``` Only money already received can be refunded or credited, so on an unpaid invoice a credit note only reduces what is owed. On a paid one, `settlement` decides where the money goes: | `settlement` | Money already received goes | | ------------------ | ----------------------------------------------------------- | | `CREDIT` (default) | to the customer's wallet, applied to their next invoice | | `REFUND` | back to the payments that paid the invoice | | `SPLIT` | `refundAmount` back to the payments, the rest to the wallet | ```ts const note = await base316.creditNotes.create( { invoiceId: invoice.id, reason: "PRODUCT_UNSATISFACTORY", lines: [{ description: "Cancelled seats", amount: "100.00" }], settlement: "SPLIT", refundAmount: "40.00", }, { idempotencyKey: `refund-${ticketId}` }, ); // note.refundAmount "40", note.walletAmount "60", note.refunds[0].status "PENDING" ``` * **A card is refunded through Stripe**, on your connected account — you are the merchant of record, so the money comes out of your Stripe balance. The refund is `PENDING` until Stripe settles it and then `SUCCEEDED` or `FAILED`; `refund.succeeded` and `refund.failed` announce which. Base316's application fee on the charge is refunded pro rata with it. * **Cash, cheques and transfers are refunded by recording it.** Base316 never moved that money and cannot send it back; issuing the note records that you did, with `refundReference` and `refundMethod`. It is `SUCCEEDED` at once. * **What can be refunded.** Succeeded payments on that invoice, in its currency, never more than each one paid less what is already refunded or pending. Cards go first, newest first, unless `paymentId` names one. A part paid from the wallet goes back to the wallet, not to a card. * **A retry never refunds twice.** Stripe is sent an idempotency key derived from the credit note. Send an `Idempotency-Key` on the request too, so a retried call replays the first credit note rather than issuing a second. * **A failed refund is never lost.** The credit note stays issued and the money is still owed. **Credit to wallet instead** on the invoice page (`POST /v1/refunds/:id/credit-to-wallet`) puts it in the customer's wallet; nothing does so on its own. * **A refunded invoice stays PAID.** `amountPaid` is what was received and is never reduced; `amountRefunded` beside it is what went back. * **A note that moved money cannot be voided** — a refund pending or made, or wallet balance the customer may already have spent. A note that only reduced an amount owed can be voided, and the invoice owes it again. On the dashboard, **Issue credit note** on the invoice page offers **Refund to card**, **Credit to wallet** or **Split**, with what each payment still has to refund. Each refund's status shows on its payment, its credit note and the timeline, and `GET /v1/refunds?invoiceId=…` lists them. The credit note PDF says "Refunded to card ending 4242", "(pending)" until Stripe settles it, or "Credited to account". ## PDF invoices, credit notes and quotes [#pdf-invoices-credit-notes-and-quotes] Every invoice, credit note and quote can be downloaded as a PDF. Base316 renders it on its own servers each time you ask, from the stored figures. A credit note or a payment recorded since the last download shows up on the next one. | Where | How | | -------------------------------- | --------------------------------------------------------------------------------------------- | | API (secret key) | `GET /v1/invoices/:id/pdf`, `GET /v1/credit-notes/:id/pdf`, `GET /v1/quotes/:id/pdf` | | SDK | `base316.invoices.pdf(id)`, `base316.creditNotes.pdf(id)`, `base316.quotes.pdf(id)` | | Your app, for the signed-in user | `GET /v1/me/invoices/:id/pdf` (browser SDK: `billing.invoicePdf(session, id)`) | | Hosted pay page | A **Download PDF** link under the same pay-link token as the page | | Emails | Attached to every template on an `invoice.*` event, and to the dunning notices | | Dashboard | **PDF** on each row of the invoice, credit note and quote lists, and on a customer's invoices | | CLI | `base316 invoice:pdf inv_… [file]` | ```ts import { writeFile } from "node:fs/promises"; const pdf = await base316.invoices.pdf(invoice.id); // Uint8Array await writeFile(`${invoice.number}.pdf`, pdf); ``` The response is `application/pdf`, with a `Content-Disposition` filename built from the document number (`invoice-INV-2026-000042.pdf`). Errors come back as the usual JSON error. An id from another environment returns `404`, the same as an id that does not exist. **What is on an invoice.** It lists your business name, logo, brand colour, support address and website, all taken from your hosted sign-in branding. If you set no support address, it uses the reply-to or from address of your verified sending domain. It then shows the customer's name, email, country and your id for them; the issue and due dates and the service period; and every line, with its quantity, unit price and amount. For a usage line it shows how much was used and how much of that was billable after the allowance. Below the lines come each discount, each tax rate, the total, any credit notes, wallet balance and payments applied, and the amount due. A draft is marked as a draft, so you can review it before you finalize. Amounts are formatted in the invoice's currency, to that currency's own number of decimal places: none for JPY and KRW, three for KWD and BHD. A unit price keeps the precision it was rated at, so a per-token price of `0.00000125` is printed as it is, not rounded to `0.00`. **The pay link.** Base316 stores only a digest of a pay link, never the link itself, so an API download cannot print one. A PDF downloaded from the hosted pay page, or attached to a dunning notice, includes that page's own link as **Pay online**. **Customer details.** Base316 does not store a postal address for a customer. The bill-to block shows what is stored: their name, email, country and region, and your id for them. The text uses an embedded Noto Sans font, so names like "Łódź" and "Dvořák" print correctly. Logos must be PNG or JPEG. ## Reporting [#reporting] **MRR** normalises every recurring fee to a month — an annual plan at 12,000 is 1,000, not 12,000 in whichever month somebody renewed. Trials are excluded and reported separately, `PAST_DUE` counts, usage is excluded entirely because it is consumption rather than recurring revenue, and currencies are never summed together. **Ageing** buckets open invoices by how overdue they are, net of part payments and credit notes. # Entitlements `access.check` is the call on your request path. It answers whether an account may do something, and how much room they have left. ```ts const decision = await base316.access.check({ externalId: "acct_1234", feature: "api_calls", requested: 1, }); ``` ```json { "allowed": true, "usage": { "used": 412, "limit": 1000, "credits": 0, "remaining": 588, "unit": "calls", "overagePriced": false, "overage": false, "billableOverage": 0, "hardCap": null } } ``` ## The rule [#the-rule] For a metered feature, the allowance is: ``` limit + credits ``` and only usage **beyond** that is billable. Prepaid credits extend the plan's limit rather than being spent first, which is why a customer topped up past their limit is not over quota. A limit declared **per unit** (`limitPerUnit` on the plan's entitlement) is multiplied by the subscription's quantity before it is compared: one hundred credits per seat on a five-seat subscription is a limit of five hundred. The check, the entitlement claims in a token and the invoice all scale it the same way, so an application is never refused for usage the invoice then includes. `requested` is how much you are about to consume. Passing it lets the check refuse *before* the work happens rather than after — which is the difference between declining a request and eating the cost of one. ## Soft limits and hard caps [#soft-limits-and-hard-caps] Whether the limit refuses depends on whether the plan version **prices** the feature's usage — a `PER_UNIT`, `MATRIX`, tiered or `PACKAGE` price carrying the feature's id: | The plan version… | Past `limit + credits`, `check`… | | -------------------------- | ------------------------------------------------------------------------------ | | has no usage price on it | refuses with `USAGE_LIMIT_EXCEEDED` — a **hard limit** | | prices the feature's usage | allows, with `usage.overage: true`, and the usage is billed — a **soft limit** | On a soft limit, `limit: 0` means every unit is billed; `limit: null` is still unlimited and bills nothing. A `FLAT` price on a feature does not charge per unit, so it leaves the limit hard. `QUANTITY` features are always hard: nothing bills a held count past its limit. `usage` says which case you are in: | Field | Meaning | | ----------------- | --------------------------------------------------------------------------------------- | | `overagePriced` | The limit is soft: usage past it is billed, not refused | | `overage` | This request takes usage past `limit + credits` — it will be billed | | `billableOverage` | Usage already past `limit + credits` this period, as the invoice will charge it | | `hardCap` | The ceiling past which `check` refuses with `HARD_CAP_REACHED`; null when there is none | | `remaining` | `limit + credits - used`; below zero on a soft limit is how far into overage | A feature priced per model (a `MATRIX` price — see [per-model pricing](/concepts/billing#per-model-pricing-matrix)) is still checked on its total: `used`, `remaining` and `billableOverage` are the feature's, across every model. `GET /v1/customers/{id}/entitlements` adds `byDimension` — this period's usage per row, rated as the next invoice will rate it, with `unpriced` marking values no row prices. A **hard cap** is set per entitlement on the plan (`hardCap`). It is an absolute ceiling on usage in the period: scaled by quantity like the limit when `limitPerUnit` is set, and **never raised by credits**. It needs a numeric limit and cannot be below it. On a hard limit it only matters if credits would carry usage past it. The cap governs `check`, not ingestion. An event recorded past it is still metered and billed — see [billing](/concepts/billing). Entitlement claims in a token (`entitlements` scope) carry each feature's limit. For a soft-limited feature they add `entitlements_overage`, feature key to hard cap (null for none): a key there means the number in `entitlements` is an allowance billed past, not a ceiling to enforce. ## Why it can refuse [#why-it-can-refuse] `reason` is present only when refused, and is safe to log. | Reason | What happened | | ----------------------- | ------------------------------------------------- | | `CUSTOMER_NOT_FOUND` | No customer with that id in this environment | | `NO_SUBSCRIPTION` | The account has no subscription | | `SUBSCRIPTION_INACTIVE` | Cancelled, paused or incomplete | | `FEATURE_NOT_FOUND` | No feature with that key | | `ENTITLEMENT_REQUIRED` | The plan does not include this feature | | `USAGE_LIMIT_EXCEEDED` | Past `limit + credits` on a hard limit | | `HARD_CAP_REACHED` | Past the entitlement's hard cap | | `USER_NOT_FOUND` | The acting user does not exist | | `USER_DISABLED` | The user exists but is disabled | | `NOT_A_MEMBER` | The user is not in the organisation being checked | | `PERMISSION_DENIED` | The user's role does not grant the permission | Show `remaining` in your own UI before somebody hits the wall. A refusal a customer could have seen coming is a support ticket. ## Every feature at once [#every-feature-at-once] `check` answers one feature. To see every feature for an account — the customer page's **Effective entitlements** panel, or a support tool of your own — ask for all of them: ```ts const { payerId, data } = await base316.customers.entitlements("cus_8f2k"); for (const feature of data) { console.log(feature.featureKey, feature.allowed, feature.reason, feature.remaining); } ``` `GET /v1/customers/:id/entitlements` (secret key) runs the same evaluator as `check`, once per live feature, with the default `requested` of 1 — so a `DENIED` here is the refusal your application would get, with the same `reason`. Each feature also says what its answer rests on: | `source` | What it names | | --------- | ---------------------------------------------------------------------------------------------------- | | `plan` | The subscription and plan version deciding it; `listed: false` means the version omits the feature | | `credits` | The live credit grants extending the allowance, with what is left on each | | `payer` | The paying account, when this customer is a managed tenant billed to its parent — whose plan decides | Add-ons never appear as a source: an add-on is a charge on the invoice and grants no feature. Nothing is consumed by asking. ## Checking a permission too [#checking-a-permission-too] One call can ask both questions: ```ts await base316.access.check({ externalId: "acct_1234", userId: "usr_abc", feature: "reports", permission: "reports.export", }); ``` The account must be entitled to the feature **and** the acting user must hold the permission. Checking a permission requires a user; checking a feature does not. ## Statuses that still entitle [#statuses-that-still-entitle] `ACTIVE` and `TRIALING` both entitle. So does `PAST_DUE` — the subscription is live, dunning is expected to recover the payment, and cutting somebody off the moment a card fails punishes them for their bank's decision. `PAUSED`, `CANCELED` and `INCOMPLETE` do not. ## What check does not do [#what-check-does-not-do] **It does not record usage.** It is a read, every time. Recording is a separate call you make *after* the work succeeds — see [metering](/concepts/metering). Keeping them apart is deliberate: work that fails should cost nothing, and a check that billed on inspection would make every retry expensive. # Metering ## Record after the work, not before [#record-after-the-work-not-before] ```ts await base316.events.record({ externalId: "acct_1234", event: "api_call", idempotencyKey: requestId, payload: { tokens: 1_420 }, occurredAt: new Date(), // optional; defaults to now }); ``` Check before, record after. Work that failed should cost nothing. ## The idempotency key is required [#the-idempotency-key-is-required] Not optional. A retry after a network timeout must not bill the same work twice, and Base316 cannot tell a retry from a second genuine call — only you know what "the same work" means. A request id is usually right. The same key records the event once, however many times you send it, so a client that retries on timeout is safe by construction. ## Meters aggregate events [#meters-aggregate-events] A meter declares which event feeds it, which property of the payload carries the value, and how values combine: | Aggregation | Use for | | ----------- | ------------------------------------------- | | `COUNT` | How many times something happened | | `SUM` | Adding a value up — tokens, bytes, minutes | | `MAX` | The peak within a period | | `LAST` | The most recent value — seats, active users | `SUM` over `payload.tokens` is the common shape for AI products. ## Periods [#periods] Usage is measured over the subscription's current billing period. Invoicing rates what was consumed in the period that just closed, so the clock that matters is the subscription's, not the calendar's. Usage is billed **in arrears** — it can only be rated once it has happened. The recurring fee is billed **in advance**, because it pays for access about to begin. A renewal invoice carries both: last period's usage and next period's fee. ## Credits [#credits] Prepaid credits extend a feature's allowance: ``` allowance = limit + credits ``` They are drawn down as usage is metered, and the original grant is kept alongside what has been consumed so a balance stays explainable after it has been partly spent. A grant can be voided, which takes its remaining balance back immediately. ## What this is not [#what-this-is-not] Base316 is not an event store. Events are metered and rated, then aggregated — they are not queryable as analytics, and they are not a log. Keep your own record of anything you need to ask questions about later. # How the model fits together Read this before designing a real catalog. Most integration trouble comes from mapping these onto the wrong things. ## The shape [#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 version ``` Everything 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 [#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 [#every-customer-has-an-owner] A customer belongs to one of: * a **tenant** — a runtime organization (`runtimeOrganizationId`, or its own `externalId` as `runtimeOrganizationExternalId`); * 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 [#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 [#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](/guides/plan-migrations). ## Where identity fits [#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 [#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: ```http 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 [#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. ```text Hartley & Co parent: its own members, plan and invoice ├── Acme Ltd managed: its own members and data, billed to the firm └── Globex Inc managed ``` Three things follow, each covered in [Managed tenants](/guides/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 [#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. # Products and bundles ## Products group plans and features [#products-group-plans-and-features] An environment selling four things needs to say which product a plan or feature belongs to. Without that, "Pro", "Team" and "Starter" sit in one list with nothing distinguishing them. A **product** (a product family) is that grouping. Plans belong to one; features belong to one. Both links are optional. A catalog written before products existed has none, and "ungrouped" is the honest answer for it. ## A bundle is a plan [#a-bundle-is-a-plan] There is no separate bundle object. A plan version already draws its entitlements and prices from any feature in the environment, so: > **A plan whose features span several products is a bundle.** The same way a creative suite is one SKU rather than a folder of subscriptions. That means everything else already works: billing, proration, MRR, dunning and `check` treat a bundled feature exactly like any other. Nothing downstream had to learn what a bundle is. ## What it costs [#what-it-costs] A bundle has **one price and one renewal date**. A customer cannot cancel half of one without changing plan. That is how a suite is normally sold. If you need per-product cancellation, sell per-product plans instead — a customer can hold several subscriptions. ## Invoices group by product [#invoices-group-by-product] A customer paying one price for three products still needs to see which part was which, so invoice lines are grouped by the product of the feature they price. The plan's own recurring fee prices no single feature, so it is grouped on its own rather than attributed to whichever product happens to sort first. ## Retiring a product [#retiring-a-product] A product cannot be archived while live plans still belong to it. Otherwise a subscription would point at a product the catalog says does not exist, and its invoices would stop being explainable. Archive the plans first. Archived products leave the picker but stay readable, so an existing invoice still explains itself. # Build with an agent A coding agent can take an application from no Base316 account details at all to a working sandbox in three commands. The only human step is approving the sign-in once, in a browser. ## 1. Sign in — the one step a person does [#1-sign-in--the-one-step-a-person-does] An agent's terminal output reaches you only after a command finishes, so sign-in is split in two. The agent runs: ```bash npx @base316/cli login --no-wait ``` ``` To sign in, open: https://app.base316.com/device?user_code=ABCDEFGH and check the page shows the code ABCD-EFGH. Then run `npx @base316/cli login` again to finish. ``` Open the link, check the code matches, and press **Approve**. The agent then runs `npx @base316/cli login`, which picks up the approval. Approve only a code you or your agent just asked for. The session it creates can do anything you can do in the dashboard. Nobody from Base316 will ever ask you for one. At a terminal yourself, plain `npx @base316/cli login` does both halves and opens the browser for you. The session is stored owner-only in `~/.config/base316/credentials.json`. `npx @base316/cli logout` revokes it on the server as well as deleting it, and it appears under **Settings → Security → Active sessions** like any other sign-in. ## 2. Set up the project [#2-set-up-the-project] ```bash npx @base316/cli init --project "My App" ``` ``` Created project "My App" in Acme. Wrote sandbox keys to .env: BASE316_SECRET_KEY, BASE316_PUBLISHABLE_KEY, NEXT_PUBLIC_BASE316_PUBLISHABLE_KEY. Added .env to .gitignore. ``` * A project that already has that name is reused, with a fresh sandbox secret key named for the machine — secret keys are shown once and cannot be read back. * Only **Sandbox** keys are written. Nothing `init` does can reach live customers. * An `.env` that already has `BASE316_SECRET_KEY` is left alone unless you pass `--force`. * In more than one workspace, `init` lists them and asks for `--workspace`. * Creating a project needs an owner or admin of the workspace, and counts against the plan's project limit, exactly as it does in the dashboard. The key itself is never printed, so it stays out of the agent's transcript. ## 3. Give the agent the tools [#3-give-the-agent-the-tools] ```bash claude mcp add base316 -- npx -y @base316/mcp ``` Run from the project directory, the [MCP server](/tools/mcp) reads the key `init` wrote, so no key goes into the agent's own configuration. The [CLI](/tools/cli) reads it the same way: ```bash npx @base316/cli whoami npx @base316/cli plans ``` ## 4. Tell the agent the rules [#4-tell-the-agent-the-rules] Paste this into the application's `AGENTS.md` or `CLAUDE.md`. Each line is a mistake an agent makes without it. ```md ## Base316 - Keys live in `.env` (written by `npx @base316/cli init`). `BASE316_SECRET_KEY` is server-only — never import it into client code. Browsers get `BASE316_PUBLISHABLE_KEY`. - Gate work **before** doing it: `await base316.access.check({ externalId, feature, requested })`, and refuse when `allowed` is false. Do not bill afterwards for work that should have been refused. - Record usage **after** the work succeeds: `await base316.events.record({ externalId, event, idempotencyKey, payload })`. `idempotencyKey` is required — use the request id, so a retry never bills twice. - Work in Sandbox. Test renewals, trials and dunning with test clocks (`npx @base316/cli clock:advance `) instead of waiting for them. - Check what a key points at with `npx @base316/cli whoami`, and why a customer was refused with `npx @base316/cli check `. - The API describes itself at https://api.base316.com/llms.txt and /openapi.json. ``` ## Where agents find Base316 [#where-agents-find-base316] | Resource | For | | ------------------------------------------------------- | --------------------------------- | | [`/llms.txt`](https://api.base316.com/llms.txt) | The API, summarised for a model | | [`/openapi.json`](https://api.base316.com/openapi.json) | Every operation, with its schemas | | [Docs `llms-full.txt`](/llms-full.txt) | These docs as one file | | [`@base316/mcp`](/tools/mcp) | Tools an agent calls directly | | [`@base316/cli`](/tools/cli) | Checks an agent runs from a shell | # Keys and environments ## Environments [#environments] Every project has a **Sandbox** and a **Production** environment. They are completely separate: separate customers, separate plans, separate invoices, separate number sequences. Nothing crosses between them. A key belongs to exactly one environment, so the key decides which data you touch. There is no "environment" parameter to get wrong. ## Two kinds of key [#two-kinds-of-key] | Prefix | Where it belongs | What it can do | | ----------- | ----------------- | -------------------------------- | | `b316_sk_…` | Your server, only | Everything the API offers | | `b316_pk_…` | A browser | Public catalog and sign-in flows | A publishable key **cannot** read customers, subscriptions or invoices. That is the point of it: it ships to a browser where anybody can read it, so it must not be able to enumerate your customers. The band is in the key itself — `b316_sk_sandbox_…` versus `b316_sk_live_…` — so you can tell what you are pointed at without asking. ## The mistake this prevents [#the-mistake-this-prevents] Running a script against production while believing it is sandbox. Both the [CLI](/tools/cli) and the [MCP server](/tools/mcp) read the band from the key and **refuse writes on a live key** unless you say so explicitly: ``` $ base316 usage cus_abc123 api_call Refused: recording usage would change live data. This key is a live key. Re-run with --live if you mean it, or export a sandbox key instead. ``` The check is local — it reads the key's own prefix rather than asking the server — so it cannot fail open when the network does. ## Scoped keys [#scoped-keys] A secret key can be issued with **permissions**, so it can do one job and no other — a sales tool that sends agreements has no business reading invoices. Today the scope that exists is agreements: | Permission | Allows | | ----------------- | ---------------------------------------------------- | | `agreement.read` | `GET /v1/agreements` and everything under it | | `agreement.write` | Creating, sending, resending, revoking and importing | | `agreement.*` | Both | A scoped key is refused, with `KEY_PERMISSION_DENIED` and the permission it needed in `error.param`, on **every** route its permissions do not name — it is fail-closed. A key issued without permissions is unrestricted, exactly as keys always were. Publishable keys cannot be scoped. Choose the scope when you issue the key under **Keys**. ## Rotating a key [#rotating-a-key] Create the new key first, deploy it, then revoke the old one. A revoked key stops working immediately, and its row is kept so audit history stays attributable rather than pointing at nothing. ## Configuration [#configuration] | Variable | Meaning | | -------------------- | ------------------------------------- | | `BASE316_SECRET_KEY` | Your server-side key | | `BASE316_API_URL` | Defaults to `https://api.base316.com` | The SDK reads both from the environment, so `new Base316()` with no arguments is usually right. # Quickstart By the end of this page your application will refuse work an account has not paid for, and record the work it does allow. ## 1. Get a secret key [#1-get-a-secret-key] From your application's directory: ```bash npx @base316/cli login npx @base316/cli init ``` `login` opens the dashboard and asks you to approve a code; `init` creates a project named after the directory (or uses the one you name with `--project`) and writes its **Sandbox** keys to `.env`, adding `.env` to `.gitignore`. Building with an AI agent? See [Build with an agent](/getting-started/agents). Or by hand: in the dashboard, open your project and go to **Developers → API keys**, copy the **secret** key for the **Sandbox** environment — it starts `b316_sk_sandbox_` — and export it: ```bash export BASE316_SECRET_KEY=b316_sk_sandbox_... ``` Secret keys are server-side only. The publishable key (`b316_pk_`) is the one safe to ship to a browser, and it cannot read customers, subscriptions or invoices. ## 2. Check the key works [#2-check-the-key-works] ```bash npx @base316/cli whoami ``` ``` Environment sandbox (env_…) Project proj_… Workspace org_… API https://api.base316.com ``` If that fails, nothing below will work — fix it first. ## 3. Install the SDK [#3-install-the-sdk] ```bash pnpm add @base316/sdk ``` ```ts import { Base316 } from "@base316/sdk"; // Reads BASE316_SECRET_KEY from the environment. const base316 = new Base316(); ``` ## 4. Define what you sell [#4-define-what-you-sell] In the dashboard's **Catalog** tab, create: 1. A **meter** — the raw thing you count, for example `api_call`. 2. A **feature** — what a customer buys, for example `api_calls`, backed by that meter. 3. A **plan** — a price and what it entitles. Give it a limit on `api_calls`. Then create a **customer** and subscribe them to the plan. Use the CLI to check your work without leaving the terminal: `npx @base316/cli plans` and `npx @base316/cli features`. ## 5. Gate the feature [#5-gate-the-feature] This is the call you will make most often. Ask **before** doing the work: ```ts const decision = await base316.access.check({ externalId: "acct_1234", // your own id for the account feature: "api_calls", requested: 1, // how much you are about to use }); if (!decision.allowed) { throw new Error(`Refused: ${decision.reason}`); } // ... do the work ... ``` `decision.usage` carries `used`, `limit`, `credits` and `remaining`, so you can show somebody how close they are before they hit the wall. ## 6. Record what you used [#6-record-what-you-used] Metering is a separate call, made **after** the work succeeds. If the work fails, nothing should be billed. ```ts await base316.events.record({ externalId: "acct_1234", event: "api_call", idempotencyKey: requestId, // required payload: { tokens: 1_420 }, }); ``` `idempotencyKey` is required rather than optional. A retry after a network timeout must not bill the same work twice, and you are the only one who knows what "the same work" means — a request id is usually the right answer. ## 7. Watch it add up [#7-watch-it-add-up] ```bash npx @base316/cli check acct_1234 api_calls ``` ``` ALLOWED Limit 1000 Used 1 Remaining 999 ``` ## Your project's setup checklist [#your-projects-setup-checklist] The project's **Overview** lists the first steps — a meter, a published plan, the first usage event, Stripe connected and able to charge, the first invoice, and a Production secret key that has made a call. Each step is read from what the environment actually has, links to the screen that does it, and the list disappears once everything is done. Hide it with the cross; that is remembered for you, not for your teammates. Rather explore before building? On an empty **Sandbox**, the checklist offers **Load example data**: three plans, four customers with subscriptions in different states, and some usage — the same world `POST /v1/seed` builds. It only ever adds to an empty catalog and is never offered in Production. ## Where to go next [#where-to-go-next] * [How the model fits together](/concepts/model) — read this before designing a real catalog. * [Entitlements](/concepts/entitlements) — exactly how `check` decides. * [Webhooks](/guides/webhooks) — react to invoices and subscription changes. # Sync to QuickBooks, Xero and HubSpot Base316 is the system of record for billing. **Settings → Integrations** copies what it produces into the tools the rest of the business already uses: the books (QuickBooks Online or Xero) and the CRM (HubSpot). Nothing is read back except the vendor's own ids, so a re-sync updates what is there instead of posting it twice. ## What goes where [#what-goes-where] | Base316 | QuickBooks Online | Xero | HubSpot | | -------------------------- | ------------------------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Customer | Customer (`DisplayName`, email, address) | Contact (`Name`, `ContactNumber` = Base316 id) | Company (`base316_customer_id`) | | Finalized invoice | Invoice — lines, `DocNumber`, due date, tax | Invoice `ACCREC`, `AUTHORISED`, lines, tax per line | — | | Voided invoice | Invoice voided | Invoice `VOIDED` | — | | Payment (card or recorded) | Payment linked to the invoice | Payment against the invoice | — | | Credit note | CreditMemo, applied to the invoice | Credit note `ACCRECCREDIT`, allocated to the invoice | — | | Subscription | — | — | `base316_plan`, `base316_mrr`, `base316_subscription_status` on the company, and one deal | * **Money goes over exactly.** Amounts are rounded once, to the currency's own minor unit — none for JPY and KRW, three places for KWD and BHD — and written into the request as the decimal Base316 rated, never through a floating-point number. Line amounts are split so they add up to the invoice total to the cent. * **Tax** is sent as the amount Base316 charged, against the vendor tax code you map each Base316 tax rate to. A document carrying two rates at once (state plus city) is refused with a message rather than guessed at — map a combined rate at the vendor and retry. * **Discounts** become a discount line in QuickBooks and a negative line in Xero. * **A credit note on an invoice that is already paid** is created but left unapplied: the customer is owed a refund, which is a decision for a person. * **Prepaid wallet balance applied to an invoice is not sent** as a payment. In the books, those invoices show the wallet amount as still open; record the wallet as you account for prepayments today. * **MRR** is the same figure as the revenue report: recurring plan fees and addons, per month, excluding usage; a cancelled subscription is 0. ## When it syncs [#when-it-syncs] Every customer, finalized invoice, payment, credit note and subscription change already raises a webhook event. The same events queue a sync job; the scheduler sends queued jobs every fifteen seconds. Nothing waits on the vendor while somebody is checking out. * A job sends whatever it depends on first — an invoice sends its customer if the customer has not been sent — so jobs never need to run in order. * **Rate limits:** a 429 pauses that connection until the vendor's `Retry-After` (a minute when it gives none), without using up an attempt. * **Outages** (timeouts, 5xx) are retried at 30 s, 2 min, 8 min, 32 min and 2 h, then marked failed. * **A refused document** — a missing mapping, a duplicate number — fails at once with the vendor's own message. Fix the cause and press **Retry** in the sync log. * **Sync now** sends everything that already exists: customers, issued invoices, settled payments and issued credit notes (live subscriptions for HubSpot), skipping anything already sent, with a progress bar. ## Connecting [#connecting] 1. Open **Settings → Integrations** in the environment you want to connect. **Sandbox environments connect to the vendor's sandbox** (a QuickBooks sandbox company, the Xero Demo Company, a HubSpot developer test account), and **Production environments to live books** — the same split as Stripe's test and live modes. Connect each environment separately. 2. Press **Connect**, sign in at the vendor and choose the company. You come back to the Integrations screen. 3. Press **Mapping** and choose where things post: the QuickBooks product or service (or Xero revenue account) for invoice lines, the account payments land in, and a vendor tax code for each Base316 tax rate. For HubSpot, choose the deal pipeline and stages. 4. Press **Sync now** to send what already exists. **Disconnect** stops syncing at once, cancels anything queued and revokes the token at the vendor. What is already in the books stays there. Reconnecting the **same** company resumes where it left off; connecting a different company starts clean, because the old company's ids mean nothing in the new one. If the vendor stops renewing access (the person who connected it left, or revoked the app), the integration shows **Needs reconnecting**. Work keeps queuing and is sent once somebody presses **Reconnect**. ## Checking from code [#checking-from-code] `GET /v1/integrations` (secret key) lists each vendor's status, mode, the connected account, when it last synced, the last error and how many jobs are pending or failed — `base316.integrations.list()` in the TypeScript SDK. Connecting is an OAuth consent a person gives in the dashboard, so there is no API to create a connection. ## Operating Base316: going live [#operating-base316-going-live] Base316 itself is registered once with each vendor as the developer app every customer authorizes. Until a deployment has the app's credentials, the integration says **Not configured on this deployment** and names the variables. The redirect URI is the same for sandbox and live; register exactly one per deployment. They are `{API URL}/integrations/oauth/{vendor}/callback`: | Deployment | QuickBooks | Xero | HubSpot | | ---------- | ------------------------------------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------- | | QA | `https://qa-api.base316.com/integrations/oauth/quickbooks/callback` | `https://qa-api.base316.com/integrations/oauth/xero/callback` | `https://qa-api.base316.com/integrations/oauth/hubspot/callback` | | Production | `https://api.base316.com/integrations/oauth/quickbooks/callback` | `https://api.base316.com/integrations/oauth/xero/callback` | `https://api.base316.com/integrations/oauth/hubspot/callback` | | Local | `http://localhost:3110/integrations/oauth/quickbooks/callback` | `http://localhost:3110/integrations/oauth/xero/callback` | `http://localhost:3110/integrations/oauth/hubspot/callback` | (The billing deployment, if it should sync too, adds the same paths on `https://billing-api.base316.com`.) A deployment that is not `BASE316_ENV=production` — QA included — only ever uses sandbox credentials. The variables go into the API **and** scheduler workloads' secrets (`base316-secrets`); the scheduler is what sends. ### QuickBooks Online (Intuit Developer) [#quickbooks-online-intuit-developer] 1. At developer.intuit.com, create an app with the **Accounting** scope (`com.intuit.quickbooks.accounting`). 2. Under **Development → Keys & credentials**, add the redirect URIs above and copy the development keys into `BASE316_QUICKBOOKS_SANDBOX_CLIENT_ID` and `BASE316_QUICKBOOKS_SANDBOX_CLIENT_SECRET`. These work only against sandbox companies (`sandbox-quickbooks.api.intuit.com`). 3. For live books, complete Intuit's production questionnaire, add the production redirect URI under **Production → Keys & credentials**, and set `BASE316_QUICKBOOKS_CLIENT_ID` and `BASE316_QUICKBOOKS_CLIENT_SECRET` on the production deployment. 4. Base316 pins `minorversion=75` and sends `requestid` on every create. ### Xero (Xero Developer) [#xero-xero-developer] 1. At developer.xero.com, create a **Web app**. New apps get granular scopes; Base316 requests `openid profile email offline_access accounting.contacts accounting.invoices accounting.payments accounting.settings.read`. 2. Add the redirect URIs above, and set `BASE316_XERO_CLIENT_ID` and `BASE316_XERO_CLIENT_SECRET`. Xero has no separate sandbox keys — a sandbox environment authorizes the same app against the Demo Company — but `BASE316_XERO_SANDBOX_CLIENT_ID` / `_SECRET` are honoured if you register a second app for QA. 3. Xero limits each organisation to 60 calls a minute and 5,000 a day; the sync waits out a 429 rather than failing. ### HubSpot (public app) [#hubspot-public-app] 1. In a HubSpot developer account, create a **public app** using OAuth, with the scopes `oauth crm.objects.companies.read crm.objects.companies.write crm.objects.deals.read crm.objects.deals.write crm.schemas.companies.read crm.schemas.companies.write crm.schemas.deals.read crm.schemas.deals.write`. The schema scopes let the first sync create the `base316` property group. 2. Add the redirect URIs above, and set `BASE316_HUBSPOT_CLIENT_ID` and `BASE316_HUBSPOT_CLIENT_SECRET`. Sandbox environments connect to a developer test account through the same app. 3. Tokens are exchanged and refreshed at HubSpot's v3 endpoint (`/oauth/v3/token`); v1 stops working on 16 February 2027. Tokens are encrypted at rest under `BASE316_ENCRYPTION_KEY` and are moved by a key rotation like every other stored credential. # Agreement checkout A B2B buyer — often a CEO or a procurement contact — does not sign up through a pricing page. They are sent terms, they read a contract, somebody with authority signs it, and only then is a card charged. Agreement checkout is that flow, hosted by Base316, with no account for the buyer to create: 1. **Review** the agreement (your Markdown or HTML, rendered safely, plus an optional PDF). 2. **Confirm their email** with a six-digit code sent to the address you named. 3. **Sign**: full name, title and company, a box confirming they have authority to bind the company, and an explicit consent to electronic records and signatures. 4. **Add a card** on Stripe's hosted page (setup mode, on your connected account). 5. **Activate**: the subscription opens on the agreed terms and the first invoice is charged — now, or on the agreed start date. Both sides are emailed the executed agreement with a **certificate of completion**: the document's SHA-256, how the address was proved, the consent in the words that were shown, the IP address and browser, and every step with its time. ## Create and send an offer [#create-and-send-an-offer] ```bash curl https://api.base316.com/v1/agreements \ -H "Authorization: Bearer $BASE316_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "customer": { "name": "Acme Corp", "externalId": "acme" }, "planVersionId": "pv_...", "customAmount": "1500.00", "billingAnchorDay": 22, "expiresAt": "2026-11-30T23:59:59Z", "signerEmail": "ceo@acme.example", "signerName": "Dana Whitfield", "notifyEmails": ["sales@yourco.example"], "document": { "title": "Master Services Agreement", "format": "MARKDOWN", "body": "# MSA\n\n..." }, "send": true }' ``` The response is the agreement plus, because `send` was set, a `signingUrl`. That link is returned **once** — by `send` and `resend` — and never appears in a list, a read or a webhook. Pass `"email": false` to deliver it yourself. | Term | Meaning | | ------------------ | --------------------------------------------------------------------------------------------------- | | `planVersionId` | A published plan version. Its interval is the billing interval; `interval` is checked against it. | | `customAmount` | A negotiated recurring fee, saved as a custom price on the plan's one recurring fee before billing. | | `startAt` | A future date: the subscription opens now as a trial and the first invoice is charged on that date. | | `billingAnchorDay` | Every period ends on this day of the month; the first period is a prorated stub to it. | | `trialDays` | Free days after the start. Omitted uses the plan's own trial. | | `netTermDays` | Invoice due days for the account. The card on file is still charged. | | `expiresAt` | The last moment it can be signed. A signed agreement does not lapse. | A customer that already has a live subscription is refused (`ALREADY_SUBSCRIBED`): an agreement opens a subscription. ## Statuses and webhooks [#statuses-and-webhooks] ```text DRAFT → SENT → VIEWED → SIGNED → CARD_ADDED → ACTIVE └──────────┴───────┴────────┴──→ REVOKED (operator) DRAFT/SENT/VIEWED past expiresAt ──→ EXPIRED ``` Each change is a webhook: `agreement.created`, `.sent`, `.viewed`, `.signed`, `.card_added`, `.activated`, `.revoked`, `.expired`, and `agreement.imported`. Each carries `agreementId`, `customerId`, `status`, `previousStatus`, `documentSha256` and `subscriptionId`; `signed` adds the signer, `activated` adds the subscription's period and whether the first payment was collected. ## The link is single use [#the-link-is-single-use] The emailed link opens a page that spends nothing (mail scanners fetch links); its button redeems the link once into a short session held in a cookie. A second click on the same link is refused, and offers to email a fresh link to the signer's address only. Every step's form is single use, and completing, revoking or expiring the agreement ends the link and the session. The page cannot be framed, every form is CSRF protected, and requests are rate limited. ## Moving an existing customer [#moving-an-existing-customer] `POST /v1/agreements/import` records an agreement executed elsewhere, with the card already on Stripe and the period the old system had: ```json { "customer": { "name": "Acme Corp", "externalId": "acme" }, "stripeCustomerId": "cus_...", "stripePaymentMethodId": "pm_...", "planVersionId": "pv_...", "customAmount": "1500.00", "currentPeriodEnd": "2026-11-22T00:00:00Z", "document": { "title": "MSA (executed)", "pdfBase64": "JVBERi0..." }, "signer": { "name": "Dana Whitfield", "title": "CEO", "email": "ceo@acme.example" }, "signedAt": "2026-03-22T15:04:00Z" } ``` The Stripe customer and card are looked up on **your connected account** and must belong together; one from any other account is refused. Nothing is charged: the next invoice is raised at `currentPeriodEnd`, as it would have been. ## Scoped keys [#scoped-keys] Give a sales tool a key that can only work with agreements: issue a secret key with `agreement.read`, `agreement.write` or `agreement.*` under **Keys**. See [scoped keys](/getting-started/keys-and-environments#scoped-keys). # Billing portal Every subscription product ends up building the same screen: what plan am I on, what will I be charged next, change my plan, update my card, download last month's invoice, cancel. The billing portal is that screen, ready-made, in two forms that do the same things by the same rules: | | Hosted portal | `` | | ---------------- | ------------------------------------------ | ----------------------------------------------- | | Where it runs | A page on Base316 (or your custom domain) | Inside your React application | | Who it is for | Anyone — your app needs no Base316 sign-in | People signed in through Base316 | | How it is opened | Your server mints a link with a secret key | Rendered under `Base316Provider` with a session | | Look | Your hosted sign-in branding | Your app's styles, plus the provider's branding | | JavaScript | None — forms and redirects only | React | Both show: * the current plan and its status, with when it renews or ends; * the next invoice, line by line, before it is raised; * a scheduled change — a downgrade at renewal — with a button that undoes it; * **change plan**, through a review step: features gained and lost, the prorated credit and charge if it happens now, or the date it waits for, and which of the two is the default (an upgrade now, a downgrade at the renewal); * **seats**, when the plan is priced per seat, previewed before anything is charged; * **cancel at the end of the period**, asking why and making your [retention offer](/guides/retention-offers) when there is one, and **resume** until then; * **invoices**, with each one's PDF, and a **Pay** button on an open one; * **the card on file**, and a way to replace it; * a **Billing details** section showing who invoices are made out to. Two more sections sit beside the overview, in the portal's navigation: * **Payment methods** (`/portal/billing/payment-methods`) — every saved card, which one is charged, **Make default**, **Remove**, and **Add a card** (the same Stripe-hosted card entry as "Update card", coming back to this section). Shown unless the account is billed to a parent. See [Saved cards](/guides/payment-methods). * **Credits** (`/portal/billing/credits`) — each credit balance, buying a pack, and auto-recharge. Shown when the product sells credit packs. See [Credit packs](/guides/credit-packs). ## The hosted portal [#the-hosted-portal] ### Open it from your server [#open-it-from-your-server] Call this after your own application has signed the person in. The customer id is you vouching for who they are — which is why it needs a **secret key**, and why the page never takes a customer id from its URL. ```ts import { Base316 } from "@base316/sdk"; const base316 = new Base316({ secretKey: process.env.BASE316_SECRET_KEY }); // In your "Billing" route handler: const session = await base316.billingPortal.createSession(account.base316CustomerId, { returnUrl: "https://app.example.com/settings", }); return redirect(session.url); ``` Or over HTTP: ```bash curl https://api.base316.com/v1/billing-portal/sessions \ -H "Authorization: Bearer $BASE316_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "customerId": "cus_…", "returnUrl": "https://app.example.com/settings" }' ``` To land on a section rather than the overview, pass `page`: `"overview"` (the default), `"credits"` or `"payment-methods"`. The visit can move between all of them either way. ```ts const session = await base316.billingPortal.createSession(customerId, { page: "payment-methods", returnUrl: "https://app.example.com/settings", }); ``` Every server SDK has the same call: `create_billing_portal_session` in Python and Ruby, `CreateBillingPortalSession` in Go, `createBillingPortalSession` in Java and PHP, `CreateBillingPortalSessionAsync` in C#. Redirect straight away. The link: * **opens once**, and only within ten minutes of being minted. Opening it swaps the token for a cookie and takes the token out of the address bar, so a link left in browser history is already spent; * then lasts **30 minutes** as a visit, long enough to go to Stripe and back; * opens **only the billing portal**, for **only that customer**. A link minted for the API keys page cannot reach billing, and the reverse. `returnUrl` is the portal's **Back** link. It must be one of the environment's **redirect URLs** (Authentication → Redirect URLs), matched exactly as written — the same allow-list the hosted sign-in pages and checkout use. Anything else is refused with `400` and `param: "returnUrl"`. Leave it out and the page simply has no back link. ### From the dashboard [#from-the-dashboard] On a customer's page, **Actions → Open billing portal as customer** opens the same page in a new tab, and **Copy billing portal link** copies a fresh link to send them. Both need the **Manage** permission and are recorded in the audit log, because the link can change and cancel the customer's plan. ### Branding [#branding] The portal uses the branding you set for your hosted sign-in pages — logo, name, colours, background and support email. On a tenant's own verified domain, it uses that tenant's branding, exactly as the sign-in pages do. See [Custom domain](/guides/custom-domain) and [Managed tenants](/guides/managed-tenants#the-firms-brand-on-the-firms-domain). ### How it keeps people safe [#how-it-keeps-people-safe] * The page has **no JavaScript**: every action is a form that posts and comes back, so refreshing never repeats a charge, and the CSP forbids scripts. * Every form carries a **CSRF token** matched against a cookie only that browser holds; a forged post from another site changes nothing. * **No card number ever reaches your servers or Base316.** "Update card" sends the person to a page hosted by Stripe on your connected account, which keeps you in PCI SAQ A. Base316 reads the result back from Stripe and makes it the card future invoices are charged to. * "Pay" on an open invoice goes to the hosted pay page for that invoice. * A first purchase, for an account with no plan, goes through checkout — and is only offered when the session has a `returnUrl`, which is where checkout ends. * When the environment sells [credit packs](/guides/credit-packs), the portal has a **Credits** section (and a **Manage credits** button on the overview that opens it). To send someone a link that tops up credits and can do nothing else, mint a credits page link with `creditsPortal.createSession`: it opens the same controls on their own, with no way into billing, because a link sent only to top up credits must not be able to cancel the plan. ## The React component [#the-react-component] ```tsx import { Base316Provider, BillingPortal, SignIn, useSession } from "@base316/react"; function Billing() { const { session } = useSession(); if (!session) return ; return ; } ``` It reads and acts through the signed-in person's session and the `/v1/me` routes, so there is nothing to call from your server. Props: | Prop | What it does | | ----------------- | ------------------------------------------------------------------------------------------ | | `returnUrl` | Where the browser comes back to after updating the card on Stripe's page. A redirect URL. | | `featuredPlanKey` | The plan to present as the default choice. | | `unitLabel` | What a seat is called. `"seats"` by default. | | `billingDetails` | Your own Billing details section — an address or tax-ID form. Pass `null` to leave it out. | It is built from components you can use on their own when you want a different layout: `SubscriptionManager` (now with resume, and a confirm step on cancel), `UpcomingInvoice`, `PricingTable`, `PlanChangeReview` (the review step), `SeatManager`, `PaymentMethod` (the card and its update) and `Invoices` (now with PDF download and Pay). See [Components](/tools/components). The `/v1/me` routes added for it are usable from your own UI too: `POST /v1/me/subscription/resume`, `GET /v1/me/payment-method`, `POST /v1/me/payment-method/update-session` and `POST /v1/me/invoices/{id}/pay-link`. ## One set of rules [#one-set-of-rules] The hosted page and the component call the same functions, so they cannot disagree: * A change **now** is charged now and needs a card on file; without one the review asks for a card instead. A change at the **period end** charges nothing and needs no card. * A scheduled change waits for a pending cancellation to be resumed. * Adding seats is charged now for the rest of the period; removing them takes effect at the period end and refunds nothing. The customer picks only the number. * Cancelling is always at the end of the period they have paid for. ### A client billed by the firm that runs it [#a-client-billed-by-the-firm-that-runs-it] For a [managed tenant](/guides/managed-tenants) whose parent pays, both forms show the parent's plan, read-only, with "Provided by Hartley & Co", and refuse every change with `billed_by_parent` — a plan bought on the client's account would grant nothing, and the firm's plan covers its other clients too. The upcoming invoice and plan-change preview are not shown, because the firm's invoice lists every client's usage. The client's own invoices, if it has any, are listed. There is no card to update: the firm's card is the one charged. # Sell credit packs A **credit pack** is a one-off price that grants units of a metered feature: "1,000 AI credits for $10". A customer buys one on Base316's hosted pay page; when the payment lands, the units become a credit grant (reason `PURCHASE`) that `check()` and the billing run spend like any other credit. ## The order units are spent in [#the-order-units-are-spent-in] For a metered feature, in each billing period: 1. **The plan's allowance**, scaled by quantity for a per-unit allowance. 2. **Credit grants, soonest-to-expire first.** Voided and expired grants are skipped. A grant that was live at any point in the period still covers the usage it covered while it was live. 3. **Overage**, only when the plan version prices the feature's usage (a soft limit), up to any hard cap. Without an overage price the limit is hard and `check()` refuses with `USAGE_LIMIT_EXCEEDED` once allowance and credits are gone. Credits are drawn down when the period is invoiced. `GET /v1/credits/balance` applies this period's draw already, so its figures are what will be left. ## Create a pack [#create-a-pack] In the dashboard: **Catalog → Credit packs → New credit pack**. Or with the SDK: ```ts await base316.creditPacks.create({ key: "credits-1000", name: "1,000 AI credits", featureKey: "ai_credits", units: 1000, currency: "USD", amount: "10.00", // Optional volume discount over the number of packs bought. tiers: [ { upTo: 4, unitAmount: "10.00" }, { upTo: 9, unitAmount: "9.00" }, { upTo: null, unitAmount: "8.00" }, ], // Purchased credits last 365 days by default; null never expires. expiresInDays: 365, // Most packs one checkout may buy. 1,000 unless set; up to 1,000,000. maxQuantity: 1000, }); ``` * The feature must be `METERED`. * **Volume tiers** price the whole quantity at the band it lands in: five packs at the 5–9 price, not four at one price and one at another. Exactly one band is open-ended (`upTo: null`). * The key, feature, units and currency are fixed once created — they are what a purchase bought. Price, expiry and `maxQuantity` can change, and apply to purchases made afterwards. Archive a pack to stop selling it. ### Top-ups of any amount [#top-ups-of-any-amount] `maxQuantity` caps how many packs one checkout buys, 1,000 unless you set it. To let a customer top up any whole-dollar amount, sell a pack worth one dollar and raise the cap — 100 units of a feature counted in cents, for $1.00, with `maxQuantity: 25000`, takes any top-up from $1 to $25,000: ```ts await base316.creditPacks.create({ key: "ad_funds", name: "Ad funds", featureKey: "ad_spend", // metered in cents units: 100, currency: "USD", amount: "1.00", expiresInDays: null, maxQuantity: 25000, }); // A $250 top-up. await base316.creditPacks.checkout({ externalId: account.id, creditPackKey: "ad_funds", quantity: 250, }); ``` A checkout past the cap is refused with `INVALID_REQUEST` naming it. The public pack list includes `maxQuantity`, so a picker can bound its input. ## List packs in your app [#list-packs-in-your-app] The packs on sale are public, like plans, so a "buy more credits" picker can read them in the browser with a publishable key: ```ts const { data: packs } = await browser.billing.creditPacks(); ``` `GET /v1/public/credit-packs` leaves out archived packs and anything about a customer. ## Sell one [#sell-one] Buying names the account being sold to, so your **server** starts it: ```ts const checkout = await base316.creditPacks.checkout( { externalId: account.id, creditPackKey: "credits-1000", quantity: 2, returnUrl: "https://app.example.com/billing", }, { idempotencyKey: `pack-${requestId}` }, ); redirect(checkout.url); ``` * Base316 raises a one-off invoice for the packs (tax and the customer's wallet are applied as on any invoice) and returns the hosted pay page's `url`. After paying, the buyer gets a receipt and a link back to `returnUrl`, which must be one of the environment's allowed redirect URLs. * When the customer's wallet covers the whole amount, `status` is `PAID`, `url` is null and the credits are already granted. * The credits are granted when the invoice is paid — by card on the pay page, by an offline payment you record, or marked paid in the dashboard — exactly once, however many times the payment is reported. `credit_pack.purchased` is sent then. * **An unpaid checkout is never charged automatically**, even with a card on file. It lapses at `expiresAt` (24 hours by default, `expiresInMinutes` to change it) and its invoice is voided. * A managed tenant billed to its parent spends its parent's balance, so the checkout is refused (`CONFLICT`) — sell the pack to the parent account. From the dashboard, a customer's page has **Sell a pack**, which creates the same payment link to send by hand. ## Refunds [#refunds] A refund is a credit note on the purchase invoice. The grant keeps the share of its units that was not credited back, and never less than what has already been spent: crediting half of a $10 pack takes 500 of 1,000 unspent credits away. The money goes where the credit note sends it: to the customer's wallet by default, or back to their card (see Refunds in the billing concepts). A credit note that moved money cannot be voided, so the units do not come back once the money has. ### Refund everything unused [#refund-everything-unused] When a customer leaves, or stops using a feature, refund all of their unused purchased credit for it in one call: ```ts const refund = await base316.credits.refund({ externalId: account.id, featureKey: "ad_spend", reason: "Closed the ads account", idempotencyKey: `refund-${account.id}-ad_spend`, }); refund.refundedCents; // 3766 refund.creditNotes; // one per purchase refunded ``` * **Only what is really unused.** Usage this period counts as spent, even though the billing run only records it at the period's close. A customer with $50 bought and $12.34 used so far this month is refunded $37.66, and the $12.34 still comes out of their credit when the period closes — not billed as overage. * **One credit note per purchase**, on the invoice that paid for it, for that purchase's unused share of the invoice total (tax included), rounded down to the cent. The money goes back to the payment that bought it; a pack paid from the wallet is returned to the wallet. * **Credit nobody paid for is withdrawn**, not refunded — promotional or manual grants — and listed in `grantsVoided`. * **Auto-recharge for the feature is turned off.** Otherwise the empty balance is a low balance, and the next check would buy a pack straight back. It shows as `disabledReason: "CREDITS_REFUNDED"` and can be turned on again. * A purchase whose refund is refused (for example, a Stripe account that cannot issue refunds) is left exactly as it was and listed in `skipped`. * **`idempotencyKey` is required.** A retry with the same key returns the first result, whenever it arrives, and refunds nothing more. * `credits.refunded` is sent once the refund is done. * A managed tenant spends its parent's credit, so the parent is refunded: the call is refused (`CONFLICT`) for the tenant. ## Show the balance [#show-the-balance] ```ts const balance = await base316.credits.balance({ externalId: account.id, featureKey: "ai_credits" }); // { allowance: 5000, used: 5600, allowanceRemaining: 0, // purchasedRemaining: 400, creditsRemaining: 400, remaining: 400, ... } ``` The purchase history for a billing page is `creditPacks.purchases({ externalId })`. ## Low-balance events [#low-balance-events] `credits.low_balance` is sent when a feature's remaining balance — allowance plus credits, less usage — falls below a threshold, **once per crossing**: it re-arms only when the balance is next seen at or above the threshold (a top-up, a new period, a bigger plan). * **The product's threshold** is set on the feature: `catalog.features.update(featureId, { lowBalanceThreshold: 500 })`, or **Catalog → Features → Edit**. * **A customer's own threshold** overrides it: `credits.setAlert({ externalId, featureKey: "ai_credits", threshold: 1000 })`. `threshold: null` falls back to the product's. A threshold set above a balance that is already low fires at once. The payload carries `customerId`, `featureKey`, `threshold`, `thresholdSource` (`customer` or `product`), `remaining`, `allowance`, `allowanceRemaining`, `used`, `purchasedRemaining`, `creditsRemaining`, `overagePriced`, `periodEnd` and `crossedAt`. Both events can also trigger an email template. ## Auto-recharge [#auto-recharge] Auto-recharge buys a pack with the customer's saved card when a balance runs low, so an agent mid-task does not stop at zero. It is per customer and metered feature, and **off by default**. ```ts await base316.credits.setAutoRecharge({ externalId: account.id, featureKey: "ai_credits", enabled: true, threshold: 200, // recharge when fewer than 200 credits are left creditPackKey: "credits-1000", monthlyMaxCount: 5, // and/or monthlyMaxAmount: "50.00" }); const { data } = await base316.credits.autoRecharge({ externalId: account.id }); ``` Fields left out keep their stored value, so `{ featureKey, enabled: false }` turns it off. Turning it on needs a monthly maximum — a count of purchases, an amount (before tax, in the pack's currency), or both. **When it charges.** The balance is checked after every metered event (the same moment `credits.low_balance` is decided) and by a sweep every 15 minutes. Below the threshold, the setting is marked due and charged within about 30 seconds: Base316 raises the pack's invoice and charges the default saved card off session. Payment grants the credits as a PURCHASE grant, as any pack purchase does, and sends `credits.auto_recharged` (and `invoice.paid`, whose email template carries the invoice PDF — use either for the receipt). **Safety.** * Never more than the monthly maximum, counted from this calendar month's (UTC) automatic purchases, pending or paid. * At least 10 minutes between attempts, however often the balance dips. * Never two charges in flight. A charge waiting on the card's bank holds everything else until it settles. **When it can't charge.** | Situation | What happens | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | No card on file, or invoiced on terms | Nothing is charged or invoiced. It stays on, flagged `needsPaymentMethod`, and `credits.auto_recharge_failed` is sent once (`reason: NO_PAYMENT_METHOD` or `MANUAL_COLLECTION`). | | Card declined | The invoice is voided and the purchase expires. One retry an hour later (`retryAt`). | | Declined again | Auto-recharge is turned off (`disabledReason: PAYMENT_FAILED`), the customer is emailed, and the event says `disabled: true`. | | Monthly maximum reached | Nothing more is bought this month; the event is sent once (`reason: MONTHLY_LIMIT`). | | The pack is archived | Turned off (`PACK_UNAVAILABLE`). | A declined pack invoice does not start dunning: there is no subscription to mark past due. Turning auto-recharge back on clears the failure count. **Where customers manage it.** The [billing portal](/guides/billing-portal) has a **Credits** section. Mint a link that lands on it with `billingPortal.createSession(customerId, { page: "credits" })` and send the customer there: it shows each balance, lets them buy a pack, turn auto-recharge on or off with their own threshold and maximum, and shows when it was turned off and why. To send someone a link that tops up credits and can do nothing else, mint a credits page link from your server with `creditsPortal.createSession(customerId, { returnUrl })` (`POST /v1/credits-portal/sessions`, secret key) and redirect the customer to `url` straight away: the hosted credits page has the same controls as the portal's section. The link opens once, within ten minutes, then lasts a 30-minute visit; `returnUrl` must be one of the environment's redirect URIs, exactly. It opens the credits page and nothing else — not the billing portal, not the API keys page — so a link sent to top up cannot change the plan. In the dashboard, each customer's page has the same controls under **Balances and credit packs**. **When the card is the problem.** "No card on file" and "declined twice" are both fixed by the customer entering a card. From the credits page, its **Card on file** section has an **Update card** button that runs the billing portal's card update (a Stripe-hosted page) and comes back to the credits page; in the billing portal, the Credits section's warnings link to the **Payment methods** section (`page: "payment-methods"`). The dashboard's auto-recharge panel offers **Copy credits page link** in those states. See [Saved cards](/guides/payment-methods). A managed tenant billed to its parent spends the parent's pool, so auto-recharge is set on the parent (`CONFLICT` for the child). # Serve sign-in from your own domain By default your users sign in at a Base316 URL. On your own hostname — `accounts.yourcompany.com` — they never see somebody else's brand at the moment they are typing a password, which is also the moment they are most alert to anything that looks wrong. Setting it up is two DNS records and a button. The work is in somebody else's DNS panel, so most of this page is about what to put there and what each thing is for. ## Add the domain [#add-the-domain] In the dashboard, open the environment and go to **Settings → Custom domain**, and add your hostname under **Custom domain**. Base316 answers with two records. | Type | Name | Points at | | ------- | --------------------------------------------- | -------------------------- | | `TXT` | `_base316-challenge.accounts.yourcompany.com` | A one-off challenge string | | `CNAME` | `accounts.yourcompany.com` | Base316's auth host | They do different jobs and they fail differently. The **TXT** record proves you control the name. Until it resolves, Base316 refuses to answer for the hostname at all — otherwise anyone could point a hostname they do not own at us and have a branded sign-in page served under it. The **CNAME** sends traffic to us. Without it nothing reaches Base316 to be refused, so the page simply does not load. Add both, then press **Check DNS**. Propagation is usually minutes and occasionally an hour; the panel shows when it last looked and what it saw. Keep the records after verification. The TXT is re-checked, and removing it later stops the domain answering. ## Set a default redirect [#set-a-default-redirect] This is the step people miss, and it is not optional. A sign-in that starts on your own hostname carries **no `redirect_uri`** — `accounts.yourcompany.com/hosted/sign-in` with nothing in the query string is the whole point of the feature. Without somewhere to send people afterwards, they sign in successfully and stop on an error page. Set **Default redirect** under **Settings → Sign-in methods & design**, to the same URL you would have passed as `redirect_uri`. It is checked against your allowed redirect URIs like any other destination, so add it to that list too. Set **Home page** as well if you want the logo and "back" links to point at your application rather than at the sign-in page. ## The certificate [#the-certificate] You do not request one. Once DNS is verified, a certificate is issued for the hostname and the panel shows **Issuing certificate** until it answers. The page starts loading over HTTPS when that finishes. Base316 never sees a private key for your domain — the certificate lives on the load balancer, and the only thing recorded against your environment is which certificate answers for the hostname. ## What your users see [#what-your-users-see] Everything hosted moves with the domain: sign-in, sign-up, the forgotten password flow, emailed sign-in links, the second-factor prompt and the account page. The links are listed under **Settings → Hosted pages**, and they update to your hostname once it is serving. **Your application does not change.** It keeps the same issuer and discovery URL. When it sends somebody to Base316's authorize endpoint, and your domain is serving, the browser is moved to your domain before any page is shown, and the rest of the sign-in runs there. Signing out moves the same way, because that is where the session lives. Tokens are still issued by Base316's host, and your server's code exchange is unaffected. Your branding — name, logo, colours — is already what those pages use. The domain changes the address bar, which is the part your users read without being asked to. ## When it does not work [#when-it-does-not-work] **"Awaiting DNS"** after several minutes: the TXT record is not resolving yet. Check it with `dig TXT _base316-challenge.accounts.yourcompany.com` — some DNS panels append the zone name to what you type, giving you `_base316-challenge.accounts.yourcompany.com.yourcompany.com`. **The page does not load at all**: that is the CNAME, not the verification. Nothing is reaching us. **Signing in ends on "Nowhere to return you to"**: the default redirect is not set. See above — this is the commonest one. **The page loads but the certificate is untrusted**: issuance has not finished. It does not need anything from you. ## Removing it [#removing-it] Removing the domain returns sign-in to the default hostname immediately. Your users are not signed out, and nothing else changes — but any link you have published on the custom hostname stops working, so change those first. # Directory sync (SCIM) An enterprise customer runs Okta or Entra and will not maintain a second list of people by hand. SCIM lets their directory create and update users in your product automatically. ## The half that matters [#the-half-that-matters] Provisioning is the convenience. **Deprovisioning is the product.** The reason SCIM appears in procurement is that somebody offboarded in HR has to lose access to your product because their record closed there — not because an administrator remembered to go and remove them. That is the control an enterprise security review is actually asking about. ## Setting one up [#setting-one-up] One directory manages **one tenant**. Each of your customers gets their own endpoint and token, so revoking one cannot disturb another. 1. In **Authentication → Directory sync**, choose **Connect a directory**. 2. Pick the tenant its people should land in, and a default role. 3. Copy the **endpoint** and the **token**. The token is shown once. Only a digest is stored, so it cannot be shown again — if it is lost, revoke it and issue another. In Okta, that is *Provisioning → Integration → SCIM 2.0 Base URL* and *API Token*. In Entra, *Provisioning → Admin Credentials → Tenant URL* and *Secret Token*. ``` Endpoint https://api.base316.com/scim/v2/env_1a2b3c Token b316_scim_… ``` ## Groups are roles [#groups-are-roles] A SCIM **Group** is a Base316 **role**. Not a separate thing mapped to one — the group *is* the role, its members are the people holding it, and there is therefore no mapping to drift out of date. A group pushed from a directory that matches no existing role creates one **with no permissions**. That is deliberate: a group says who is together, never what they may do, and inventing permissions for it would grant access nobody asked for. Fill it in under **Roles**. Because a membership holds one role, somebody in two groups ends up with whichever the directory applied last. That is the directory's own ordering, not a rule invented here — if a person needs the union of two groups' permissions, make a role that says so and put them in it. ## What each operation does [#what-each-operation-does] | The directory does | Base316 does | | ------------------------- | ------------------------------------------------------------------ | | Creates a user | Creates the person and puts them in the tenant on the default role | | Sets `active: false` | Revokes every session and refresh token; see below | | Sets `active: true` | Restores it | | DELETEs a user | The same as `active: false` | | Adds someone to a group | Gives them that group's role | | Removes them from a group | Returns them to the default role — they stay in the tenant | | Deletes a group | Removes the role; its people stay, on the default role | Three of these are worth dwelling on. **A directory reaches its own tenant and no further.** An address that already belongs to somebody in the environment — including somebody in one of your *other* tenants — comes back `409`, with nothing about them in the body. The same goes for adding a stranger to a group: it is a miss, not an invitation to enrol them. If a person genuinely belongs to two of your tenants, add the second membership yourself; that is a decision you can see both sides of and one customer's IT department cannot. For the same reason, `active: false` revokes sessions but only marks the *account* disabled when that tenant was the person's last one here. A disabled account is locked out everywhere in the environment, which is not something one of your customers gets to impose on another's staff. Sessions go either way, so offboarding still takes effect immediately. **A delete deactivates rather than erases.** Usage, invoices and the audit trail stay attributable — an offboarded employee is a record an enterprise still needs — and the user remains readable with `active: false`, which is what the next sync expects to find. Actually removing somebody is a [privacy request](/guides/privacy-requests), which is a person's right rather than a directory's routine. **Removing somebody from a group does not remove them from your product.** "No longer an engineer" is a different sentence from "no longer works here", and the directory says the second one separately. ## What is supported [#what-is-supported] * **Users** and **Groups**, with `GET`, `POST`, `PUT`, `PATCH` and `DELETE`. * `ServiceProviderConfig` and `ResourceTypes`, which Okta reads before its first sync. * Filters of the form `attribute eq "value"` — `userName`, `emails.value`, `externalId` and `displayName`. This is what directories send when they look a person up before creating them. Compound filters with `and`, `or`, `co` and `sw` are refused rather than half-answered, because a filter that quietly matches the wrong people is worse than one that fails. * Pagination with `startIndex` and `count`, up to 200 per page. Not supported: bulk operations, sorting, and ETags. No directory needs them for provisioning. ## When a sync appears to have stopped [#when-a-sync-appears-to-have-stopped] The connection list shows **last sync** for each directory. If it is older than you expect: * Check the token has not been revoked — a revoked one is marked as such. * Check the directory is pointed at the right environment. The URL names one, and a token issued for another environment is refused even if it is valid. # Email verification An environment can refuse to finish a sign-in until the person has confirmed their email address. It is off by default. Switch it on under **Settings → Security & sign-up policy → Email verification**, the first section of the policy card. Sandbox and Production each have their own setting; the section says which one you are editing. The section also shows how many people in that environment have not confirmed their address. Those are the people who will see the confirmation page at their next sign-in, so check the number before you save. On the API it is `requireEmailVerification` on the environment's auth policy. ## What a person sees [#what-a-person-sees] Somebody who signs in correctly with an address that is not confirmed is not sent back to your application. The hosted page stops them on **Confirm your email address**, shows the address masked (`r•••@gmail.com`), and emails a new link straight away. * **Send another link** sends a new one. It is throttled to one a minute and five a day for each account, counting the mail sent at sign-up, and the page says the same thing whether or not a mail went out. * Opening the link **in the same browser** finishes the sign-in that was waiting. If the account has a second factor, it is asked for first. The person then lands on your application's `redirect_uri` with its `state`, the same as any other sign-in. * Opening the link **somewhere else**, such as a phone's mail app, confirms the address and offers **Sign in** back into the same application. It does not sign anybody in by itself, because the link proves only the inbox. * **I have confirmed it — continue** checks again, for somebody who opened the link in another tab. The page is shown only after the password, code or passkey was right. Showing it to anybody else would let the form be used to find out which addresses signed up and never confirmed. ## Each sign-in method [#each-sign-in-method] | Method | What happens | | ----------------------------- | ------------------------------------------------------------------------- | | Password | Stopped until the address is confirmed | | Text message | Stopped. A phone code proves the phone, not the inbox | | Passkey | Stopped. A passkey proves the device, not the inbox | | Social sign-in | Not stopped when the provider confirms the address (such as Google) | | Social, provider cannot vouch | Stopped. X, Twitch and Roblox share no verified flag | | Magic link | Never stopped. Opening the link confirms the address | | Emailed second-factor code | Answering a code sent to the account's own address confirms it | | Password reset | Finishing a reset confirms the address | | Enterprise SSO | Not affected. The identity provider decides | | An existing Base316 session | Not used to skip the page. The person signs in again and is stopped there | | A new sign-up on hosted pages | Stopped right after creating the account. The sign-up mail is the link | An account with no email address, such as one created by text message, is never stopped. There is nothing to confirm. When a social provider confirms the address, the account is marked confirmed on every sign-in, including accounts that already existed. Someone who signed up with a password and never opened the mail is confirmed the first time they choose **Continue with Google**. ## On the API [#on-the-api] Your own sign-in screens get the same answer from the same rule. `POST /v1/auth/signin`, `/v1/auth/phone/verify`, `/v1/auth/mfa/verify`, `/v1/auth/mfa/required/complete` and `/v1/auth/passkeys/signin/finish` answer `403` with reason `EMAIL_NOT_VERIFIED` instead of a session. With the SDK this is a `Base316Error` whose `reason` is `"EMAIL_NOT_VERIFIED"`. `` in `@base316/react` already shows it. Confirm the address with `POST /v1/auth/verify-email` and the token from the link. Switching this on stops everybody counted in the section until they open a link. Most will find the mail and carry on. Some addresses will bounce. Look at the count first, and switch it on in Sandbox before Production. # Gate a feature The shape is always the same: **check, work, record.** ```ts import { Base316 } from "@base316/sdk"; const base316 = new Base316(); export async function generateReport(accountId: string, requestId: string) { // 1. Ask before doing the work. const decision = await base316.access.check({ externalId: accountId, feature: "reports", requested: 1, }); if (!decision.allowed) { throw new PaymentRequired(decision.reason); } // 2. Do the work. const report = await buildReport(accountId); // 3. Record it, only now that it succeeded. await base316.events.record({ externalId: accountId, event: "report_generated", idempotencyKey: requestId, }); return report; } ``` ## Turn a refusal into a useful error [#turn-a-refusal-into-a-useful-error] `reason` is safe to log and safe to branch on. Map it to something your user can act on: ```ts const MESSAGES: Record = { USAGE_LIMIT_EXCEEDED: "You have used your monthly reports. Upgrade or add credits.", HARD_CAP_REACHED: "You have reached this month's spending limit for reports.", ENTITLEMENT_REQUIRED: "Reports are not included in your plan.", SUBSCRIPTION_INACTIVE: "Your subscription is not active.", NO_SUBSCRIPTION: "This account has no subscription.", }; throw new PaymentRequired(MESSAGES[decision.reason ?? ""] ?? "Not available on your plan."); ``` ## Warn before the wall [#warn-before-the-wall] `decision.usage` carries `remaining`. Showing it is the difference between a customer who upgrades and one who files a ticket: ```ts const { used, limit, remaining } = decision.usage ?? {}; if (limit !== null && remaining !== null && remaining < limit * 0.1) { notifyNearLimit(accountId, { used, limit, remaining }); } ``` You can also subscribe to the `usage.limit_reached` webhook rather than computing it yourself. ## Overage: when the limit does not stop anyone [#overage-when-the-limit-does-not-stop-anyone] If the plan prices the feature's usage — a per-unit, tiered or package price on it — the limit is **soft**. `check` keeps answering `allowed: true` past it and the extra usage is billed. Tell the user when that starts, because from then on every call costs them money: ```ts if (decision.allowed && decision.usage?.overage) { notifyBilledOverage(accountId, { billable: decision.usage.billableOverage, cap: decision.usage.hardCap, }); } ``` To stop runaway spend, give the entitlement a **hard cap**. Past it, `check` refuses with `HARD_CAP_REACHED` — a different message from `USAGE_LIMIT_EXCEEDED`, because the fix is different: raising a spending limit, not upgrading the plan. Credits never raise the cap. A plan with no usage price on the feature keeps a hard limit, refused with `USAGE_LIMIT_EXCEEDED` exactly as before. ## Gating on a permission as well [#gating-on-a-permission-as-well] For a feature only some users in an account may use: ```ts const decision = await base316.access.check({ externalId: accountId, userId: actingUserId, feature: "reports", permission: "reports.export", }); ``` Both must pass. The account must be entitled and the user must hold the permission. ## Things to get right [#things-to-get-right] * **Check before, record after.** Work that fails should cost nothing. * **Pass `requested`** when you know how much you are about to use, so the refusal happens before the cost. * **Do not treat `allowed` as "within the plan".** On a soft limit it can mean "allowed, and billed" — read `usage.overage`. * **Never cache an allow** across a request boundary. The point of the call is that the answer changes. * **Fail closed on a network error**, or fail open deliberately — but decide. Silently allowing on a timeout is a decision you did not know you made. # Managed tenants 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 [#create-a-client-tenant] Pass the parent's id when you create the tenant. ```ts 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 [#decide-who-reaches-in] A member of the parent gets nothing in the children until their role is mapped. ```ts 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 [#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 [#checking-access] Nothing changes in your checks. Ask about the client exactly as before: ```ts 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: ```json { "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 [#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: ```ts 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 [#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 `seats` feature, as any tenant is. * **Each client** is held to the firm's plan's `client_seats` feature instead, counted per client. So "3 client seats" means up to three members in every client, not three spread across all of them. Leave `client_seats` off 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 [#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.check` and the entitlement snapshot for a client answer from the firm's subscription — and so do the `plan` and `entitlements` claims 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 `breakdown` of which client sent how much. ```ts 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 [#what-a-clients-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 [#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 [#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 [#the-firms-brand-on-the-firms-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. ```ts 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. `null` clears 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 [#in-your-own-screens] The hosted pages pick the brand from the domain. Screens you render yourself with [`@base316/react`](/tools/components#white-label-for-a-tenant) take it from the provider: ```tsx ``` Without React, `billing.tenantBranding({ organizationId })` on `Base316Browser` returns the same resolved branding. ## Letting a client go [#letting-a-client-go] ```ts 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 [#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 ` lists what a firm pays for. Changes made with an API key are recorded in the audit log. # Connect MCP clients and AI connectors An MCP client — a claude.ai or ChatGPT connector, Cursor, an IDE — connecting to your MCP server needs three things from an authorization server: a way to find it, a way to register itself, and a token addressed to your server rather than to anyone else. Base316 provides all three, per environment. ## How a connection goes [#how-a-connection-goes] 1. The client calls your MCP server with no token. Your server answers `401`, pointing at its resource metadata. 2. The client reads that metadata, finds your environment's issuer, and fetches its metadata from `/.well-known/oauth-authorization-server`. 3. It registers itself at `/register` (RFC 7591) and gets a `client_id` — or, if it has a **client ID metadata document**, uses that document's URL as its `client_id` and skips registering. ChatGPT prefers the second. 4. It sends the person to sign in, naming your server with `resource=`. 5. The person signs in on your hosted pages and sees a **consent screen** listing what the client asked for. Nothing is shared until they allow it. 6. The client redeems the code for an access token whose `aud` is your server, carrying only the scopes the person's plan entitles. ## Set it up [#set-it-up] 1. Under **Developers → Protected APIs**, add your MCP server as an API. Its **audience** is the server's URL, exactly — `https://mcp.example.com`. Add the scopes it understands, and tie any paid ones to a feature so the plan decides who gets them. 2. Under **Settings → Security & sign-up policy**, in **Password and session policy**, check **Let AI connectors and other applications register themselves**. It is **on by default in Sandbox** and **off in Production** until you turn it on: registration is an unauthenticated endpoint, and every application it creates is a third party. From code: `base316.authPolicy.update({ allowDynamicClientRegistration: true })` (`null` returns to the environment's default). 3. Serve the resource metadata from your MCP server, and verify tokens. ```ts import { createProtector, protectedResourceMetadata, resourceMetadataChallenge, } from "@base316/sdk"; const resource = "https://mcp.example.com"; const issuer = "https://api.base316.com/oidc/env_1a2b3c"; const protector = createProtector({ audience: resource, issuer }); // GET /.well-known/oauth-protected-resource app.get("/.well-known/oauth-protected-resource", (c) => c.json(protectedResourceMetadata({ resource, issuer, scopes: ["notes.read"] })), ); app.use("/mcp", async (c, next) => { const result = await protector.check(c.req.header("authorization")); if (!result.ok) { // The header is how a client that has never seen you finds Base316. if (result.status === 401) { c.header("WWW-Authenticate", resourceMetadataChallenge(resource)); } return c.json({ error: result.failure.reason }, result.status); } await next(); }); ``` The `resource` the client sends, the API's audience in Base316 and the `audience` you verify against must be the same string. A trailing slash on one of them is enough for every token to be refused. ## Turning on AI connectors [#turning-on-ai-connectors] With registration on, the environment's discovery document (at `{issuer}/.well-known/openid-configuration`, and the RFC 8414 forms `{issuer}/.well-known/oauth-authorization-server` and `https://api.base316.com/.well-known/oauth-authorization-server/oidc/{environmentId}`) advertises `registration_endpoint` and `client_id_metadata_document_supported`. That is all claude.ai and ChatGPT need: add your MCP URL as a custom connector (claude.ai: **Settings → Connectors → Add custom connector**; ChatGPT: **Settings → Apps & Connectors → Advanced → Developer mode → Create**) and the first person to connect is sent to your sign-in and consent pages. | Client | Connects with | Sends people back to | | ------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | | claude.ai, Claude Desktop | registration | `https://claude.ai/api/mcp/auth_callback`, `https://claude.com/api/mcp/auth_callback` | | ChatGPT | metadata document (`private_key_jwt`), or registration | `https://chatgpt.com/connector_platform_oauth_redirect`, or `https://chatgpt.com/connector/oauth/{callback_id}` shown in the app's settings | | Claude Code, Cursor, VS Code, MCP Inspector | registration | `http://127.0.0.1:{port}/…` or `http://localhost:{port}/…`; Cursor also `cursor://…` | Three settings beside the switch narrow it: * **Allowed redirect hosts** — one per line. Empty allows any https address, the loopback interface and native app schemes. Otherwise a redirect must be on a listed host (`claude.ai`), under a wildcard (`*.example.com` — its subdomains, not itself), `localhost` for every loopback address, or a listed app scheme (`cursor:`). It is checked at registration and again at every sign-in, so tightening it also cuts off applications already registered. For claude.ai and ChatGPT only: `claude.ai`, `claude.com`, `chatgpt.com`. * **Most self-registered applications** — 500 by default. Past it, registration is refused until you delete some (**OpenID Connect** lists them, marked *Self-registered*) or unused ones are removed. * Registration is also rate limited: 20 an hour per address and 200 an hour per environment. Every application that connects this way: * is a public client that must use PKCE (S256), or — from a metadata document — a `private_key_jwt` client verified against the document's key set; OAuth 2.1 strict mode is always on; * may use only `authorization_code` and `refresh_token`, never `client_credentials`; * gets a refresh token when it registered the refresh grant and asks for one of your APIs, so the connection outlives your API's short access tokens (the consent screen says "Stay connected when you are not using it"); * may ask only for the scopes your environment's APIs declare, and gets what the person approves and their plan entitles; * gets a token only for a `resource=` registered as one of your APIs. Any other resource is refused with `invalid_target`. A registration's response carries a `registration_access_token` and `registration_client_uri` (RFC 7592), with which the client can read (`GET`) or delete (`DELETE`) its own registration. Updating is not supported; a client that needs different metadata registers again. A metadata document is fetched when the client first sends someone to sign in, through the same outbound protections as a key set, and refreshed after an hour. It must name its own URL as `client_id`, carry no secret, and list redirect URIs that pass the rules above. ## What the person sees [#what-the-person-sees] The consent screen names the application, the host it will return them to, and each scope in plain words — the description you gave the scope, or its key. Give a scope that moves money or deletes things its own key and a description that says so: it is listed as its own line. A self-registered application is marked **Unverified application**: its name and home page are shown as its own claims, and the person is told to allow it only if they started connecting it just now. The answer is remembered. The next sign-in through the same client, for the same or fewer scopes, goes straight through; asking for more asks again. ## Which organization a connection acts in [#which-organization-a-connection-acts-in] A person can belong to several of your tenants (organizations). A token that acted in all of them would let an application connected "for" one company read and change another, so every grant is **bound to one organization**: * Somebody in **more than one** organization picks one on the consent screen. It is preselected from an `organization` (or `org_id`) parameter on the authorize URL when the person is a member of it — otherwise the one their previous approval named, otherwise their oldest. A hint naming somewhere the person is not a member is ignored, and the client is not told. * Somebody in **exactly one** is not asked: the grant is bound to it. * Somebody in **none** gets an unbound grant, as before. * A first-party client (no consent screen) is bound only when the choice is unambiguous — a valid hint, or a single membership — and is otherwise unbound. The choice is stored on the approval, the authorization code and the refresh token, so refreshed tokens keep it. Connecting again with a different hint shows the consent screen again; the new choice replaces the old one. The access token (and the id\_token) carry it as **`org_id`**, the Base316 organization id, and `createProtector` reports it as `principal.organizationId` (SDK 0.7.0 and later). When it is set, act only in that organization: ```ts const result = await protector.check(c.req.header("authorization")); if (result.ok && result.principal.organizationId) { // Look the tenant up by this id, and refuse anything outside it. } ``` **Membership is re-checked whenever the grant is used.** Once the person leaves the organization (or it is deleted), the refresh grant is refused (`invalid_grant`), introspection answers `active: false`, `/userinfo` answers `401` and token exchange refuses the subject token — never a silently unbound token. An access token already issued is verified offline, so it keeps verifying until it expires; check the membership in your own records too if you hold them. **Unbound tokens.** Tokens issued before binding existed, and grants for people in no organization, have no `org_id`; `principal.organizationId` is `null` and introspection says `organization_bound: false`. They keep working. Decide whether your API accepts them — once every client has reconnected, refusing them is the safer setting. **Auth hooks.** A claim rule can already write `org_id` onto the id\_token from the `organization` source. For a bound grant that source now describes the organization the person chose, and the consent choice is written last, so the id\_token and the access token always name the same organization. For an unbound grant the rule behaves exactly as before (the person's first membership). The entitlement claims and `customer_id` describe the bound organization's customer. ## Taking access back [#taking-access-back] A person disconnects an application from their own settings page with `base316Browser.auth.disconnectApp(sessionToken, clientId)`, and your support team can do it from the user's **Security** dialog in the dashboard or with `base316.users.disconnectApp(userId, clientId)`. Disconnecting withdraws the approval and revokes the client's refresh tokens, so it cannot mint another access token. One it already holds works until it expires — the API's token lifetime, fifteen minutes by default. ## Checking a token from somewhere else [#checking-a-token-from-somewhere-else] A service that would rather not verify JWTs itself can ask Base316 at `/introspect`, authenticating as one of your confidential clients. The answer reflects the account as it is now: a token for somebody disabled a minute ago still verifies by its signature, but introspects as inactive. The answer includes `org_id` for a bound token and `organization_bound` for any token issued to a person. ## Housekeeping [#housekeeping] A client that registers itself and is never approved by anyone is removed after thirty days. An approved one is kept however old it is — removing it would disconnect whoever approved it. # Migrating from another provider Base316 reads exports from **Chargebee**, **Lago**, **Stripe**, **Clerk** and **Auth0**, and its own CSV format. ## The rule that matters [#the-rule-that-matters] **Importing a subscription must not bill it.** An account mid-cycle at its old provider has already paid for this period. So `current_period_end` is taken from your file, and the billing run picks the subscription up at its **real** renewal date — never on the day the import ran. Recomputing the period from the import date would invoice a migrated customer twice in one month. That is the single most expensive mistake available here, and it is the one the importer is built around. ## Order matters [#order-matters] 1. **Rebuild your catalog by hand.** Plans, features and meters are not imported — pricing models differ enough between providers that a translation would be a guess, and a wrong plan is worse than no plan. 2. **Import customers.** 3. **Import subscriptions.** They match customers by `external_id`, so a subscription for an unknown customer is reported rather than inventing one — which would hide a mis-ordered migration until the invoices came out wrong. ## Every import previews first [#every-import-previews-first] The dashboard's **Import & export** tab checks the file against your real data and shows what would happen, row by row, before anything is written: ``` chargebee.csv — nothing written yet CHARGEBEE 1 to create · 1 to update · 1 skipped · 2 with problems Row Outcome Reason 4 SKIP Repeated earlier in this file. 5 PROBLEM A customer needs an external_id or an email to be matched on. 6 PROBLEM "DOLLARS" is not a three-letter currency code. ``` Row numbers count the header as row 1, so they match your spreadsheet. ## The mapping is shown, and can be corrected [#the-mapping-is-shown-and-can-be-corrected] Provider presets are a **starting point, not an authority** — providers rename columns between versions and exports differ by account settings. So the mapping is displayed before anything is written: ``` external_id customer id name first name + last name currency preferred currency code net_term_days net term days Not imported: vat number ``` Columns that matched nothing are named rather than dropped. If a column is mapped wrongly, override it and re-run — nothing has been written yet. A file missing a required column fails **once**, up front, rather than once per row. ## Re-running is safe [#re-running-is-safe] Nobody migrates in one attempt. A file is run against sandbox, corrected, run again, and finally run against production. Rows are matched on `external_id`, so a row that already exists is **updated**, not duplicated. Existing subscriptions are **skipped** rather than updated — silently moving somebody's renewal date on a re-run is worse than reporting it. ## Identity providers are different [#identity-providers-are-different] Clerk and Auth0 have no billing to migrate, so their exports land on your **users**, not your customers. Mapping them onto customers would create billable accounts for people who were never billed. ### Your users keep their passwords [#your-users-keep-their-passwords] This is the part most migrations get wrong, because most platforms make it impossible. Include a `password_hash` column and nobody has to reset anything: no reset email to your whole user base on a day they were not expecting one, and no churn among the people who ignore it. bcrypt, argon2, PBKDF2 and bare MD5/SHA digests are all read as they are. Add `password_hash_format` if your export says which it used; otherwise it is detected from the shape of the hash. ```csv email,name,password_hash ada@globex.test,Ada Lovelace,$2a$10$… ``` Each hash is stored exactly as it arrived and replaced with Base316's own the first time that person signs in. After one login cycle a migrated table holds no foreign hashes at all. A hash that cannot be read **fails its row** rather than importing the person without it. The alternative looks like success and produces somebody who can never sign in and is never told why. Run a dry run first — it reports those rows while your file is still open. Auth0 releases password hashes on request, separately from the bulk user export, so the two are usually combined by hand into one file before importing. The column names vary depending on who did the combining, and the preset accepts the common spellings. A row with **no** `password_hash` leaves an existing credential alone, so re-running an import to correct a name cannot lock anybody out. ## Back-dated subscriptions [#back-dated-subscriptions] Two columns carry a migrated account's history: * **`started_at`** — when the account first subscribed. It becomes the subscription's creation date, so tenure, cohorts and revenue reports start where they really did. Chargebee's `Started At` and Stripe's `Start Date (UTC)` map to it. * **`current_period_start` / `current_period_end`** — the term it is in now. If the file's current term has **already ended** — an export taken a while ago, or an account whose renewal the old system never ran — importing it as it stands would have the billing run invoice every period since, one renewal at a time. Such a row is refused, naming the option. Import with **`skipPastInvoices`** (the **Don't invoice periods that already ended** box on the import screen, or `"skipPastInvoices": true` on `POST /v1/import/subscriptions`) and the period is rolled forward along the plan's cadence to the one containing today, with no invoice for anything before it — the old provider billed those. The row says how many periods were rolled forward. The billing run then renews it at that period's real end, exactly like an account imported mid-term. `purchase_order_number`, `invoice_memo` and `billing_anchor_day` set the subscription's [contract terms](/concepts/billing#contract-terms-po-number-memo-and-billing-day); Chargebee's `PO Number` and `Invoice Notes` map to the first two. ## Only live subscriptions import [#only-live-subscriptions-import] `ACTIVE` and `TRIALING`. A cancelled subscription is skipped — importing one would create an entitlement for an account that is not entitled. ## Getting your data out [#getting-your-data-out] Customers, subscriptions and invoices export as CSV from the same screen, or from the API: ``` GET /v1/export/customers GET /v1/export/subscriptions GET /v1/export/invoices ``` Real CSV with a `Content-Disposition`, so `curl -O` and a browser both do the obvious thing. Being able to leave is not a privileged action and export needs only read access. # Saved cards Invoices are collected and auto-recharge buys credits with the customer's **default card**. Exactly one card is the default. A card reaches Base316 in one of three ways, and all three store it the same way (`saveDefaultCard`): * **Subscription checkout** saves the card the subscription was bought with. * **A card update** on Stripe's hosted page — a Checkout Session in setup mode, on your connected account. The customer starts it from the billing portal's **Payment method** section or the credits page's **Card on file**. * **The pay page**, when the payer ticks **Save this card for future payments**. Stripe collects the card and runs any verification it needs (3-D Secure, a bank's own app). Base316 never sees the number, and none of its own pages has a card field: there is no card form to build, and no SetupIntent to confirm. ## Replacing a card [#replacing-a-card] Send the customer to a hosted page with a single-use link from your server: ```ts // The whole billing portal: plan, invoices, billing details and the card. const billing = await base316.billingPortal.createSession(customerId, { returnUrl: "https://app.example.com/billing", }); // Or only the credits page: balances, packs, auto-recharge — and the card // those charge. It cannot open the billing portal. const credits = await base316.creditsPortal.createSession(customerId); ``` Both pages show the card on file and an **Update card** button. It sends the customer to Stripe; when they finish, Base316 reads the result back from Stripe, checks it belongs to this customer and environment, makes it the default, and returns them to the page they came from. A cancelled update changes nothing. The billing portal also has a **Payment methods** section (`billingPortal.createSession(customerId, { page: "payment-methods" })` lands on it) listing every card on file, where the customer can **Make default**, **Remove**, or **Add a card** through the same Stripe-hosted update, which comes back to the section. The credits page shows the button beside the two auto-recharge warnings that a card fixes — "no card on file" and "turned off after two declines" — so a customer you send there to fix auto-recharge never needs billing access. In an application of your own, `/v1/me/payment-method/update-session` starts the same card update for the signed-in user (see the [billing portal guide](/guides/billing-portal)). ## Cards on file [#cards-on-file] | SDK | REST | | ------------------------------------------- | ------------------------------------------------------ | | `paymentMethods.list(customerId)` | `GET /v1/customers/:id/payment-methods` | | `paymentMethods.setDefault(customerId, id)` | `POST /v1/customers/:id/payment-methods/:pmId/default` | | `paymentMethods.remove(customerId, id)` | `DELETE /v1/customers/:id/payment-methods/:pmId` | Secret key only. Removing a card detaches it at Stripe first; if Stripe refuses, the card stays on file so nothing claims it is gone when it is not. Removing the default promotes the newest remaining card, so a subscription does not fall into dunning because an old card was tidied away. In the dashboard, each customer's page has a **Payment methods** section with the cards on file, **Make default**, **Remove**, and **Copy billing portal link** / **Open as customer**. The auto-recharge panel offers **Copy credits page link** when it needs a card. Operators never type a card in. A managed tenant billed to its parent has no cards of its own: its parent's card pays, and neither page offers it a card update. ## Saving the card on the pay page [#saving-the-card-on-the-pay-page] The hosted pay page (`/pay/...`) has a **Save this card for future payments** checkbox, off by default. Ticked, the payment is set up for off-session use on the invoice's customer, so Stripe saves the card with the payment. Base316 records the card — as the default — when Stripe reports the payment succeeded, not when the browser says so. It is not offered on an invoice billed to a parent account. # Move subscribers to a new plan version A subscription points at the **plan version** it was sold on, and versions are immutable, so publishing new pricing never reprices anybody. The other side of that rule: publishing a *better* version — a new feature, a higher allowance, the same price — reaches nobody already subscribed until you move them. That move is a **migration**. ## Lateral, or not [#lateral-or-not] Base316 compares the version a subscriber is on with the one you are moving them to: | Classification | Means | | -------------- | -------------------------------------------------------------------------------------------------------- | | `LATERAL` | Every price identical, and nothing taken away: no feature lost, no limit lowered. | | `PRICE_CHANGE` | Any price differs, up or down. | | `REDUCTION` | A feature lost, a limit lowered, or a hard cap, reset interval or rollover changed against the customer. | A version that reprices *and* takes something away is a `REDUCTION`. ## Policies [#policies] | Policy | What happens to subscribers of older versions | | ----------------------- | ------------------------------------------------------------------------------------------------------ | | `GRANDFATHER` | They stay where they are. The default. | | `MIGRATE_NOW` | Moved now, **in place**: same subscription id, same period, nothing billed. Lateral only. | | `MIGRATE_AT_PERIOD_END` | Moved at each subscription's next period boundary. Anything not lateral needs `allowNonLateral: true`. | | `AUTO` | `MIGRATE_NOW` when the change is lateral, otherwise `GRANDFATHER`. | A lateral move does not restart the billing period, issue an invoice or credit anything — the customer is simply entitled to more from that moment. Usage already recorded this period is rated on the new version, which charges the same or less by definition. A trialing subscription moves at once under either timing: nothing is charged during a trial, so moving now and at conversion bill the same. **Skipped, with the reason recorded:** a subscription with a pending schedule (a plan change, a ramp or an earlier migration), one cancelling at period end, one in another currency, and one whose custom prices cannot be matched to the new version exactly. ## On publish: what a catalog sync wants [#on-publish-what-a-catalog-sync-wants] Pass `propagation` when you create or publish a version. `AUTO` is the setting for a script that keeps your catalog in step with code: every same-price improvement reaches existing customers, and every price change leaves them alone. ```ts await client.catalog.plans.createVersion(planId, { currency: "USD", interval: "MONTH", prices: [{ model: "FLAT", amount: "99.00" }], entitlements, propagation: "AUTO", }); ``` The answer's `propagation` says what happened (`migrated`, `skipped`, and the migration id). Set it once for a plan instead with `defaultPropagation`: ```ts await client.catalog.plans.update(planId, { defaultPropagation: "AUTO" }); ``` Only `AUTO` and `MIGRATE_NOW` act on publish. A period-end move can change what somebody pays, so it always goes through a preview. ## Preview, then migrate [#preview-then-migrate] Every migration is confirmed against a preview made in the last fifteen minutes, so what moves is exactly what you saw. ```ts const preview = await client.catalog.plans.previewMigration(planId, versionId, { policy: "MIGRATE_AT_PERIOD_END", allowNonLateral: true, }); // preview.versions — subscribers per old version, and how many move, wait or skip // preview.items — per subscription: entitlement changes, feeDelta, outcome, reason const run = await client.catalog.plans.migrateSubscribers(planId, versionId, { policy: "MIGRATE_AT_PERIOD_END", allowNonLateral: true, previewToken: preview.previewToken, }); ``` If a subscriber joined or changed since the preview, the migration is refused with `CONFLICT` — preview again. Up to 500 subscriptions move before the call returns; a larger migration answers `PENDING` and finishes in the background. Follow it with `client.catalog.migrations.get(run.migrationId)`. ## Finding subscribers left behind [#finding-subscribers-left-behind] `GET /v1/subscriptions/outdated` lists every subscription not on its plan's latest published version, with how its gap classifies. `LATERAL` rows are customers being refused features they already pay for. Pass `subscriptionIds` to the preview and the migration to move exactly those. ```ts const { data } = await client.subscriptions.listOutdated({ planId }); const lateral = data.filter((row) => row.classification === "LATERAL"); ``` ## Webhooks [#webhooks] * **`subscription.plan_version_changed`** — a subscription moved onto a newer version, now or at its period end: `previousPlanVersionId`, `planVersionId`, `timing`, `classification`, `migrationId` and the entitlement changes. The subscription id is unchanged, unlike `subscription.updated` with `change: "plan_changed"`, which is a customer changing plans. * **`entitlement.updated`** — what a customer is entitled to changed. Carries the customer, not the entitlements: fetch them, or refresh any token carrying the `entitlements` scope (those claims last at most five minutes). * A period-end move is announced when scheduled as `subscription.updated` with `change: "plan_version_change_scheduled"`. Every move is also in the audit log, and every migration keeps a record of what happened to each subscription in it. # Privacy requests Two things a privacy notice promises and most products never build: a copy of what you hold, and deletion. Both have endpoints here, and both come in two shapes — one the person can use themselves, and one for answering a request that arrived by email from somebody who cannot sign in. That second case is most of them, because people who ask to be forgotten have usually already stopped using the product. ## A copy of everything [#a-copy-of-everything] ```ts // The person, with their own session. const data = await client.auth.privacy.export(sessionToken); ``` ```http # You, on their behalf. GET /v1/users/{userId}/data Authorization: Bearer b316_sk_… ``` `` in `@base316/react` is the download-and-delete pair ready to drop into an account page. In the dashboard it is **Export data** on the user's Security panel, which hands you a file. The export carries the account, its sessions, the security log, passkeys, linked social accounts, second factors, tenant memberships and identity checks. ### What it will not hand over [#what-it-will-not-hand-over] Credential material — password hashes, authenticator secrets, session digests, unspent reset tokens. These are not the person's data in any useful sense. They are the things that *authenticate* them, and handing them to whoever passed an identity check weakens the account rather than informing its owner. The export **says** it is holding them back, and what they are, in a `withheld` list. Silently omitting them would be the worse answer: somebody is entitled to know what is held about them, which is not the same as being handed the keys to their own account. ## Forgetting somebody [#forgetting-somebody] ```ts await client.auth.privacy.erase(sessionToken); ``` ```http DELETE /v1/users/{userId} Authorization: Bearer b316_sk_… ``` Irreversible. Confirm it in your own interface first — `` asks the person to type a word, because it is the one control on an account page that cannot be undone. This removes the account and everything that hangs off it: credentials, password history, second factors, passkeys, sessions, verification tokens, linked social accounts, refresh tokens, memberships, identity checks, unspent authorization codes and approved device sign-ins. ### What survives, and why [#what-survives-and-why] The response is a **receipt**, and it is the point of the return value rather than a courtesy. Keep it. Answering a regulator months later from memory is how a truthful process stops being one. **Invoices are kept.** Tax law requires them for years after they are raised, and GDPR Article 17(3)(b) exempts data held to meet a legal obligation. Deleting them to satisfy a data subject would break a different law. The tenant they hang from is stripped of names and addresses instead, so the records stay auditable without staying personal. **Only a tenant the person was the last member of.** A signup makes a tenant of one, and stripping that is right. A tenant they were invited into, or provisioned into over SSO, belongs to other people — so it keeps its name and its billing contact, and only the membership goes. Erasing yourself out of your employer's account must not rename your employer. **The security log is kept, with the person detached.** The trail of a break-in is not the intruder's to erase, and with no user attached it is no longer about an identifiable person. The receipt names both, with the reason that outranked the request: ```json { "erasedAt": "2026-09-16T12:00:00.000Z", "erased": ["RuntimeUser", "RuntimeCredential", "..."], "retained": [{ "model": "Invoice", "count": 7, "because": "Tax law requires invoices…" }], "anonymised": [{ "model": "RuntimeAuthEvent", "count": 214, "because": "A security trail…" }] } ``` ## Erasure is not deprovisioning [#erasure-is-not-deprovisioning] If a directory removes somebody, that is a [SCIM deactivation](/guides/directory-sync) — the account is disabled and the record stays, because an offboarded employee is something an enterprise still needs to be able to account for. Erasure is a person exercising a right over their own data. The two look similar from the outside and mean entirely different things; do not wire one to the other. # Relationship-based authorization Roles answer *what may this person do anywhere in the tenant*. Some questions are about one object: may Anne edit **this** document, may Bob see **this** folder because his team was given it. Base316 answers those from an **authorization model** and the **relationships** your application records, using [OpenFGA](https://openfga.dev) underneath. You never run or reach OpenFGA yourself. Every environment has its own model and its own relationships: nothing written in Sandbox can answer a Production check, and no other workspace can see yours. All of `/v1/fga` needs a **secret key**, including checks. A check reveals who can see what; with a publishable key, any visitor to your site could ask. Call it from your server, which knows who is signed in. ## 1. Write a model [#1-write-a-model] A model declares types, the relations on them, and how relations imply each other. It is written in OpenFGA's modelling language: ```text model schema 1.1 type user type group relations define member: [user, group#member] type folder relations define viewer: [user, group#member] type document relations define parent: [folder] define owner: [user] define editor: [user, group#member] or owner define viewer: [user, group#member] or editor or viewer from parent ``` * `[user, group#member]` — who can be given the relation directly: a person, or everyone who is a member of a group. Groups can contain groups. * `or owner` — owners are editors without being written as editors. * `viewer from parent` — viewers of the parent folder view the document. Save it in the dashboard under **Settings → Fine-grained access**, which validates as you go and shows each problem with its line and column, or through the API: ```ts import { Base316 } from "@base316/sdk"; const base316 = new Base316(); await base316.fga.writeModel({ dsl: MODEL }); ``` `writeModel` also takes `{ model }` in OpenFGA's JSON form. A model that does not validate is refused with `INVALID_REQUEST`, and `error.message` names the first problem by line. Saving replaces the model; relationships you already wrote are kept. ## 2. Record relationships [#2-record-relationships] A relationship is a tuple — `user` has `relation` to `object`: ```ts await base316.fga.write({ writes: [ { user: "user:anne", relation: "member", object: "group:eng" }, { user: "group:eng#member", relation: "editor", object: "document:roadmap" }, { user: "folder:plans", relation: "parent", object: "document:roadmap" }, ], }); ``` * A call holds up to **100** tuples across `writes` and `deletes`, and is applied atomically. * Writes and deletes are **idempotent**: writing a tuple that exists, or deleting one that does not, succeeds. Retrying after a timeout is safe. * Use your own ids. `user:anne` is whatever identifies Anne in your system. ## 3. Ask [#3-ask] ```ts const { allowed } = await base316.fga.check({ user: "user:anne", relation: "viewer", object: "document:roadmap", }); ``` Anne is a member of `eng`, `eng` edits the roadmap, and editors are viewers — so this is allowed without anybody writing "Anne may view the roadmap". `listObjects` answers the other way round — every document Anne can view — and `listUsers` lists who holds a relation: ```ts const { objects } = await base316.fga.listObjects({ user: "user:anne", relation: "viewer", type: "document", }); const { users } = await base316.fga.listUsers({ object: "document:roadmap", relation: "editor", userFilters: [{ type: "user" }], }); ``` The tester under **Settings → Fine-grained access** asks all three questions — a check, list objects and list users — against the environment's saved model, so you can try a model out before writing any code. ### Contextual tuples [#contextual-tuples] A relationship that holds only for this request — the group a session token claims, say — can ride on the check without being stored: ```ts await base316.fga.check({ user: "user:anne", relation: "viewer", object: "document:roadmap", contextualTuples: [{ user: "user:anne", relation: "member", object: "group:auditors" }], }); ``` Up to 50 per call. ## Plan, permission and relationship in one call [#plan-permission-and-relationship-in-one-call] `access.check` can ask all three at once. It is allowed only when every part passes: ```ts const decision = await base316.access.check({ externalId: accountId, // the billing account: is "reports" on its plan? feature: "reports", externalUserId: "anne", // the person relation: "editor", // ...and may they edit this report? object: `report:${reportId}`, }); if (!decision.allowed) { // "RELATION_NOT_GRANTED" when only the relationship failed. throw new Forbidden(decision.reason); } ``` The relationship is checked for `user:` (or `user:`), exactly as sent. Pass `subject` to check for something else, such as `group:eng#member`. A relationship alone needs no billing account. ## Errors [#errors] | Code | When | | --------------------- | ---------------------------------------------------------------------------- | | `INVALID_REQUEST` | A model that does not validate, a type or relation the model does not define | | `INVALID_REQUEST` | `param: "model"` — no model has been written for this environment yet | | `SECRET_KEY_REQUIRED` | Any `/v1/fga` call with a publishable key | | `SERVICE_UNAVAILABLE` | The authorization service did not answer; retry with backoff | ## Personal data [#personal-data] Tuples hold whatever ids you write into them. When you erase a user, delete their tuples too — read them with `fga.tuples({ user: "user:anne", object: "document:" })` for each type, then `fga.write({ deletes })`. Base316's own erasure does not reach into your relationships, because it cannot know which of your ids is that person. # Reseller mode Your product has **firms** (parent organizations) that manage **clients** (their child organizations — see [Managed tenants](/guides/managed-tenants)). An accounting firm runs the books for twenty businesses; an agency runs fifty brands. Without reseller mode you bill the firm, and the firm bills its clients however it likes, outside your product. With reseller mode the firm is the **merchant of record for its clients**: * The firm connects **its own Stripe account**, through your product. * Every charge to one of the firm's clients — invoices, saved cards, pay links, credit packs, auto-recharge — is made **on the firm's account**. The money lands in the firm's Stripe balance, and disputes are the firm's. * You keep billing the firm **wholesale**, on your own account, as today. You take no application fee on the firm's client payments. Base316's own processing fee, where your Base316 plan has one, applies to them as it does to your own charges. ## How the accounts fit together [#how-the-accounts-fit-together] Your environment's account is a Standard connected account of Base316's Stripe platform. Stripe does not let a connected account have connected accounts of its own, so a firm's account cannot sit *under* yours. It sits **beside** it: another Standard connected account of the same platform, owned by the firm. ```text Base316 platform ├── your environment's account ← the firm pays you here (wholesale) ├── Ledger & Co's account ← Ledger & Co's clients pay Ledger & Co here └── Hartley Partners' account ← Hartley's clients pay Hartley here ``` Who is charged where is decided by one rule: | Customer | Charged on | | ------------------------------------------------ | -------------------------- | | A client of a firm that has connected an account | The firm's account | | Everyone else, including the firm itself | Your environment's account | A client whose firm has connected is **never** charged on your account, even while the firm's onboarding is unfinished or Stripe has restricted it: the charge is refused (`NO_PAYMENT_ACCOUNT`) until the firm's account can take it. A card or Stripe customer the client had on your account before its firm connected is not used on the firm's; the client adds a card again. ## Turn it on [#turn-it-on] In the dashboard: **Settings → Payments & failed payments → Reseller mode**. Or with the SDK: ```ts await base316.reseller.update({ enabled: true, // Optional: only firms whose plan grants this BOOLEAN feature may connect. featureKey: "resale", }); ``` Turning it off stops **new** firms connecting. A firm already connected keeps its account and keeps charging its clients on it, because their Stripe customers and cards live there. ## Connect a firm [#connect-a-firm] Onboarding is done by a person at the firm, on Stripe's hosted page, and they come back to **your** application. Build a "Connect Stripe" button in your firm settings that calls your server, which calls: ```ts const { onboardingUrl, account } = await base316.reseller.connect(firmOrganizationId, { // Your own pages. Both must be allowed redirect URLs for the environment // (Authentication → Redirect URLs). returnUrl: "https://app.example.com/firm/settings/payments?stripe=return", refreshUrl: "https://app.example.com/firm/settings/payments?stripe=refresh", country: "US", email: "owner@ledger.example", }); // Send the firm's person to `onboardingUrl`. ``` * Only a **parent** organization can connect; a client is refused (409). * Calling `connect` again for a firm already connected returns a **fresh link** for the same account (200) rather than creating a second one (201). Stripe's links are single-use and expire; `onboardingLink(firmId, { returnUrl, refreshUrl })` mints another, which is what your `refreshUrl` page should do. * `paymentAccount(firmId)` reports `ready` — whether Stripe will accept a charge on it now — and what Stripe still asks for (`requirementsDue`, `currentDeadline`). It is kept current by Stripe's `account.updated`; call `refresh(firmId)` if a webhook was missed. ### Show the firm its Stripe account [#show-the-firm-its-stripe-account] Mount Stripe's [Connect embedded components](https://docs.stripe.com/connect/supported-embedded-components) (the requirements banner, payments, payouts) in your firm settings with an Account Session for the firm's account: ```ts // On your server, when the components mount: const { clientSecret, publishableKey } = await base316.reseller.accountSession(firmOrganizationId); ``` Verification stays entirely on Stripe's side: the hosted onboarding and the banner component satisfy every requirement Stripe can raise. ## Webhooks [#webhooks] * `payment_account.requirements_due`, `.charges_disabled` and `.verified` fire for firms' accounts too, with `organizationId` set to the firm (null for your environment's own account). * `payment.recorded` and `payment.failed` carry `resellerOrganizationId` when the payment was taken on a firm's account. ## API [#api] | Method and path | What it does | | ------------------------------------------------------------ | --------------------------------------------------- | | `GET /v1/reseller`, `PUT /v1/reseller` | Read or change reseller mode | | `GET /v1/reseller/accounts` | Every firm's account, with its client count | | `GET /v1/organizations/:id/payment-account` | A firm's account and whether it is `ready` | | `POST /v1/organizations/:id/payment-account` | Connect (or resume onboarding) and get the URL | | `POST /v1/organizations/:id/payment-account/onboarding-link` | A fresh onboarding URL | | `POST /v1/organizations/:id/payment-account/refresh` | Pull the account state from Stripe | | `POST /v1/organizations/:id/payment-account/session` | An Account Session for Stripe's embedded components | All of them need a **secret** key: an account's requirements name the verification a business still owes Stripe. ## Sell to clients: the firm's own catalog [#sell-to-clients-the-firms-own-catalog] Once connected, a firm can sell its clients **plans and credit packs of its own**, priced in its account's currency and charged on its account. Your application builds the screens; the firm never sees Base316. ```ts const plan = await base316.reseller.clientPlans.create(firmOrganizationId, { name: "Bookkeeping Plus", interval: "MONTH", entitlements: [{ featureKey: "ai_credits", limit: 500 }, { featureKey: "advanced_reports" }], prices: [ { model: "FLAT", amount: "79" }, // the monthly fee { model: "PER_UNIT", featureKey: "ai_credits", amount: "0.05" }, // overage; omit for none ], }); await base316.reseller.clientPacks.create(firmOrganizationId, { name: "1,000 AI credits", featureKey: "ai_credits", units: 1000, amount: "25.00", }); ``` **A firm sells only what it has.** A client plan may include only features the firm's own plan grants, and no more of a metered or quantity feature than the firm's plan allows — unlimited only where the firm's is. A refusal names every offending feature (`LIMIT_EXCEEDS_FIRM_PLAN`, `FEATURE_NOT_IN_FIRM_PLAN`). New terms (`clientPlans.update(firmId, planId, { terms })`) publish a new version; clients already on the plan keep theirs. A firm's plans and packs are **its clients' only**: they never appear in your own plan list, pricing table or checkout, another firm's client cannot buy them, and a client on one can move only between its firm's plans. Your own plans and packs stay refused to a firm's client (`BILLED_BY_PARENT`): its firm sells to it. ### Put a client on a plan [#put-a-client-on-a-plan] ```ts // Hosted checkout, in the firm's brand, the card saved on the firm's account: const { url } = await base316.reseller.clientCheckout(clientOrganizationId, { planId: plan.id, returnUrl: "https://app.example.com/billing?done=1", }); // Or assign it directly (the firm invoices the client, or has its card): await base316.reseller.assignPlan(clientOrganizationId, { planId: plan.id }); // The client's own billing page — its plan, the firm's packs, cards, invoices: const portal = await base316.reseller.clientPortal(clientOrganizationId, { returnUrl }); ``` A client on one of its firm's plans is then a payer in its own right on the firm's account — subscription, invoices, cards and credits — while its firm keeps being billed **wholesale** for it exactly as before: its usage still counts in the firm's pool, and the per-client add-on still counts it. ## What a client may use: the smaller of two plans [#what-a-client-may-use-the-smaller-of-two-plans] A client on one of its firm's plans is decided twice on every `check()`: against **its own plan** (its allowance, then the packs it bought from the firm, then overage where the firm's plan for it prices it), and against **the firm's plan** (the shared pool, as before). It may go ahead only when both say yes. ```ts const decision = await base316.check({ customerId: clientCustomerId, feature: "ai_credits" }); if (!decision.allowed && decision.limitedBy === "client") { // The client's own plan is used up: offer the firm's packs or an upgrade. } if (!decision.allowed && decision.limitedBy === "firm") { // The firm's pool is out: only the firm can fix it. } ``` `usage` is the client's own snapshot, with `remaining` capped at what the firm has left; `firmUsage` is the firm's. Token claims (`entitlementSnapshot`) carry the same smaller answer. A client of a firm that sells it no plan is decided by the firm's plan alone, as before. ## Two prices for every usage event [#two-prices-for-every-usage-event] Every event a client sends is stored once — against the firm, with the client as its source — and rated twice: | | Retail | Wholesale | | ------------- | ----------------------------------------- | ---------------------------------------- | | Rated against | the client's plan from the firm | the firm's plan from you | | Usage | the client's events only | every client's and the firm's (the pool) | | Credits drawn | packs the **client** bought from the firm | credits the **firm** holds | | Invoice | the client's, on the firm's account | the firm's, on yours | The two ledgers never meet: a pack a client buys never raises the firm's pool, and the firm's credits never cover a client's retail overage. A [MATRIX price](/concepts/billing#per-model-pricing-matrix) — a unit price per AI model or region — rates the same way on each side: a firm's client plan may use one (`{ model: "MATRIX", featureKey, rows: [{ dimensions: { model: "opus" }, unitAmount: "0.10" }] }`), and its rows are applied to the client's own events, while your plan's rows are applied to the firm's whole pool. ### Margins [#margins] ```ts const margins = await base316.reseller.margins(firmOrganizationId); // margins.clients: [{ name, plan, retail, wholesale: { usage, clientFee, total }, margin }] ``` Per client, what the firm charges it against what you charge the firm for it. `basis: "current"` (the default) rates this period as the next invoices will; `basis: "invoiced"` with `from`/`to` totals invoices issued in the window. A client's wholesale share is its part of the firm's usage charges, by usage, plus an even share of the per-client add-on. Before tax and discounts. ## Branding and failed payments [#branding-and-failed-payments] A client's invoices, credit notes, pay page, hosted checkout, billing portal and dunning emails carry the **firm's** tenant branding (see [Managed tenants](/guides/managed-tenants)), because the firm is the merchant. When a client's payment to its firm fails, the firm decides what happens: ```ts await base316.reseller.updateSettings(firmOrganizationId, { clientPaymentFailure: "pause_ai", // or "pause_client", or "none" (the default) }); ``` While the client's subscription is past due, its `check()` carries `clientPaymentEffect: "pause_ai" | "pause_client"`. Base316 does not enforce it — only your application knows which features are AI — so act on it there. `payment.recorded` and `payment.failed` for a client's charge carry `resellerOrganizationId`, `clientOrganizationId` and `customerId`. **Refunds and tax stay on the firm's account.** A credit note that refunds a client's card payment sends the refund through the account the payment was taken on — the firm's — and the money comes out of the firm's Stripe balance. With Stripe Tax on, a client's invoices are taxed on the firm's account, under the firm's own registrations, because the firm is the seller: the firm needs Stripe Tax set up in its own Stripe dashboard, or its clients' invoices stay drafts with the reason until it is. ## Fees [#fees] You take no cut of a firm's client payments; you bill the firm wholesale. What Base316 itself takes from a charge on a firm's account is shown on `GET /v1/reseller` as `applicationFeeMode`: `inherit` (your plan's Base316 fee, as on your own payments — the default), `exempt`, or `custom`. Base316 sets it. ## API [#api-1] | Method and path | What it does | | ----------------------------------------------------------------- | --------------------------------------- | | `GET /v1/reseller/catalogs` | Every firm's plans and packs, read-only | | `GET`, `POST /v1/organizations/:firm/client-plans` | A firm's client plans | | `PATCH /v1/organizations/:firm/client-plans/:planId` | Rename, or publish new terms | | `POST /v1/organizations/:firm/client-plans/:planId/archive` | Take off sale | | `GET`, `POST /v1/organizations/:firm/client-packs` | A firm's client credit packs | | `PATCH /v1/organizations/:firm/client-packs/:packId`, `…/archive` | Change or take off sale | | `GET`, `PATCH /v1/organizations/:firm/reseller-settings` | Failed-payment setting | | `GET /v1/organizations/:firm/margins` | Per-client margins | | `PUT /v1/organizations/:client/client-plan` | Assign a firm plan to a client | | `POST /v1/organizations/:client/client-checkout` | Hosted checkout for a client | | `POST /v1/organizations/:client/client-portal` | Billing-portal link for a client | All need a **secret** key. ## Not yet [#not-yet] * Auto-recharge for a firm's clients (they buy the firm's packs by hand). * Links on the firm's own verified domain: the checkout and portal URLs are on Base316's host, in the firm's brand. * Connecting a firm's **existing** Stripe account (OAuth) rather than creating a new one. * Environments that bring their own Stripe credential: reseller mode is refused there (`OWN_PROCESSOR`). # Retention offers A subscriber clicking **Cancel** is the last chance to keep them. A retention offer turns that click into a short flow: 1. **Why are you leaving?** An optional survey: one of seven reasons, plus a comment. 2. **Before you go.** When the environment makes an offer and the subscriber qualifies, they are offered a discount, say 50% off their next three invoices. 3. **Stay or leave.** Accepting applies the discount and the subscription carries on. Declining cancels at the end of the period, as before. Every attempt is recorded, whichever way it went, so the dashboard can show how many people tried to leave, why, how many stayed, and the monthly revenue they represent. The hosted billing portal and `` run this flow without any code from you. To build your own cancel screen, use the two-step API below. ## Set up the offer [#set-up-the-offer] The offer is one of your existing coupons (**Product → Coupons**). It is not a separate kind of discount, so it is rated, counted against the coupon's `maxRedemptions` and printed on invoices exactly like any other subscription discount. In the dashboard, open **Settings → Retention offers**, choose a coupon and turn on **Make an offer**. Or from your server: ```ts await base316.retention.update({ enabled: true, couponId: "cpn_...", // Optional: replace the default wording, which states the offer's terms. headline: "Before you go", message: "Stay on your plan and get 50% off your next 3 invoices.", }); ``` Making an offer needs the **Pro** plan or above. The survey and the report work on every plan, so a workspace that upgrades already has its history. ## Who is offered what [#who-is-offered-what] A subscriber is offered the discount only when all of these hold: * the offer is on, and its coupon is active, unexpired, not used up and, for a fixed amount, in the subscription's currency; * the subscription is not free; * the subscription has **no discount already**. The offer never replaces one, because doing that could quietly swap a better deal for a worse one; * the customer has **not accepted an offer in the last `cooldownDays`** (365 by default). Without this limit, Cancel becomes the way to get a discount. When no offer is made, the attempt records why (`ineligibleReason`) for your reporting. Never show that reason to the subscriber; they see no offer, and cancelling works as it always has. An offer can be accepted for **24 hours** after it is shown. A tab left open for a week must not redeem a coupon that has since been withdrawn. **Leaving is never blocked.** The offer is shown, never imposed: confirming works whether an offer was made, expired or ignored, and "No thanks, cancel" sits beside "Accept offer and stay" rather than behind it. ## Build your own cancel screen [#build-your-own-cancel-screen] ### In the browser, for a signed-in user [#in-the-browser-for-a-signed-in-user] ```ts // 1. Start: records the reason, cancels nothing. const attempt = await base316.billing.startCancellation(token, { reason: "TOO_EXPENSIVE", // optional comment: "Over budget this quarter", // optional }); if (attempt.offer) { // 2. Show attempt.offer: its type, percentOff or amountOff, duration and // durationInPeriods, plus your headline and message. // The coupon's code is never included. await base316.billing.acceptRetentionOffer(token, attempt.id); // stays // …or await base316.billing.confirmCancellation(token, attempt.id); // leaves } else { await base316.billing.confirmCancellation(token, attempt.id); } ``` ### From your server [#from-your-server] For an integration with its own sign-in, the same three steps take a secret key and a subscription id: ```ts const attempt = await base316.retention.start(subscriptionId, { reason: "NOT_USING" }); await base316.retention.accept(attempt.id); // or retention.confirm(attempt.id) ``` Every language SDK has the same methods, for example `start_cancellation`, `accept_retention_offer` and `confirm_cancellation` in Python and Ruby. The reasons are `TOO_EXPENSIVE`, `NOT_USING`, `MISSING_FEATURES`, `SWITCHING`, `TECHNICAL_ISSUES`, `TEMPORARY` and `OTHER`. `GET /v1/retention` lists them in display order. | Call | Errors | | --------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `POST /v1/me/subscription/cancellation` | 404 `no_subscription`; 403 `billed_by_parent` | | `POST /v1/me/subscription/cancellation/:id/accept` | 409 `offer_expired`, `offer_unavailable` (the coupon or plan changed since), `already_resolved`, `no_offer`; 404 | | `POST /v1/me/subscription/cancellation/:id/confirm` | 409 `already_resolved` (they accepted the offer); confirming twice answers as the first time did | Starting again while an attempt is open returns that same attempt, so a double click or the back button does not count as two people leaving. The one-step `POST /v1/me/subscription/cancel` still works and still cancels straight away. It makes no offer, but it is recorded, so the report counts everyone who left. ## Accepting after a cancellation was already scheduled [#accepting-after-a-cancellation-was-already-scheduled] A subscriber whose subscription is already set to end, and who then goes through the flow and accepts, is **un-cancelled**: the discount goes on and the subscription renews. The webhook says so with `resumed: true`. ## Webhooks [#webhooks] | Event | `change` | Carries | | ---------------------- | -------------------------- | ---------------------------------------------------------------------------- | | `subscription.updated` | `retention_offer_accepted` | `cancellationId`, `cancellationReason`, `couponId`, `discountId`, `resumed` | | `subscription.updated` | `cancel_scheduled` | `cancellationId` and `cancellationReason` when it came through a cancel flow | ## The report [#the-report] **Insights → Retention** shows, for the last 7, 30, 90 or 365 days: * how many subscribers started to cancel, how many were saved, and the **save rate**, which is saves divided by offers made; * the **monthly revenue kept**: the recurring value of the saved subscriptions, per currency, before the discount; * the breakdown **by reason**; * the most recent attempts, with what was offered and what the subscriber chose. An attempt nobody finished, whose offer has lapsed, shows as **Abandoned**; that subscriber is still subscribed. The same figures come from `GET /v1/retention/report` (`base316.retention.report()`) and the attempts from `GET /v1/cancellations`. ## Privacy [#privacy] The comment is in the subscriber's own words. When a person exercises their right to erasure, the comment is cleared wherever their billing account's name and address are stripped. The attempt itself stays, as a billing record. # SAML identity provider Some applications your users need only accept sign-in over SAML 2.0. Base316 can be the **identity provider** for them: you register each application as a **service provider**, and Base316 signs your users in to it with the identity they already have here — whichever way they signed in, second factor and all. This is the opposite direction from enterprise SSO. There, one of your customers' identity providers (Okta, Entra) signs people in to Base316 and Base316 is the service provider. Here, Base316 is the identity provider and the SAML application is the service provider. An environment can do both at once. ## Where it lives [#where-it-lives] Each environment has its own identity provider. In the dashboard, open the project, then **Settings → Applications**, and scroll to **SAML applications**, below the OIDC clients. The panel at the top shows what every SAML application asks for: | Dashboard label | Value | | -------------------- | ----------------------------------------------------------- | | **IdP metadata URL** | `https://api.base316.com/saml/idp/{environmentId}/metadata` | | **IdP entity ID** | `https://api.base316.com/saml/idp/{environmentId}` | | **SSO URL** | `https://api.base316.com/saml/idp/{environmentId}/sso` | The SSO URL accepts AuthnRequests over both the **HTTP-Redirect** and **HTTP-POST** bindings. Responses always go back over **HTTP-POST**; a request that asks for any other response binding is refused rather than left waiting. Below the URLs is the **Signing certificate**: its SHA-256 fingerprint, its expiry, and **Download certificate** for the PEM. ## Registering an application [#registering-an-application] Choose **Add SAML application**. ### 1. Import its metadata [#1-import-its-metadata] Most applications publish SP metadata — an XML document, usually downloadable from their SAML settings page. Paste it into **Import metadata**, or upload the file, and choose **Read metadata**. The dialog fills in the **Entity ID**, **ACS URLs**, **Signing certificate**, **Require signed requests** and **NameID format** from it. Nothing is saved until you choose **Add SAML application** at the bottom, so check the fields first. Metadata is only ever pasted or uploaded. Base316 never fetches it from a URL you give it — a fetch-by-URL field is a request-forgery primitive pointed at whatever that URL resolves to. ### 2. Or fill it in by hand [#2-or-fill-it-in-by-hand] | Field | What goes in it | | ------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | **Name** | Yours, to recognise it by. | | **Entity ID** | The SP entity ID, exactly as the application sends it as the `Issuer`. It becomes the `Audience` of every assertion. | | **ACS URLs** | One per line, `https` only. Matched **exactly** — see below. | | **NameID format** | **Email address**, **Persistent** or **Unspecified**. | | **NameID value** | **Email**, **User ID** or **External ID** — what the NameID is filled from. | | **Attribute mapping** | The attributes the application expects; see [Attributes](#attributes). | | **Require signed requests** | Refuse unsigned AuthnRequests. Needs the **Signing certificate**. | | **Signing certificate** | The application's request-signing certificate, in PEM. | | **Require consent** | Ask each person before their attributes are sent. | | **Allow IdP-initiated sign-in** | Off by default. Read [IdP-initiated sign-in](#idp-initiated-sign-in) first. | | **Default RelayState** | Only for IdP-initiated sign-in. At most 80 bytes. | | **Enabled** | Shown when editing. A disabled application is refused at sign-in; its settings are kept. | ### 3. Configure the application [#3-configure-the-application] Give the application Base316's details. If it takes a metadata URL, that is all it needs: ``` https://api.base316.com/saml/idp/env_1a2b3c/metadata ``` If it asks for each value separately, give it the **SSO URL**, the **IdP entity ID**, and the certificate from **Download certificate**. Prefer the metadata URL where there is a choice: an application that reads it picks up a [rotated certificate](#certificate-rotation) on its own. ### A worked example [#a-worked-example] A helpdesk documents its SAML settings as: ``` SP Entity ID https://support.example.com/saml/metadata ACS URL https://support.example.com/saml/consume NameID urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress Attributes email, first_name, department ``` Register it with **Entity ID** `https://support.example.com/saml/metadata`, **ACS URLs** `https://support.example.com/saml/consume`, **NameID format** **Email address** (which fixes **NameID value** to **Email**), and three rows under **Attribute mapping**: | Attribute name | Filled from | | -------------- | ------------------------------ | | `email` | `email` | | `first_name` | `name` | | `department` | **Custom field…** `department` | Then, in the helpdesk's admin screen, paste the **IdP metadata URL** — or the **SSO URL**, the **IdP entity ID** and the downloaded certificate — and turn SAML on. The next person who chooses "Sign in with SSO" there lands on your hosted sign-in page, and arrives back at the helpdesk signed in. ## Attributes [#attributes] Each row under **Attribute mapping** sends one ``, named as the application expects it (`email`, or a URN — whatever it documents), filled from a fact about the person. The sources are the same facts the OIDC provider puts in claims, read the same way, so a SAML attribute and an OIDC claim about one person cannot disagree. | Source | Value | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `email` | The email address | | `email_verified` | `true` or `false` | | `name` | The display name | | `user_id` | The Base316 user id | | `external_id` | Your id for the user, if you set one | | `picture` | The image URL | | `phone_number` | The phone number | | `roles` | Every role the person holds — one value each | | `organization_ids` | The tenants the person belongs to — one value each | | `organization_names` | Their names | | `organization_external_ids` | Their external ids, where set | | `plan` | The plan key of the person's tenant | | `subscription_status` | That subscription's status | | `field:` | One of your custom user fields — **Custom field…** in the dashboard | | `entitlement:` | What the tenant's plan grants for a feature — **Entitlement…** in the dashboard. An unlimited limit is sent as `unlimited`. | A source with no value for the person leaves that attribute out of the assertion, rather than sending an empty string. An attribute name may be mapped once. ## NameID [#nameid] | Format | Meaning | | ----------------- | ---------------------------------------------------------------------------------------------------------- | | **Email address** | `…nameid-format:emailAddress`. Always filled from the email address. | | **Persistent** | `…nameid-format:persistent`. With **User ID**, an identifier that never changes, even if the address does. | | **Unspecified** | `…nameid-format:unspecified`. For applications that do not care. | A request whose `NameIDPolicy` asks for a different format is answered with an `InvalidNameIDPolicy` error response. A request asking for `unspecified`, or not asking, gets the registered format. ## Signed AuthnRequests [#signed-authnrequests] Turn on **Require signed requests** and paste the application's **Signing certificate**, and every AuthnRequest must be signed with it: * **HTTP-Redirect** — the query-string signature (`SigAlg` and `Signature`), checked over the parameters exactly as they were encoded on the wire. RSA-SHA256, RSA-SHA512 and ECDSA-SHA256 are accepted. * **HTTP-POST** — an enveloped XML signature on the AuthnRequest. RSA-SHA256 and RSA-SHA512, with SHA-256 or SHA-512 digests. **SHA-1 is refused** in both. A signature that is present is always checked, even when not required — a request carrying a bad one is a forgery or a broken application, and neither is a request to act on. ## Consent [#consent] With **Require consent**, the first time a person signs in to the application they are shown the attributes it will receive and asked to approve. Their answer is remembered; if you later add attributes to the mapping, they are asked again, because they agreed to what was listed then. Declining sends the application a signed `RequestDenied` error response instead of an assertion. ## IdP-initiated sign-in [#idp-initiated-sign-in] Normally the application starts sign-in (SP-initiated): it sends an AuthnRequest, and the response answers that request. With **Allow IdP-initiated sign-in** on, Base316 can also start it, from a link you can put on your own portal — the **Launch URL** shown under the application in the list: ``` https://api.base316.com/saml/idp/{environmentId}/start/{applicationId} ``` It is **off by default** for a reason. An unsolicited response answers no request, so the application cannot tie it to the browser that receives it: a captured one can be replayed until it expires, and an attacker can push a victim's browser into the application signed in as the attacker (login CSRF). Turn it on only for an application that cannot start sign-in itself. The response goes to the first **ACS URL**. The RelayState comes from **Default RelayState** on the registration, **never** from the launch URL: the link is unauthenticated, and letting it choose where the application sends the person next would make it an open redirect. ## Single sign-on [#single-sign-on] Signing in to one application signs the browser in to Base316 as well, so the next SAML application — or OIDC client — in the same environment goes straight through without a form. This is the same session `/oidc/{env}/authorize` reuses. * **`ForceAuthn="true"`** asks for credentials again, as `prompt=login` does for OIDC. * **`IsPassive="true"`** with nobody signed in is answered with a `NoPassive` error response rather than a sign-in form. The assertion's `AuthnInstant` is when the person **actually authenticated**, not when the assertion was built. An application reusing a single sign-on session therefore sees how long ago the person really proved who they are, and can ask for `ForceAuthn` if that is too long. ## Single logout is not supported [#single-logout-is-not-supported] Signing out of Base316 does **not** end sessions at SAML applications, and Base316 does not send or accept SAML `LogoutRequest`s. Set a short session lifetime in each application, so that a person who has been disabled or signed out here loses access there soon after. Disabling a person does stop every **new** sign-in immediately — no assertion is issued for a disabled account. ## What is checked [#what-is-checked] Every AuthnRequest is refused, with a message page rather than a response, if: * its `Issuer` is not a registered, enabled application's **Entity ID**; * its `AssertionConsumerServiceURL` is not **exactly** one of the registered **ACS URLs** — never a prefix match. With no URL, the registered first one is used; with an index, the registration's own order; * its `Destination` is present and is not this environment's **SSO URL**; * its `IssueInstant` is more than **5 minutes** old, allowing **3 minutes** of clock skew either way; * its request `ID` has already been seen for that application — a replayed request is refused; * it contains a DTD or entity declaration; * it is unsigned, badly signed or SHA-1-signed when a signature is required. ## The assertion [#the-assertion] * The `Response` **and** the `Assertion` are both signed, RSA-SHA256 with exclusive canonicalisation, with the active **Signing certificate**. * `Audience` is the application's **Entity ID**. * `Recipient` and `Destination` are the ACS URL the response is posted to. * `InResponseTo` names the request (absent for an IdP-initiated response). * The assertion is valid for **5 minutes**. * `AuthnInstant` is the real sign-in time, as above. ## Certificate rotation [#certificate-rotation] **Rotate certificate** mints a new certificate and signs every new response with it straight away. The old one is marked **Retiring** and stays published in the metadata for **14 days**, then is dropped. An application that reads the **IdP metadata URL** picks up the new certificate within that window by itself. One configured with a pasted certificate needs the new one — **Download certificate** beside it — before the old one leaves the metadata; until then, it rejects the new signatures. ## Over the API [#over-the-api] Everything the dashboard does is available with a **secret key** at `/v1/saml-applications`. A publishable key is refused: registering an ACS URL decides where a signed assertion about a user is posted. | Method | Path | Does | | -------- | ---------------------------------- | ----------------------------------------------------------- | | `GET` | `/v1/saml-applications` | List applications | | `POST` | `/v1/saml-applications` | Register one — fields, or `metadataXml`, or both | | `GET` | `/v1/saml-applications/:id` | Read one | | `PATCH` | `/v1/saml-applications/:id` | Update one | | `DELETE` | `/v1/saml-applications/:id` | Delete one | | `GET` | `/v1/saml-applications/idp` | The IdP's entity ID, metadata URL, SSO URL and certificates | | `POST` | `/v1/saml-applications/idp/rotate` | Rotate the signing certificate | With `metadataXml`, the document supplies the entity ID, ACS URLs, signing certificate, signed-request flag and NameID format, and any field you also send wins over it. In the SDK these are under `base316.saml`: ```ts import { readFile } from "node:fs/promises"; import { Base316 } from "@base316/sdk"; // Reads BASE316_SECRET_KEY from the environment. const base316 = new Base316(); const idp = await base316.saml.idp(); console.log(idp.metadataUrl); const helpdesk = await base316.saml.createApplication({ name: "Helpdesk", metadataXml: await readFile("helpdesk-sp-metadata.xml", "utf8"), attributeMapping: [ { name: "email", source: "email" }, { name: "department", source: "field:department" }, { name: "seats", source: "entitlement:seats" }, ], }); await base316.saml.updateApplication(helpdesk.id, { requireConsent: true }); ``` | Method | Does | | ----------------------------------- | ------------------------------ | | `saml.idp()` | IdP URLs and certificates | | `saml.rotateCertificate()` | Rotate the signing certificate | | `saml.applications()` | List applications | | `saml.createApplication(input)` | Register one | | `saml.application(id)` | Read one | | `saml.updateApplication(id, input)` | Update one | | `saml.deleteApplication(id)` | Delete one | # Second factors A user can hold an **authenticator app** (TOTP), a **code sent to their inbox**, or a **code sent by text message**. Each is off until somebody turns one on for themselves — unless the environment's auth policy sets `mfaRequirement: "REQUIRED"`, in which case anybody without one enrols one as part of signing in (see [When a factor is required](#when-a-factor-is-required)). A factor is never imposed on an account in a way its owner cannot complete. ## Which to offer [#which-to-offer] Offer both. The app is stronger, and the emailed code is the one people who will not install anything actually use — a factor somebody turns on beats a stronger one they decline. Be honest with yourself about what the emailed code buys. It is only as strong as the mailbox, and if that is the same address that receives password resets it protects against a stolen password and very little else. It is still a great deal better than nothing. ## Enrolling [#enrolling] `` in `@base316/react` offers both and handles the whole flow, drawing the authenticator's QR code in the browser (the secret is never sent anywhere to be turned into a picture) with the key underneath for anyone who cannot scan it. On the API: ```ts // An authenticator app: returns a secret and an otpauth:// URI to draw as a QR. const { factorId, secret, uri } = await client.auth.mfa.enroll(sessionToken); // A code to the inbox: the first code is sent immediately. const { factorId, destinationHint } = await client.auth.mfa.enrollEmail(sessionToken); // A code by text message, to the account's number or one you pass. const { factorId, destinationHint } = await client.auth.mfa.enrollSms(sessionToken, { phone }); ``` A text message goes out from Base316's number unless the environment has its own Twilio account, and in production counts against your plan's monthly allowance. See [Text messages and phone numbers](/guides/text-messages). Either way, **enrolling does not activate**. The factor gates nothing until a code from it comes back: ```ts const { recoveryCodes } = await client.auth.mfa.confirm(sessionToken, { factorId, code, }); ``` That two-step is what stops somebody locking themselves out with a QR code that never scanned. The authenticator secret is returned by `enroll` and nowhere else. Base316 keeps it encrypted at rest (AES-256-GCM), never includes it in a data export or the dashboard, and only decrypts it to check a code. The recovery codes are returned once and stored only as digests. Show them, say plainly that they will not be shown again, and mean it. The address an emailed factor uses is **fixed when it is enrolled**. It does not follow the account's email afterwards — otherwise taking over an address would silently take over the second factor protecting it. ## Signing in [#signing-in] A sign-in that owes a factor returns a challenge instead of a session: ```ts const result = await client.auth.signIn({ email, password }); if (needsSecondFactor(result)) { // result.factors says what can answer it — an authenticator box and // "check your email" are different screens. For a deliverable factor the // code is already sent by the time this returns. const { token } = await client.auth.verifyMfa({ challengeToken: result.challengeToken, code, }); } ``` Render from `result.factors` rather than assuming. A page that guesses tells half your users to open an app they never installed. `challengeToken` is what proves the password step happened. Treat it as a credential: it is opaque, answers once, and expires ten minutes after the sign-in that made it. Hold it in memory for the length of the challenge rather than persisting it. **This replaced a `userId` parameter, and the old shape no longer works.** A user id is not a secret — it appears in team member lists, in the OIDC `sub` claim and in anything you provision over SCIM — so accepting one as evidence that the first factor had been passed meant a second factor on its own could open an account. Upgrade the SDK and pass `result.challengeToken`. If the code never arrives, `client.auth.mfa.resend({ challengeToken })` sends another. It takes no session, because at that point there is none. Sending a new code retires the old one, and asking repeatedly produces one email every thirty seconds however often the button is pressed. Guesses are counted against the sign-in rather than against the code, so resending does not hand out a fresh budget of them. After ten wrong answers the challenge is spent and the person starts again from the password. ## When a factor is required [#when-a-factor-is-required] With `mfaRequirement: "REQUIRED"` on the environment's auth policy, every sign-in owes a second factor — including from somebody who has none yet, like a user who has just signed up or accepted an invitation. Their sign-in returns a challenge with `enroll: true`, no `factors`, and the methods they may enrol: ```ts const result = await client.auth.signIn({ email, password }); if (needsEnrollment(result)) { // result.enrollMethods: "TOTP" always; "EMAIL_OTP" where the account has an // address; "SMS_OTP" where the environment can send text messages. const started = await client.auth.startRequiredEnrollment({ challengeToken: result.challengeToken, method: "TOTP", }); // TOTP: started.secret, started.uri (otpauth://) and started.qrSvg to show // as an image. EMAIL_OTP / SMS_OTP: the first code is sent; // started.destinationHint says where. const { token, recoveryCodes } = await client.auth.completeRequiredEnrollment({ challengeToken: result.challengeToken, factorId: started.factorId, code, }); // Show recoveryCodes now — they are not returned again — then use `token`. } ``` `/v1/auth/magic-link/verify` and `/v1/auth/phone/verify` answer the same way. `` and `` in `@base316/react` do all of this, and `` does it after a first step of your own. The hosted sign-in pages do it too, with no code of yours: an email code by default, a text message where the environment can send one, an authenticator for an account with neither. A mistyped code keeps the same step on screen (and a reload does not send another code or mint another key). Once the code is right, the recovery codes are shown once, on their own page, and the person moves on to your application only when they press **I have saved these codes**. Both calls take the challenge, never a session, and the challenge keeps its rules: single use, ten minutes, ten wrong codes in total. Starting again replaces an authenticator secret, and for email resends the code — at most once every thirty seconds. Completing mints the session exactly as `verifyMfa` does, under the environment's session lifetime and cap, with `MFA_ENROLLED`, `MFA_VERIFIED` and `SIGNED_IN` in the audit log. An account that already has a factor is refused (`CONFLICT`, `reason: "ALREADY_ENROLLED"`) and answers its challenge with `verifyMfa`. A password on its own is never enough to enrol a second authenticator around the one an account already has. ## Where a factor is enforced [#where-a-factor-is-enforced] | Signing in with | Second factor asked for | | --------------- | ----------------------- | | Password | Yes | | Magic link | Yes | | Text message | Yes | | Social sign-in | Yes | | Passkey | **No** | | Enterprise SSO | **No** | Both exceptions are decisions rather than gaps. A **passkey** is already possession of a registered authenticator, usually behind a biometric or device PIN, and it cannot be phished. Asking for six digits on top adds friction to the strongest thing on the list. **Enterprise SSO** exists so the customer's identity provider is the only door. MFA policy there belongs to the enterprise that bought it, and adding one here would sit on top of whatever they already enforce. If you need a factor on top of an SSO sign-in for a particular customer, that is a per-connection option rather than a default — say so and it can be built. ## Losing a factor [#losing-a-factor] A recovery code signs somebody in once and is then spent. When they run out, an administrator can remove the factor from the user's **Security** panel in the dashboard, which shows which factor the account holds and — for an emailed one — a masked hint of where its codes go. That last detail is usually the actual question when somebody calls to say the codes are not arriving. # Set up with the assistant The setup assistant turns a description of your product and pricing into Base316's model. Open a project, pick the environment (usually **Sandbox**), and choose **Product → Setup assistant**. ```text We sell an API. Free gets 1,000 calls a month, Pro is $49/month with 100,000 calls and $0.002 per extra call. ``` The assistant reads what the environment already has, then proposes what is missing: here a meter that counts `api.call` events, a metered `api_calls` feature, and the two plans. Its reply tells you what your application has to do for the model to work: the event name and payload property to send for each meter, and the feature key to check. ## Nothing changes until you apply [#nothing-changes-until-you-apply] The assistant can read the environment but cannot write to it. Everything it wants to create appears under **Proposed changes**. Untick anything you do not want and press **Apply**. Proposals run in order, so a plan can use a feature proposed above it in the same list. If one fails, for example because its key is already taken, it is marked with the reason and the rest still apply. Proposals from several messages accumulate, so you can keep refining: "add an annual version of Pro at $490", "add a seats feature with 5 included". If a pricing decision is genuinely open, such as a limit or a price you did not mention, the assistant asks rather than inventing a number. ## What it can propose [#what-it-can-propose] * meters, with their event name, aggregation and payload property * features: boolean, metered or quantity * plans with a recurring price, a trial, entitlements and per-unit overage prices — or a per-model (MATRIX) price, one unit price per value of a meter's `groupBy` property such as the AI model * roles and their permissions * add-ons, including one that bills a firm per client tenant * tenants, client tenants of a firm, inherited roles and tenant branding It cannot edit or delete existing things. For that, use the matching screen in the dashboard. ## Overage and `check()` [#overage-and-check] An overage price makes a limit soft. `check()` keeps allowing usage past the plan's limit plus any credits, reports `usage.overage: true`, and that usage is billed at the overage price. Without an overage price the limit is hard and `check()` refuses at it with `USAGE_LIMIT_EXCEEDED`. To put a ceiling on what a customer can be charged, give the entitlement a **hard cap**: `check()` refuses past it with `HARD_CAP_REACHED`, and credits do not raise it. The assistant says this whenever it proposes an overage price, and can propose a cap with it. See [Gate a feature](/guides/gate-a-feature). ## Who can use it [#who-can-use-it] Members who can manage the organization. Every message is a call to a model, and applying creates catalog entries, so view-only members see the page but cannot send messages. Your messages, and a summary of the environment's catalog (meters, features, plans, roles, add-ons and the first 50 tenants), are sent to Anthropic to generate replies. See the [privacy policy](https://base316.com/legal/privacy-policy). # Social sign-in Your users can sign in with an account they already have. You register an application with each provider and give Base316 its credentials; Base316 runs the flow on your behalf and hands your application the same single-use code a password sign-in does. Set providers up per environment, on the project's **Sign-in page design** screen, in the **Social sign-in** card. Choose **Set up** beside a provider, register the **Callback URL** the dialog shows, then paste the client ID and secret. The same is available over the API with a secret key: `PUT /v1/auth-config/providers/{provider}`, or `authConfig.saveProvider` in the SDK. ## The callback URL [#the-callback-url] Every provider has one callback URL, the same for every environment and every customer: ``` {BASE316_AUTH_URL}/hosted/oauth/{slug}/callback ``` On the hosted service that is `https://api.base316.com/hosted/oauth/{slug}/callback`. The dialog shows the exact value for your deployment — copy it from there. Register it exactly; providers compare it character for character. | Provider | `{slug}` | Where to register an app | | --------- | ----------- | ------------------------------------------------ | | Google | `google` | Google Cloud console → APIs & Services | | Microsoft | `microsoft` | Microsoft Entra admin center → App registrations | | Apple | `apple` | Apple Developer → Identifiers → Services IDs | | Facebook | `facebook` | Meta for Developers → My Apps | | GitHub | `github` | GitHub → Settings → Developer settings | | GitLab | `gitlab` | GitLab → Preferences → Applications | | LinkedIn | `linkedin` | LinkedIn Developers → My apps | | Slack | `slack` | api.slack.com → Your Apps | | Discord | `discord` | Discord Developer Portal → Applications → OAuth2 | | X | `twitter` | X Developer Portal → Projects & Apps | | Twitch | `twitch` | Twitch Developer Console → Applications | | Roblox | `roblox` | Roblox Creator Dashboard → OAuth 2.0 Apps | ## Which accounts are joined by email [#which-accounts-are-joined-by-email] Every account is found by the provider's own permanent id for it, never by email, so somebody who changes their address at the provider keeps their account here. The first time somebody signs in with a provider, Base316 checks whether an account already uses that address. It joins the two **only when the provider says the address is verified**. Joining on an unverified address would let anyone who can register your user's address at some provider sign in as them. If the address is taken and the provider cannot vouch for it, the sign-in is refused and the person is asked to sign in with their password first. Some providers cannot vouch for an address at all, so a sign-in with them is **never** joined to an existing account: | Provider | What it shares | | -------- | ---------------------------------------------------------- | | X | No email address. Every X sign-in is its own account. | | Roblox | No email address. Every Roblox sign-in is its own account. | | Twitch | An address, with no statement that it was verified. | Discord shares an address with a `verified` flag, and only a verified one is joined. The dialog for each of the three above says so as well. ## Provider notes [#provider-notes] **Discord.** Add the callback under **OAuth2 → Redirects**. Base316 asks for the `identify` and `email` scopes. **X.** Create the app with **OAuth 2.0** turned on and the type set to **Web App** (a confidential client), then add the callback under **User authentication settings**. Base316 asks for `users.read` and `tweet.read` — X will not grant the first without the second — and authenticates at X's token endpoint with HTTP Basic, as X requires. Paste the **OAuth 2.0** client ID and secret, not the API key and secret. **Twitch.** Register the callback as the app's **OAuth Redirect URL** and choose the **Confidential** client type. Base316 asks for `user:read:email`. **Roblox.** Add the callback as a redirect URL and grant the app the `openid` and `profile` scopes. Roblox names change, so the account is tied to the Roblox user id, never to the name. ## Restricting Microsoft to certain directories [#restricting-microsoft-to-certain-directories] By default **Continue with Microsoft** admits work, school and personal accounts alike. In the Microsoft dialog, **Account types** narrows that: | Account types | Who can sign in | | ----------------------------------- | -------------------------------------------------- | | Work, school and personal accounts | Anyone with a Microsoft account | | Work and school accounts only | Any organization's Microsoft Entra directory | | Personal Microsoft accounts only | Outlook, Hotmail, Xbox and other personal accounts | | Only these directories (tenant IDs) | Only the directories you list | For the last, enter each directory's **tenant ID** — a GUID such as `72f988bf-86f1-41af-91ab-2d7cd011db47`, found on the directory's **Overview** page in the Microsoft Entra admin center. A domain name such as `contoso.onmicrosoft.com` is refused. The restriction is enforced where it cannot be sidestepped. With one tenant listed, the Microsoft prompt itself only accepts that directory. With several, Microsoft has no single address that accepts exactly those, so the prompt accepts any work account — and Base316 then checks the directory in the token Microsoft returns (`tid`) against your list on every sign-in, and refuses any other. Every mode is checked this way, not only the last. A refused person sees the same message as any other failed sign-in, so the page never reveals which directories you allow. Register the Microsoft app as **multitenant** if you list more than one directory or choose anything but a single one; a single-tenant registration only works for its own directory. Over the API: ```ts await base316.authConfig.saveProvider("MICROSOFT", { clientId: "…", tenantMode: "SPECIFIC", allowedTenantIds: ["72f988bf-86f1-41af-91ab-2d7cd011db47"], }); ``` Leaving `tenantMode` out of a later save keeps the stored restriction, so turning the provider off and on again cannot widen it. # Tax, VAT numbers and Stripe Tax Every invoice Base316 issues is taxed by one of two engines, chosen per environment. Either way **you stay the merchant of record**: the tax is collected for you, on your registrations, and you file and remit it. | Engine | Who decides the tax | When to use it | | ----------------------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | **Your tax rates** | The rates you define (**Settings → Tax**), matched on the customer's country and region. | One or a few jurisdictions you know well; no Stripe account needed. | | **Stripe Tax** (opt-in) | Stripe Tax, on your connected Stripe account, per invoice and per line. | Selling into many places; EU VAT with reverse charge; US sales tax by postal code. | Switch with **Settings → Tax → Calculate tax with Stripe Tax**. It needs a connected Stripe account that can take charges. Turning it off returns the environment to its tax rates from the next invoice; invoices already issued keep the tax they were issued with, and each says which engine taxed it. ## Billing addresses and tax IDs [#billing-addresses-and-tax-ids] A customer carries a billing address and any number of tax registrations. ```ts const customer = await base316.customers.create({ externalId: "acct_42", name: "Beispiel GmbH", address: { line1: "Hauptstr. 1", city: "Berlin", postalCode: "10115", country: "DE", }, taxIds: [{ type: "eu_vat", value: "DE123456789" }], }); ``` * The address's `country` and `state` **are** the customer's `country` and `region` — the jurisdiction your tax rates match on. There is one jurisdiction per customer, never two that disagree. * Tax ID `type` is Stripe's tax ID type: `eu_vat`, `gb_vat`, `ch_vat`, `au_abn`, `ca_gst_hst`, `in_gst`, `us_ein` and the rest. The number's format is checked per type before anything is stored, and it is stored compacted — `de 123.456 789` becomes `DE123456789`. An EU VAT number may be sent without its prefix if you give `country`; Greek numbers use VIES's `EL`. * Add and remove tax IDs one at a time with `POST` and `DELETE /v1/customers/{id}/tax-ids`, or replace the list by sending `taxIds` on `PATCH /v1/customers/{id}`. Up to 10 per customer. * Your end users can set their own: in the hosted billing portal's **Billing details** form, through `GET`/`PATCH /v1/me/billing-details` (publishable key plus their session), in ``, or by dropping in `` from `@base316/react`. A managed tenant's members are refused with `billed_by_parent` — the firm's account is the one invoiced. * Invoices print the address and tax IDs as they were when the invoice was issued; a later change does not rewrite an issued document. ## VIES: checking EU VAT numbers [#vies-checking-eu-vat-numbers] An EU VAT number is checked against the European Commission's [VIES](https://ec.europa.eu/taxation_customs/vies/) service when it is added, and its result is the tax ID's `status`: | Status | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------- | | `VERIFIED` | VIES confirmed the registration. The registered name, address and consultation number are kept as evidence. | | `INVALID` | VIES says no such registration exists. The ID is kept and marked, and is **not** sent to Stripe Tax. | | `UNAVAILABLE` | VIES or the member state's registry could not be reached. Base316 asks again, backing off from 15 minutes to a day. | | `UNVERIFIED` | Well formed, and there is no registry Base316 can ask — every non-EU type. | Two rules matter more than the table: * **VIES never holds up an invoice.** Member-state registries go offline most nights. A number VIES cannot answer for is still sent to Stripe Tax, and the invoice is issued; the check is retried in the background. * **Confirmed numbers are re-checked monthly**, because a registration can be cancelled. A number already `VERIFIED` stays verified through an outage. The dashboard's customer page shows each ID's status, what VIES returned, when it was checked and when it will be checked next, with a button to check again. ## Stripe Tax [#stripe-tax] With Stripe Tax on, every invoice finalization — and every upcoming-invoice preview — asks Stripe Tax what the invoice owes: 1. The invoice's taxable amount (after discounts and credits) is split across its lines in minor units, so Stripe sees exactly the figure the invoice states. 2. Each line carries a **product tax code**: the plan's own for its recurring fee, the feature's for its usage (`taxCode` on `PATCH /v1/plans/{id}` and `PATCH /v1/features/{id}`, e.g. `txcd_10103001` for business SaaS). A line with none uses your Stripe account's preset tax code. 3. The customer's billing address and tax IDs (except `INVALID` ones) go with it. Whether a cross-border EU sale to a business is **reverse charged** is Stripe's decision; when it is, the invoice and its PDF say "Reverse charge". 4. The calculation is recorded on the invoice, and once the invoice is issued Base316 records a **Tax Transaction** from it, so Stripe's tax reports and filings include the sale. Voiding the invoice reverses the transaction; a credit note reverses its total as it is issued — the same amount whether it refunds the card, credits the wallet or reduces what is due. The preview and the bill ask the same question in the same order, so the tax on the upcoming invoice is the tax on the invoice the billing run raises. ### What to set up in Stripe [#what-to-set-up-in-stripe] Stripe Tax runs on **your** connected account, under **your** registrations: * Turn on Stripe Tax in your Stripe dashboard and set your **origin address** and **preset tax code**. * Add a **registration** for every jurisdiction you are registered to collect in (**Tax → Registrations**). Where you have none, Stripe Tax returns zero with the reason `not_collecting`, and the invoice line says so — that is not an error, it is what you told Stripe. * Base316 sends prices as tax-exclusive: tax is added on top. ### When Stripe Tax cannot answer [#when-stripe-tax-cannot-answer] An invoice is never issued with zero tax because Stripe Tax was unreachable. * At finalization, a failure — Stripe unreachable, or refusing the customer's address — leaves the invoice a **draft**, with the reason shown on the invoice page and returned by `POST /v1/invoices/{id}/finalize` as a `503`. * Base316 finalizes it again on its own, from five minutes later and backing off to every six hours, with the same options the first attempt used. Once Stripe answers it is issued and collected as normal. * A customer with **no billing country** is not sent to Stripe at all: the draft waits for an address, and the customer page warns about it. In the US and Canada Stripe also needs a postal code. * The upcoming-invoice preview returns `503` (`tax_unavailable` on the `/me` route) rather than a figure without its tax. * If the transaction cannot be recorded right after issuing, the invoice is still issued — the tax is on it — and the transaction is recorded within minutes by a background sync. A void is reversed by the same sync; a credit note's reversal is made as it is issued, and by the sync if Stripe could not be reached then. ### What is checked against Stripe, and what only against a fake [#what-is-checked-against-stripe-and-what-only-against-a-fake] Base316's own test suite runs every part of this against fakes of the VIES and Stripe Tax HTTP APIs — calculation, transaction, reversal, the unreachable and refused cases, and preview-equals-invoice. The rates Stripe applies to your registrations, the reverse-charge decision itself and the reports only exist in your Stripe account: try a few invoices in your sandbox (a Stripe test account with test-mode registrations) before turning it on in production. # Text messages and phone numbers A phone number can be how somebody signs in, how they sign up, and the second factor that protects an account. All three send a six-digit code by text message and wait for it to come back. ## Who pays for the messages [#who-pays-for-the-messages] Every text message costs money, and an endpoint that sends one on request is the target of **SMS pumping**: an attacker points it at premium-rate numbers they share revenue on, and whoever owns the account behind it pays. So whose account carries a message is a security rule, not a billing detail. Text messages work out of the box: unless you connect your own Twilio account, they go out from **Base316's number** in both sandbox and production. Your own account always wins once it is connected. | | Base316's number | Your own Twilio account | | ----------------- | --------------------------------------------------------------- | ----------------------- | | Countries | `+1` (US and Canada) — it is a US toll-free number | Wherever Twilio reaches | | Daily cap | 25 per sandbox, 1,000 per production environment | None — your send limits | | Monthly allowance | Production only, from your plan (below); sandbox is not counted | Not counted | | Sender | Base316's toll-free number; the message names your brand | Your number or service | The daily caps bound what a pumping run can spend; a real launch should not meet them, and an environment that does belongs on its own account. ### Allowance by plan [#allowance-by-plan] Production messages through Base316's number count against your workspace's monthly allowance, shown under **Settings → Billing → Usage this month**: | Plan | Included each month | Past the allowance | | ---------- | ------------------- | --------------------------------------------- | | Free | 100 | Refused until the month turns over | | Pro | 1,000 | $0.02 per message, added to your next invoice | | Scale | 10,000 | $0.02 per message, added to your next invoice | | Enterprise | Per contract | Per contract | On Free, a sign-in by text message past the allowance is refused, and the hosted page asks the user to sign in another way. Upgrade, or connect your own Twilio account, to keep sending. Your own account has none of these fences: it is your balance, and the send limits below are yours to set. ## Setting it up [#setting-it-up] Nothing is needed to use Base316's number. To send from your own Twilio account instead, open **Settings → Text messages** for the environment: 1. Paste the **Account SID** (`AC…`) and **Auth token** from the Twilio console. 2. Give a sender: a Twilio **From number**, or a **Messaging service SID** (`MG…`). A messaging service picks a sender per country, which is usually what you want if your users are international. 3. Press **Save and verify**. Base316 checks the credentials with Twilio before storing anything, so a wrong token is refused now rather than at somebody's first sign-in. The auth token is encrypted at rest and only its last four characters are ever shown again. **Send test** sends a message through exactly the path a sign-in code takes, and the list below it shows what was sent recently — including why a message failed, in Twilio's words, which are never shown to your users. Then turn on **Sign in with a text message** under **Settings → Sign-in methods & design** to add a "Text message" tab to the hosted sign-in page. Base316 uses Twilio's Messaging API and keeps the codes itself — minted, hashed, expired and attempt-limited by the same rules as emailed codes. Twilio Verify would have split those rules across two systems. ## Phone numbers [#phone-numbers] Numbers are stored in international (E.164) form: `+14155550123`. Spaces, dashes, dots and brackets are accepted and dropped; a leading `00` becomes `+`. A number in national form (`0412 345 678`) is refused, because turning it into an international one needs to know which country it was typed in. Ask for the country code. Each number belongs to at most one user per environment. A user's number is either **verified** — a code sent to it came back — or not. Only a verified number signs anybody in. ## Signing in with a code [#signing-in-with-a-code] On the hosted pages this is the "Text message" tab. From your own interface: ```ts const { challengeToken, destinationHint } = await client.auth.phone.start({ phone: "+1 415 555 0123", }); // destinationHint is "••••23": say where the code went without printing the number. const result = await client.auth.phone.verify({ challengeToken, code }); if (needsSecondFactor(result)) { // Exactly as after a password: answer with client.auth.verifyMfa. } ``` `` in `@base316/react` adds the tab and handles both steps. **Every number gets the same answer.** A number with no account, or whose account never verified it, gets a challenge and a masked number like any other — and a code box no code will satisfy. Nothing is actually texted to it. A wrong code, a spent one and a number with no account all fail with the same message, so the form cannot be used to find out who has registered. **A number joins an account only if that account verified it.** A number you imported, or an operator typed in, is a claim; receiving a code at it proves the phone, not the account the number was typed into. **Signing up.** Where the environment allows sign-up, a code to a number with no account creates one when the code comes back — a user with a verified number and no email address, in a tenant of their own like any other sign-up. Codes are six digits, good for ten minutes, spent on first use, stored only as a digest, and retired after five wrong guesses. Asking again retires the code already sent. ## Proving a number [#proving-a-number] For somebody already signed in: ```ts const { challengeToken } = await client.auth.phone.startVerification(sessionToken, { phone: "+14155550123", }); await client.auth.phone.confirmVerification(sessionToken, { challengeToken, code }); ``` The number becomes the account's, verified, and `user.phone_verified` is delivered to your webhooks. If another account had merely claimed the number without verifying it, the claim moves to the person who proved it. If another account has already verified it, confirming fails with a conflict and nothing moves — that is for support to untangle. You can also set `phoneNumber` on `POST /v1/users` or `PATCH /v1/users/{id}`. It is stored unverified unless you also send `phoneVerified: true`, which is only for migrating people whose number your previous system already verified by a code. ## As a second factor [#as-a-second-factor] ```ts const { factorId, destinationHint } = await client.auth.mfa.enrollSms(sessionToken, { phone: "+14155550123", // omit to use the account's own number }); await client.auth.mfa.confirm(sessionToken, { factorId, code }); ``` `` offers it beside the authenticator app and the emailed code. Where an environment **requires** a second factor, the hosted enrolment page offers "Use a text message instead" whenever the environment can send one. A text message is the weakest of the three factors — SIM swaps are real — and the one people who will not install an app actually turn on. See [Second factors](/guides/second-factors). ## Limits [#limits] Under **Settings → Security & sign-up policy**, in **Text message limits**: * **Messages per number** — how many codes one number may be sent in the window (default 5 an hour). Stops somebody flooding a stranger's phone. * **Messages per address** — how many one IP address may cause (default 20 an hour). Stops one client walking through a range of numbers. Both count requests rather than messages actually sent, so they answer the same way for a number with an account and one without. Both follow the environment's attack protection mode and IP allow-list, like the sign-in limits. Wrong codes count toward the same per-account and per-address failure limits a wrong password does. ## Personal data [#personal-data] A phone number is personal data. It is in the user's data export, alongside a list of the messages sent to it with the number masked to its last two digits. Erasing the user deletes the number and masks it in the message log, which is kept so an environment's message counts stay accountable. # Webhooks ## Verify before you trust [#verify-before-you-trust] ```ts import { constructWebhookEvent } from "@base316/sdk"; app.post("/webhooks/base316", async (request, response) => { // The RAW bytes. Not a re-serialised object. const payload = await readRawBody(request); const event = await constructWebhookEvent({ payload, signature: request.headers["base316-signature"], secret: process.env.BASE316_WEBHOOK_SIGNING_KEY, }); // ... handle event.type ... response.status(200).end(); }); ``` Verify the **raw bytes**, not a parsed and re-serialised object. Any body parser that has already turned the JSON into an object and back has changed the bytes — key order, spacing, number formatting — and the signature will not match. The signing key comes from the endpoint's row in the dashboard's **Webhooks** tab. The signature is compared in constant time and carries a timestamp, so a captured request cannot be replayed indefinitely. ## Billing events [#billing-events] | Event | When | | ------------------------------------------------------------------------- | ------------------------------------------------ | | `customer.created` | An account was opened | | `subscription.created` · `.updated` · `.canceled` | A subscription changed | | `entitlement.updated` | What an account may do changed | | `usage.limit_reached` | An account hit an allowance | | `credits.low_balance` | A metered balance fell below its threshold | | `credit_pack.purchased` | A credit pack was paid for and granted | | `credits.auto_recharged` | Auto-recharge bought a pack and granted it | | `credits.auto_recharge_failed` | Auto-recharge could not buy a pack | | `invoice.created` · `.finalized` · `.paid` · `.voided` · `.uncollectible` | Invoice lifecycle | | `payment.recorded` | A payment was received, including a part payment | | `payment.failed` | A payment failed or was reversed | | `credit_note.issued` | An invoice was partly written off | | `refund.created` | A credit note issued a refund | | `refund.succeeded` | A refund reached the customer | | `refund.failed` | A refund failed; the money is still owed | | `wallet.credited` · `.debited` | Prepaid balance moved | | `agreement.created` · `.sent` · `.viewed` · `.signed` · `.card_added` | An agreement offer moved; see Agreement checkout | | `agreement.activated` · `.revoked` · `.expired` · `.imported` | An agreement ended, or one was imported | ### Why a subscription changed [#why-a-subscription-changed] `subscription.updated` carries a `change` saying why: | `change` | What happened | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `renewed` | A period ended and the next one began. When a scheduled change or ramp step took effect there, `planChange` names it and `planVersionId` is the new plan (was `previousPlanVersionId`) | | `plan_changed` | Moved to another plan; `previousSubscriptionId` is the one that closed | | `quantity_changed` | `previousQuantity` → `quantity`, from the next invoice | | `cancel_scheduled` | Ends at the period end; still entitled until then. `cancellationId` and `cancellationReason` when it came through a cancel flow | | `trial_ended` | A trial ran out and converted: ACTIVE on its own plan, first period billed | | `trial_downgraded` | A trial on a plan whose trial-end action is `DOWNGRADE` ran out with no payment method on file. Same subscription, now ACTIVE on `planVersionId` (was `previousPlanVersionId`); nothing invoiced | | `plan_change_scheduled` | A plan change was scheduled for the period end. `planVersionId` and `effectiveAt` say what and when; nothing changes until then | | `phases_scheduled` | A schedule of phases (a ramp) was set. `phases` lists each with the boundary it takes effect at | | `schedule_canceled` | The pending schedule was withdrawn — undone, or replaced by an empty one | | `price_overrides_changed` | A custom price was added, edited or removed; the next invoice is rated on the new terms | | `contract_updated` | The PO number, invoice memo or billing day changed; `previousBillingAnchorDay` when the day did | | `retention_offer_accepted` | A subscriber who set out to cancel took the retention offer; `discountId` is the discount applied, `resumed` says a scheduled cancellation was lifted | `trial_downgraded` is the one to act on for a reverse trial: switch the account to the free tier in your own product, and invite the customer to add a card to come back. ```json { "subscriptionId": "sub_…", "customerId": "cus_…", "change": "trial_downgraded", "previousPlanVersionId": "pv_team…", "planVersionId": "pv_free…", "currentPeriodStart": "2026-10-16T09:00:00.000Z", "currentPeriodEnd": "2026-11-16T09:00:00.000Z" } ``` `payment.recorded` fires for **every** receipt including instalments; `invoice.paid` fires only when the balance reaches zero. If you are reconciling your own ledger you want the first. Both fire regardless of how the money arrived — a card through a payment link, a renewal charged to a stored card, cash or a cheque entered by hand. So a handler that releases a job when `invoice.paid` arrives works the same whether the customer tapped a phone at the counter or paid on terms three weeks later. ### Knowing when a walk-up customer has paid [#knowing-when-a-walk-up-customer-has-paid] A customer scans a QR code and pays on their own phone. Two things usually need to hear about it, and they are not the same thing: **Your server** wants `invoice.paid` — release the job, print the ticket, close the work order. That is the webhook above, and it is the one to build on. **The screen the code is showing on** cannot receive a webhook. Poll `GET /pay/{environmentId}/{token}/status` instead, with the token you got back from `invoices.createPaymentLink`: ```json { "state": "payable", "number": "INV-2026-000042", "due": "250.00", "currency": "USD" } ``` It answers `settled` once there is nothing left owing. In React, the `usePaymentLink` hook does this for you and stops polling on its own: ```tsx const status = usePaymentLink({ token, environmentId, onPaid: () => closeTicket() }); if (status.state === "settled") { return

Paid — thank you.

; } ``` Poll for the screen, not for the ledger. The status endpoint reports what the invoice owes right now; the webhook is the record that a payment happened. A handler that only polls will miss a payment made while nobody had the page open. ## Identity events [#identity-events] Most applications keep their own `users` row alongside Base316's. These are how you keep it current without polling. | Event | When | | ----------------------------------------------------------------------- | ---------------------------------------------------- | | `user.created` | A user was created through the API or dashboard | | `user.updated` | A user's record changed | | `user.disabled` · `.enabled` | A user was switched off or back on | | `user.signed_up` | Somebody signed themselves up | | `user.signed_in` · `.signed_out` | A session began or ended | | `user.sign_in_failed` | A sign-in was refused | | `user.password_changed` | A password was set or changed | | `user.password_reset_requested` · `.password_reset_completed` | Reset lifecycle | | `user.mfa_enrolled` · `.mfa_verified` · `.mfa_failed` · `.mfa_disabled` | Second-factor lifecycle | | `user.recovery_code_used` | A recovery code was spent | | `user.oauth_linked` | A social account was linked | | `user.impersonation_started` | An operator began acting as a user | | `session.revoked` | A session was ended by somebody other than its owner | | `organization.created` | An organization was created | | `organization.member_added` | Somebody joined, or their role changed | | `organization.member_removed` | Somebody left | | `organization.detached` | A managed tenant was made standalone | | `organization.deleted` | An organization was deleted | `user.created` covers the **administrative** path only — the API, an import, the dashboard. Somebody signing themselves up raises `user.signed_up` instead, so an endpoint watching for new accounts is not woken twice for one person. Subscribe to both if you want every arrival. `organization.member_added` also fires when an existing member's role changes. The payload states what the membership now is, so applying it twice is harmless. `organization.created` carries `organization.parentId` — the tenant that manages the new one, or `null` for a standalone tenant. `organization.detached` carries `previousParentId`; the parent's members lost access to the tenant when it was sent. See [Managed tenants](/guides/managed-tenants). `organization.deleted` carries the tenant as it was (`organization.id`, `name`, `externalId`, `parentId`), the billing account that was unlinked and kept (`customerId`, or `null`), any trial the delete cancelled (`canceledSubscriptionIds`, each also sent as `subscription.canceled` with `reason: "organization_deleted"`) and who was removed with it (`removedMemberUserIds`, each also sent as `organization.member_removed`). A repeated delete answers `alreadyDeleted: true` and sends nothing. A sign-up that accepted an invitation sends `organization.member_added` with `viaInvitation`, the same as accepting it after signing up — and no `organization.created`, because no personal organization was opened. `user.sign_in_failed` is emitted for every refused attempt, including ones against an address that has no account — in which case `userId` is `null` and the attempted `email` is on the payload. Treat it as a security signal, not as evidence that a user exists. User payloads carry the whole record rather than a diff, so your handler can upsert. `metadata` is deliberately left out — it is arbitrary JSON of unbounded size, and a webhook body is not where you want to discover that. Read the user if you need it. ## Handling them well [#handling-them-well] **Return 200 quickly.** Do the work afterwards. A slow handler is retried, and a retried handler that is not idempotent does the work twice. **Expect redelivery.** Events are retried with backoff. Key your handling on the event id so a redelivery is a no-op. **Do not assume order.** `invoice.paid` can arrive before `invoice.finalized` under retry. Treat each event as a statement of fact about a resource, and re-read the resource if order matters. ## Testing [#testing] Under **Developers → Webhooks**, the **Deliveries** tab shows every delivery and the response it got. The **Events** tab lists recent events, newest first, with each endpoint's delivery status. It shows the payload and can replay an event, either to every subscriber or to only the endpoint whose handler you fixed. Replaying is much faster for debugging a handler than causing the real event again. The API does the same: ```ts // Every enabled subscriber again, or only the one that failed. await base316.webhooks.replay("evt_..."); await base316.webhooks.replayTo("evt_...", "whe_..."); ``` ## Changing an endpoint [#changing-an-endpoint] You can change an endpoint's URL, description and event types without recreating it, in the dashboard or with `PATCH /v1/webhooks/endpoints/{id}` (`base316.webhooks.update`). Fields you leave out keep their values, and an empty `eventTypes` subscribes to every event. The signing secret does not change, so your receiver keeps verifying with the secret it already has. A new URL goes through the same check as one at creation: https only, and it must resolve to a public host. # Machine authentication without secrets A machine client normally proves itself with a client secret — the one that ends up in a CI variable, a Helm value and three laptops. Base316's OIDC provider offers two ways to stop deploying that secret, plus a strict mode for clients that should follow OAuth 2.1 to the letter. | You have | Use | | -------------------------------------- | ------------------------- | | A service that can hold a private key | `private_key_jwt` | | A Kubernetes pod, a GitHub Actions job | a **trusted issuer** | | A client that must meet OAuth 2.1 | **OAuth 2.1 strict mode** | Everything here is per environment: keys, issuers and rules configured in Sandbox mean nothing in Production. ## Signed assertions (`private_key_jwt`) [#signed-assertions-private_key_jwt] The client keeps a private key; Base316 stores only the public half. Instead of a secret, the client sends a short-lived JWT signed with that key (RFC 7523, OpenID Connect Core §9). ### Set it up [#set-it-up] 1. Generate a key pair. EC P-256 is smallest; RSA must be 2048 bits or more. ```bash openssl ecparam -name prime256v1 -genkey -noout -out client.key openssl ec -in client.key -pubout -out client.pub ``` 2. Under **Settings → Applications**, in **OpenID Connect**, choose **Register application** (or **Edit** an existing one). Set **Client authentication** to **Private key JWT**, and either paste the public key set into **Public keys (JWKS)** or give a **JWKS URL** where you publish it. A set containing a private key is refused — and that key should be treated as exposed. No client secret is issued. ### Authenticate [#authenticate] Sign an assertion for every request. `iss` and `sub` are the client id, `aud` is the token endpoint (or the issuer), `exp` at most five minutes ahead, and `jti` unique — each assertion is accepted **once**. ```ts import { randomUUID } from "node:crypto"; import { importPKCS8, SignJWT } from "jose"; const issuer = "https://api.base316.com/oidc/env_1a2b3c"; const clientId = "oidc_…"; const key = await importPKCS8(process.env.CLIENT_PRIVATE_KEY!, "ES256"); const assertion = await new SignJWT({}) .setProtectedHeader({ alg: "ES256", kid: "my-key-1" }) .setIssuer(clientId) .setSubject(clientId) .setAudience(`${issuer}/token`) .setIssuedAt() .setExpirationTime("60s") .setJti(randomUUID()) .sign(key); const response = await fetch(`${issuer}/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", client_assertion: assertion, }), }); ``` `client_id` may be left out; it is read from the assertion. The same form works at `/introspect` and `/revoke`, whose own URLs are also accepted as `aud`. ### What is refused [#what-is-refused] * **A secret, from a client registered for keys** — and an assertion from a client registered for a secret. There is no falling back to the weaker method. * An assertion used twice, one addressed to another issuer, one expiring more than five minutes out, one issued in the future. * `alg: none` and every HMAC algorithm. The algorithm is decided by the key type: RS256–RS512 and PS256–PS512 for RSA, ES256 for P-256. With a **JWKS URL** the key set is cached for five minutes. A token naming a key id the cache lacks triggers one refetch — at most every 30 seconds — so a rotation is picked up without waiting for the cache to expire. Publish the new key before you start signing with it. ## Workload identity (trusted issuers) [#workload-identity-trusted-issuers] Kubernetes, GitHub Actions and every major cloud already sign a token saying which workload is running. Trust that issuer, map its subjects onto a client, and the workload exchanges its own token for an access token with the JWT bearer grant (RFC 7523 §2.1). It receives exactly what `client_credentials` would give that client — the same audience, the same plan-narrowed scopes, the same rate-limit claim — plus a `workload` claim naming who presented it: ```json { "sub": "oidc_invoice_worker", "aud": "https://api.example.com", "scope": "invoices.write", "workload": { "iss": "https://oidc.eks.eu-west-1.amazonaws.com/id/EXAMPLE", "sub": "system:serviceaccount:billing:invoice-worker" } } ``` ### Set it up [#set-it-up-1] 1. Under **Settings → Applications**, in **OpenID Connect**, register (or edit) the client the workload should become, with **JWT bearer (workload identity)** ticked under **Grants**, and the API it may call. This is the machine identity; nothing is ever created from a token. 2. Below it, in **Trusted issuers**, choose **Add trusted issuer**: * **Issuer** — exactly as the token's `iss` spells it. * **Audience** — the `aud` your workload will request. It defaults to this environment's issuer URL. * **Signing keys** — **Discover from the issuer**, a **JWKS URL**, or **Paste a key set** for a private cluster whose issuer nothing outside can reach. * **Maximum token lifetime** — tokens valid for longer are refused. * **Rules** — a **Subject** and the **Client** it becomes, plus optional **Required claims**. 3. Paste a real token into **Test a token**. It says which rule matched, or which check failed and why. The token endpoint itself answers every refusal with the same `invalid_grant`, so this is where to debug. The same configuration is available over the API as `/v1/trusted-issuers` (`base316.trustedIssuers` in the SDK), including `POST /v1/trusted-issuers/test`. **Subjects.** A rule's subject is exact, or a pattern in which `*` matches within one segment and never crosses `:` or `/`. `system:serviceaccount:billing:*` matches every service account in the `billing` namespace and nothing in any other; `repo:acme/*` does not match `repo:acme/api:ref:refs/heads/main`. Exact subjects are tried before patterns. ### Kubernetes [#kubernetes] Ask the kubelet for a projected service-account token made out to your audience. It is rotated automatically, and the pod presents it as it is. ```yaml apiVersion: v1 kind: Pod metadata: name: invoice-worker namespace: billing spec: serviceAccountName: invoice-worker containers: - name: worker image: example/invoice-worker volumeMounts: - name: base316-token mountPath: /var/run/secrets/base316 readOnly: true volumes: - name: base316-token projected: sources: - serviceAccountToken: path: token audience: https://api.base316.com/oidc/env_1a2b3c expirationSeconds: 3600 ``` Trust the cluster's issuer (`kubectl get --raw /.well-known/openid-configuration` shows it; on EKS, GKE and AKS it is a public URL that discovery can read). For a cluster whose issuer is private, paste the output of `kubectl get --raw /openid/v1/jwks` as the key set. Then add a rule with the subject `system:serviceaccount:billing:invoice-worker` (or `system:serviceaccount:billing:*`). In the pod: ```bash curl -s https://api.base316.com/oidc/env_1a2b3c/token \ -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ --data-urlencode "assertion=$(cat /var/run/secrets/base316/token)" \ -d scope=invoices.write ``` Unlike a client assertion, a workload token may be presented more than once: the pod reuses its projected token until the kubelet replaces it. ### GitHub Actions [#github-actions] Trust `https://token.actions.githubusercontent.com` with discovery, and pin the repository by more than its name — a fork can have the same one: * **Subject** `repo:acme/api:environment:production` * **Required claims** `repository_owner_id=123456` ```yaml permissions: id-token: write steps: - run: | TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.base316.com/oidc/env_1a2b3c" | jq -r .value) curl -s https://api.base316.com/oidc/env_1a2b3c/token \ -d grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \ --data-urlencode "assertion=$TOKEN" ``` ## OAuth 2.1 strict mode [#oauth-21-strict-mode] Tick **OAuth 2.1 strict mode** on a client, in **Register application** or **Edit**. The client then must: * send PKCE (`S256`) on **every** authorization request, confidential clients included — a code issued without one is refused at the token endpoint; * send `redirect_uri` when it redeems a code; * accept rotating refresh tokens (rotation is forced on); * authenticate with HTTP Basic or `private_key_jwt`, never with `client_secret` in the request body. Every dynamically registered client is strict. One that asks to register with `client_secret_post` is registered with `client_secret_basic` instead, and the registration response says so. # Background tasks Ten tasks run on a timer. Two of them write money. | Task | Every | Does | | ----------------------- | ----- | ------------------------------------------------- | | `webhook-deliveries` | 10s | Attempts every delivery that is due | | `prune-auth-codes` | 15m | Drops spent and expired authorization codes | | `prune-oauth-flows` | 15m | Drops social sign-ins that were never finished | | `prune-oidc` | 15m | Abandoned sign-ins, dead tokens, retired keys | | `renew-subscriptions` | 1h | **The billing run.** Invoices and rolls periods | | `collect-invoices` | 1h | **Charges** what the billing run raised | | `expire-quotes` | 1h | Tidies the status column on lapsed quotes | | `end-expired-trials` | 1h | Converts — or downgrades — a finished trial | | `refresh-sso-discovery` | 6h | Re-reads each customer IdP's discovery document | | `prune-audit-logs` | 24h | Ages out audit entries after a year | | `prune-sns-messages` | 24h | Drops processed SES feedback claims after 30 days | ## One task, one process [#one-task-one-process] Every tick takes a lease before it runs. A task already running somewhere else is skipped, silently — that is the ordinary case on every replica but one. This is not tidiness. `renewSubscription` reads a subscription, checks its period is over, and writes an invoice, with nothing holding the row in between. Two sweeps at the same moment both pass the check and both bill, and nothing downstream catches it: the only unique constraint on an invoice is its number, and a number is minted per invoice. Without the lease, scaling the API past one replica double-bills every subscription due that hour. The lease is what makes replicas safe — it is not an optimisation. The lease is a row in `scheduler_lock`, so an operator can see what holds what: ```sql SELECT name, holder, "expiresAt" FROM scheduler_lock ORDER BY name; ``` A `holder` is `host:pid:random`. An empty table means nothing is running right now, which is normal between ticks. ## Where it runs [#where-it-runs] By default the scheduler starts inside the API, which is right for a single instance and for development. ```bash pnpm --filter api start # API, with the scheduler ``` For a deployment, run it as its own workload and take it out of the API: ```bash BASE316_SCHEDULER=off pnpm --filter api start # API, serving requests only pnpm --filter api scheduler # the tasks, on their own ``` Two reasons to split rather than leave it on everywhere. A billing sweep sharing a pod with request traffic is a latency spike nobody can explain from the outside. And the API is then free to scale on load, because nothing about its replica count touches billing any more. The value must be exactly `off`. Anything else — unset, `false`, `0` — leaves the scheduler running, because the safe reading of an ambiguous value is the one where the invoices still go out. ## If a task stops running [#if-a-task-stops-running] A lease outlives its holder by design: a process killed mid-sweep would otherwise block the task for ever. The lease is five minutes for most tasks and thirty for the two billing ones, renewed continuously while the task runs, so the worst case is that a task waits one lease before another instance picks it up. If a task appears stuck for longer than that, the row says who has it: ```sql SELECT * FROM scheduler_lock WHERE name = 'renew-subscriptions'; ``` An `expiresAt` in the past means nothing holds it and the next tick will take it. An `expiresAt` that keeps moving forward means the task genuinely is still running. Deleting the row is safe but rarely the answer — it does not stop the process that holds it, so it invites exactly the concurrent run the lease prevents. ## Notes [#notes] * A missed run catches up on the next tick rather than being skipped. The billing run is hourly rather than daily for the same reason: a period ending at 09:00 is invoiced within the hour, not at midnight. * `expire-quotes` is cosmetic. `effectiveStatus` already refuses a lapsed quote, so if this task never runs, nothing is mis-accepted — the reporting just reads oddly. * Tasks are timed from the end of the previous run, so a slow drain cannot pile up concurrent copies inside one process either. # Bounces and complaints Every email Base316 sends on a customer's behalf is recorded as a delivery and checked against the environment's suppression list first. SES reports what happened to each message afterwards — delivered, delayed, bounced, or marked as spam — through an SNS topic that posts to the API: ```text SES configuration set → SNS topic → POST /internal/ses/notifications ``` Setting that up in AWS is `docs/runbooks/ses-feedback.md` in the repository. ## What each event does [#what-each-event-does] | SES event | Delivery status | Suppresses? | | --------------------- | --------------- | ---------------------------------------------- | | `Delivery` | Delivered | No | | `DeliveryDelay` | Delayed | No — SES is still retrying | | `Bounce`, `Permanent` | Bounced | **Yes**, as `HARD_BOUNCE` | | `Bounce`, `Transient` | Bounced | Only the third in seven days, as `SOFT_BOUNCE` | | `Complaint` | Marked as spam | **Yes**, as `COMPLAINT` | * **A transient bounce is not a dead address.** A full mailbox empties and a server that deferred comes back, so one soft bounce is recorded on the delivery and nothing else. Three to the same address, in the same environment, within seven days suppress it: at that point it is behaving like a dead address, and mailing it keeps hurting the sender's reputation. Bounces about the message rather than the mailbox (`ContentRejected`, `AttachmentRejected`) never count. * **An `Undetermined` bounce counts like a transient one.** * A complaint whose feedback type is `not-spam` is recorded and does not suppress — it is a report that the recipient said the opposite. * A status never moves backwards: a `Delivery` that arrives after a complaint leaves the message marked as spam. The delivery keeps what SES said — bounce type, sub-type and the receiving server's diagnostic (`smtp; 550 5.1.1 user unknown`) — and the suppression's note carries the same, so **Settings → Email** answers "why are we not mailing this person" without a log search. ## Whose list [#whose-list] A bounce or complaint suppresses the address **in the environment that sent the message**, and nowhere else. The environment comes from the delivery row that SES's message id points to — never from the notification — and only that delivery's own recipient can be suppressed. One exception: **a complaint about mail sent from Base316's shared sender** — an environment that has not verified a sending domain — is also suppressed platform-wide. That complaint counts against the shared domain for every customer using it, so no environment can mail that person from it again. Mail from a customer's own verified domain stays environment-scoped: that reputation is theirs. A send refused this way is recorded as **Suppressed**, with a note saying why; verifying a sending domain lets that environment mail the address from its own. Unsuppressing an address (**Unsuppress** on the list, which needs the organization-manage permission) only removes the environment's own entry. ## Trust [#trust] The endpoint is unauthenticated — SNS presents no API key — so every message has to earn its way in: 1. The `TopicArn` must be one of `BASE316_SES_SNS_TOPIC_ARNS`. Anyone can create an SNS topic and subscribe our URL to it, and AWS would sign those messages too; the allow-list is what makes a signed message ours. Unset, the endpoint answers `503`. 2. The SNS signature must verify (SignatureVersion 1 or 2). The signing certificate is downloaded only over HTTPS from an `sns..amazonaws.com` host, with redirects refused, and cached for a day. 3. A `SubscriptionConfirmation` is confirmed by visiting its `SubscribeURL` — only when the topic is allow-listed, and only if that URL is also an SNS host. 4. Each notification is claimed by its SNS `MessageId` before it is acted on (`sns_message`), so SNS's at-least-once redelivery cannot apply it twice. A notification whose processing failed answers `500` and is let through again on the retry. A notification naming an SES message id with no delivery row — Base316's own account mail, or another product sending from the same SES account — is acknowledged with `200` and ignored, so SNS stops retrying it. Processed claims are kept for 30 days and pruned daily by `prune-sns-messages`; failed ones are kept. # Rotating the encryption key Four things are stored encrypted rather than hashed, because Base316 has to **use** them rather than merely check them: | | | | ----------------------------------- | ------------------------------------------- | | `RuntimeOAuthProvider.clientSecret` | Presented to Google or Apple | | `SsoConnection.clientSecret` | Presented to a customer's identity provider | | `OidcSigningKey.privateKey` | Signs the tokens Base316 issues | | `StripeWebhookEndpoint.secret` | Verifies events Stripe sends | All four use AES-256-GCM under `BASE316_ENCRYPTION_KEY`. ## The keyring [#the-keyring] A ciphertext records **which key made it**: ``` v2.... ``` That is what makes rotation possible: several keys can be held at once, new values are written under the primary, and old values still decrypt under the key that made them. | Variable | For | | ------------------------- | ---------------------------------------------------------- | | `BASE316_ENCRYPTION_KEY` | One key, which becomes `k1`. Fine until you need to rotate | | `BASE316_ENCRYPTION_KEYS` | `id:base64` pairs, comma separated, **primary first** | ```bash # Before a rotation BASE316_ENCRYPTION_KEY= # During one — new key first, old key still present BASE316_ENCRYPTION_KEYS=k2:,k1: # After BASE316_ENCRYPTION_KEYS=k2: ``` ## Rotating [#rotating] The order matters. Dropping the old key before step 3 finishes makes every value still under it **permanently unreadable** — those credentials would have to be re-entered by hand. **1. Generate a key.** ```bash openssl rand -base64 32 ``` **2. Add it as the primary, keeping the old one.** ```bash BASE316_ENCRYPTION_KEYS=k2:,k1: ``` Deploy. Nothing breaks: existing values still decrypt under `k1`, and anything written from now on uses `k2`. **3. Check, then move everything.** ```bash pnpm --filter @repo/scripts rotate-keys:check # reports, writes nothing pnpm --filter @repo/scripts rotate-keys ``` ``` Rotation complete. Primary key k2 Moved 4 Already there 0 Still pending 0 Everything is on the primary key. The old key can now be dropped. ``` The script is **idempotent** — a partial run is resumed by running it again — and exits non-zero while anything is still pending, so a deploy pipeline can gate the next step on it. **4. Drop the old key.** ```bash BASE316_ENCRYPTION_KEYS=k2: ``` Only once `Still pending` is `0`. ## If something cannot be decrypted [#if-something-cannot-be-decrypted] ``` Could not be decrypted. Each of these has to be re-entered by hand: StripeWebhookEndpoint.secret swe_abc123 (written under k1) ``` This means a value was encrypted under a key the process no longer holds — almost always an old key dropped too early. The rotation reports it rather than skipping it quietly, because silence here becomes a broken integration nobody can explain months later. There is no recovery from the ciphertext. Re-enter the credential: reconnect the OAuth provider, re-add the SSO connection, or let Base316 mint a new signing key. ## Notes [#notes] * A rotation walks **every row**, not one environment. Keys are process-wide, so a partial rotation would leave the rest unreadable the moment the old key was dropped. * Values written before key ids existed (`v1.…`) still read, and are moved onto the primary key by the same run. * Decryption **fails closed**. A wrong key, an unknown key id, or a tampered ciphertext all return nothing — never a plausible-looking wrong value. # CLI Command line tools for Base316. Answers the questions that come up over and over while integrating: *which environment am I pointed at*, *why was this entitlement refused*, *did that usage event land* — and, in a sandbox, *what happens next month*. ```bash npx @base316/cli login npx @base316/cli init npx @base316/cli whoami ``` ## Getting started [#getting-started] These need no key — they are how a directory gets one. | | | | ---------------------------------------------------------------------------- | ------------------------------------------------------------- | | `login [--no-wait]` | Sign in as yourself by approving a code in the browser | | `init [--project ] [--workspace ] [--env-file ] [--force]` | Create or pick a project and write its sandbox keys to `.env` | | `logout` | Sign out, revoking this machine's session on the server | `login --no-wait` prints the link and code and exits, for an agent that only sees output once a command finishes; a second `login` waits for the approval. See [Build with an agent](/getting-started/agents) for the whole flow. Every other command reads `BASE316_SECRET_KEY` from the environment, or from `.env.local` / `.env` in the current directory when it is not set — so after `init`, nothing needs exporting. An exported value always wins over the file. ## Commands [#commands] **Catalog and accounts** | | | | ------------------------------------------ | -------------------------------------------------------- | | `whoami` | Which project and environment this key points at | | `plans` · `features` · `products` | The catalog | | `customers` · `subscriptions` · `invoices` | Accounts and what they owe | | `organizations [parent]` | Tenants, or the clients one parent manages | | `billed ` | The accounts a customer pays for, and their usage | | `refunds [invoice]` | Refunds, and whether each went through | | `invoice:pdf [file]` | Download an invoice as a PDF | | `phases ` | What is scheduled, and what already applied | | `price-overrides ` | A subscription's custom prices, beside the ones replaced | | `api-resources` | The protected APIs, and the scopes each declares | **Entitlements** | | | | ---------------------------- | ------------------------------------------------- | | `check ` | Whether a customer is entitled, **and why not** | | `entitlements ` | Every feature at once, allowed or denied, and why | **Previews** — nothing changes | | | | ------------------------------------------------- | ----------------------------------------------------- | | `upcoming ` | The next invoice — nothing is raised | | `quantity:preview ` | What changing the quantity would charge | | `plan-preview ` | A plan change, now and at period end | | `billing-anchor ` | What moving the billing day would charge | | `plans:outdated [plan]` | Subscriptions behind their plan's newest version | | `plans:migrate-preview [timing]` | What moving those subscribers onto a version would do | **Writes** | | | | ---------------------------------------------- | --------------------------------------------------------- | | `usage ` | Record one event, to prove metering works | | `seed [--reset]` | Fill a sandbox with a catalog, customers and usage | | `clock:advance ` | Move a sandbox's clock forward and run the billing sweeps | | `api-resource:create ` | Declare an API so tokens can be issued for it | | `api-scope:add [feature]` | Declare a scope, optionally gated on a feature | | `replay ` | Queue a past webhook event for delivery again | **Webhooks** | | | | ------------------------------- | -------------------------------------------------------------------- | | `listen [--type ]` | Forward this environment's events to a local URL, as a webhook would | A customer can be named by Base316 id (`cus_…`) or by your own id — the prefix decides which. ## Options [#options] | | | | -------- | ------------------------------------------------ | | `--json` | Print the raw API response, for piping into `jq` | | `--live` | Allow a write against a live key | | `--help` | The command list | ## Writes are guarded on a live key [#writes-are-guarded-on-a-live-key] Every command marked as a write is refused on a **live** key unless `--live` is passed, before any request is made: ``` $ base316 usage cus_abc123 api_call Refused: `usage` would change live data. This key is a live key. Re-run with --live if you mean it, or export a sandbox key instead. ``` A CLI is run from shell history and from scripts, and the gap between seeding a sandbox and metering ten thousand events against production is one environment variable somebody forgot to change. The band is read from the key's own prefix, so the check cannot fail open when the network does. ## Environment [#environment] | | | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `BASE316_SECRET_KEY` | A secret key (`b316_sk_…`), or read from `.env.local` / `.env`. A publishable key is refused — it cannot read customers or invoices. | | `BASE316_API_URL` | Defaults to `https://api.base316.com`. Point it at `http://localhost:3110` for a local stack. | | `BASE316_APP_URL` | The dashboard `login` and `init` talk to. Defaults to `https://app.base316.com`. | | `BASE316_CONFIG_DIR` | Where `login` keeps its session. Defaults to `~/.config/base316`. | ## Debugging an entitlement [#debugging-an-entitlement] The command worth knowing about: ``` $ base316 check cus_abc123 api_calls DENIED Reason USAGE_LIMIT_EXCEEDED Limit 1000 Used 1000 Remaining 0 ``` A bare "false" sends you to the dashboard. A reason sends you to the fix. # Components `@base316/react` renders Base316's screens inside your own pages rather than on a page your users are sent away to. ```bash npm install @base316/react @base316/sdk ``` ## Wrap your app once [#wrap-your-app-once] ```tsx import { Base316Provider } from "@base316/react"; export default function App({ children }) { return ( {children} ); } ``` The provider holds one client and the signed-in person's session, and loads the branding you set in the dashboard — so the components look like your hosted pages without configuring your brand twice. ## What there is [#what-there-is] | Area | Components | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Signing in | `SignIn` · `SignUp` · `ForgotPassword` · `ResetPassword` · `VerifyEmail` · `DeviceApproval` | | The account | `UserProfile` · `PasskeyManager` · `TwoFactorManager` · `ActiveSessions` · `PrivacyControls` | | Teams | `TeamMembers` · `AcceptInvitation` | | Billing | `BillingPortal` · `PricingTable` · `PlanChangeReview` · `SubscriptionManager` · `UpcomingInvoice` · `SeatManager` · `PaymentMethod` · `Invoices` · `PayInvoice` · `usePlans` | | Your data | `DataTable` · `Kanban` · `Chart` · `RecordDetail` · `RecordForm` · `RelationPicker` | | Brand | `Branding` | | For your own | `useBase316` · `useSession` · `useCollection` | Every component and prop is also described in machine-readable form — `MANIFEST`, exported from the package — which is what an agent building your screens reads to choose between them. ## White-label for a tenant [#white-label-for-a-tenant] When your product is run by one business on behalf of others — a firm and its client companies, an agency and its brands — the screens a client sees should carry the firm's brand, not yours. Give the provider the tenant and every component under it follows: ```tsx ``` * Name the tenant by Base316's id (`organizationId`) or your own (`organizationExternalId`). * Its branding is resolved field by field: the tenant's own, then its parent's, then your environment's. So a firm's clients show the firm's brand with nothing set on each client — and a client that sets its own colour keeps the firm's name and logo. * Change `tenant` when the person switches team and the components restyle without a reload. * A tenant id that does not exist in the environment falls back to your own branding rather than failing, so the page never renders unbranded. The same lookup is available without React: `billing.tenantBranding({ organizationId })` on `Base316Browser`, or `GET /v1/public/branding?organizationId=…` with a publishable key. Branding is set per tenant in the dashboard (**Branding** on the tenant's row) or with `organizations.setBranding` — see [Managed tenants](/guides/managed-tenants#the-firms-brand-on-the-firms-domain). ### A whole billing page [#a-whole-billing-page] `BillingPortal` is the billing screen in one component: plan and status, the next invoice, a scheduled change with its undo, a plan picker whose choice opens `PlanChangeReview` (gained and lost features, what it costs now or when it waits), seats on a per-seat plan, the card on file (`PaymentMethod`, which replaces it on a Stripe-hosted page), invoices with PDF download and **Pay**, and cancel and resume. Pass `billingDetails` to put your own address or tax-ID form in its Billing details section. The same page is also hosted by Base316 for apps that do not use React — see [Billing portal](/guides/billing-portal). ### A downgrade scheduled for the renewal [#a-downgrade-scheduled-for-the-renewal] Picking a cheaper plan in `PricingTable` schedules it for the end of the period rather than switching at once: the current plan stays marked current, and the table says when the new one starts. `usePlans().selectPlan` returns `{ status: "scheduled", effectiveAt }` for it. `SubscriptionManager` then shows "Moves to Basic on 1 Nov" with a **Keep Pro** button that withdraws the change. ### Billing screens for a firm's clients [#billing-screens-for-a-firms-clients] When the firm pays for a client, `SubscriptionManager` shows the firm's plan with "Provided by Hartley & Co" and no cancel button, and `PricingTable` names the firm above the plans and disables every button — a plan bought there would grant the client nothing. `usePlans()` exposes the same as `managedBy`. ### Teams reached through a firm [#teams-reached-through-a-firm] A firm's staff reach its clients' teams without being members of them. In `TeamMembers`, such a team says where the access comes from — "You have access through Hartley & Co" — and offers nothing to leave, because there is no membership to leave: the access ends when the firm's role mapping or the client's link to the firm does. ### What a manager can do to other roles [#what-a-manager-can-do-to-other-roles] `team.manage` decides who may invite and remove people at all. What they may do to somebody else's role is bounded by their own: a member can invite people only to a role whose permissions they hold every one of, and can remove only a member whose role is within theirs. An admin cannot invite an owner, withdraw the owner's invitation, or remove the owner; an owner can do all three to another owner. `TeamMembers` invites to the environment's default role, so if that is a role holding `*`, only a member holding `*` can invite from it — call `teams.invite` with a `roleId` for anybody else. Leaving is always allowed, with one exception: a member holding `team.manage` cannot leave while nobody remaining holds a role covering theirs. That is the last owner — they give the role to somebody first. Your server, with a secret key, is not bounded by either rule. The member list carries addresses only for somebody holding `team.manage`; everybody else sees names and roles, with `email` null. ## Styling [#styling] Three ways, from least to most invasive: 1. **The dashboard.** Brand colour, background and logo arrive automatically — your environment's, or the tenant's when the provider is given one. 2. **`appearance` tokens** on the provider: `{ colorPrimary, borderRadius, … }`. These win over the dashboard's values. 3. **Your own CSS**, against the `b316-*` class names or a `className` on any component. Every colour, radius and font is a `--b316-*` custom property. ## Reading the session [#reading-the-session] ```tsx import { useSession } from "@base316/react"; function Greeting() { const { session, loading } = useSession(); if (loading) return null; return session ?

Hello {session.user.email}

: Sign in; } ``` # MCP server A Model Context Protocol server for Base316. It lets an agent read your catalog, ask whether a customer may use a feature, and record that they did. Everything goes through the public REST API with your own secret key, so the server has no privileged access of its own — it can do exactly what that key can do and nothing more. ## Configure it [#configure-it] Claude Code, in a project where `npx @base316/cli init` has run: ```bash claude mcp add base316 -- npx -y @base316/mcp ``` The server starts in the project directory and reads `BASE316_SECRET_KEY` and `BASE316_API_URL` from its `.env.local` or `.env`, so no key goes into the client's configuration. See [Build with an agent](/getting-started/agents). Anywhere else, pass the key explicitly: ```bash claude mcp add base316 --env BASE316_SECRET_KEY=b316_sk_sandbox_... -- npx -y @base316/mcp ``` Claude Desktop (`claude_desktop_config.json`) and Cursor (`~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project) take the same block: ```json { "mcpServers": { "base316": { "command": "npx", "args": ["-y", "@base316/mcp"], "env": { "BASE316_SECRET_KEY": "b316_sk_sandbox_..." } } } } ``` | Variable | Required | Meaning | | -------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `BASE316_SECRET_KEY` | yes | A secret key (`b316_sk_…`), set here or in the project's `.env.local` / `.env`. A publishable key is refused: it cannot read customers or invoices. | | `BASE316_API_URL` | no | Defaults to `https://api.base316.com`. Point it at `http://localhost:3110` to work against a local stack. | | `BASE316_MCP_ALLOW_WRITES` | no | Set to exactly `true` to allow writes on a **live** key. | ## Writes are gated on the key [#writes-are-gated-on-the-key] Reads are always available. Anything that changes something — `record_event`, `create_customer`, `create_subscription`, and any write reached through `call_operation` — can put charges on a real customer's invoice, so on a **live** key it is refused unless `BASE316_MCP_ALLOW_WRITES=true` is set deliberately. Sandbox keys write freely, which is what a sandbox is for. A disabled tool says so in its own description, so a model can plan around it rather than discovering the refusal by calling it. ## Tools [#tools] **Reads** | Tool | What it answers | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | `whoami` | Which project and environment this key belongs to, and whether it is sandbox or live | | `list_products` | The products this environment sells | | `list_plans` | Plans and versions, with prices and the features each grants | | `list_features` | The features gated on, and the meter behind each metered one | | `list_customers` / `get_customer` | Accounts, with currency, terms and collection method | | `list_subscriptions` | Plan, status and current billing period | | `list_invoices` | Invoices, including what is still owed rather than only the total | | `preview_upcoming_invoice` | What a subscription will be charged next, and when — computed the way the billing run will, with nothing stored | | `preview_quantity_change` | What changing a subscription's quantity would charge now and each period after, and when it applies — nothing is changed | | `preview_plan_change` | What moving a subscription to another plan would do now and at the period end: entitlements, proration and the next renewal | | `check_entitlement` | **The core one.** May this customer use this feature, and how much allowance is left — counting plan limits and prepaid credits | | `get_customer_entitlements` | Every feature for one customer, allowed or denied and why, with the plan version, credit grants and paying account behind each | **Writes** | Tool | What it does | | --------------------- | ------------------------------------------------------------------------------------------------- | | `record_event` | Records one usage event. Requires an `idempotencyKey`, so a retry after a timeout records it once | | `create_customer` | Opens a billable account | | `create_subscription` | Starts billing | **Everything else** The tools above are the hot path. Behind them, three more reach the rest of the API — every endpoint this deployment serves, not a curated subset. | Tool | What it does | | -------------------- | --------------------------------------------------------------------------- | | `list_operations` | Search everything the API can do. Filter by `search`, `tag` or `writesOnly` | | `describe_operation` | One operation's parameters and JSON body schema, with its real constraints | | `call_operation` | Call it, by `operationId`, with `pathParams`, `query` and `body` | They are driven by [the OpenAPI document](https://api.base316.com/openapi.json), fetched from whichever deployment your key points at — so the reachable surface is the one actually running, not whatever was true when this package was published. **Why not one tool per endpoint?** That would be about two hundred tools. A model choosing between two hundred tools chooses worse than one choosing between fifteen, and the list is context you pay for on every turn. Discovery costs one call and keeps the choice small. `call_operation` obeys the same gate as everything else, decided per call from the operation you name: reads go through on a live key, writes do not. An `operationId` it cannot identify is treated as a write, because guessing "harmless" for something unrecognised is the wrong way round. ## Working against a local stack [#working-against-a-local-stack] ```bash BASE316_API_URL=http://localhost:3110 \ BASE316_SECRET_KEY=b316_sk_sandbox_... \ pnpm --filter @base316/mcp start ``` Diagnostics go to stderr; stdout carries the protocol. # SDKs Base316 has a client for seven languages. Each one depends on nothing beyond its standard library — the TypeScript one uses global `fetch`, the .NET one the BCL — so adding Base316 never changes your dependency graph. | Language | Where | | ----------------------- | ------------------------------------------- | | TypeScript / JavaScript | `npm install @base316/sdk` | | Python 3.9+ | `sdks/python` — the `base316` package | | Go 1.22+ | `sdks/go` — `github.com/base316/base316-go` | | Ruby 2.6+ | `sdks/ruby` — `lib/base316.rb` | | Java 17+ | `sdks/java` — `com.base316` | | C# / .NET 8+ | `sdks/csharp` — the `Base316` project | | PHP 8.1+ | `sdks/php` — `base316/sdk` | Only the TypeScript SDK is published to a registry today. The other six are single-file clients in the Base316 repository: copy the source into your project, or vendor the directory. Each one is a handful of files with no dependencies, which is what makes that practical. The TypeScript package covers the whole API — every endpoint has a method — and includes `Base316Browser`, the client for a publishable key in a browser. The other six cover what an application's server actually touches, listed below; anything beyond that is one HTTPS call with your secret key. For drop-in screens rather than calls, see [Components](/tools/components). ## What every SDK covers [#what-every-sdk-covers] | Area | What | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Keys | Parse a key and refuse one that cannot do the job, at construction | | Sign-in | Build the hosted sign-in URL; exchange the one-time code for a session | | Sessions | Resolve and revoke | | Access | `checkAccess` — entitlement, permission and relationship in one call | | Usage | `recordEvent`, with a required idempotency key | | Customers | Create, read, list; the accounts a customer pays for; every feature evaluated (`customers.entitlements` · `get_customer_entitlements` · `GetCustomerEntitlements` · `getCustomerEntitlements` · `GetCustomerEntitlementsAsync`) | | Subscriptions | Create, with an optional `trialDays` for a no-card trial; preview the next invoice (`subscriptions.upcomingInvoice` · `get_upcoming_invoice` · `GetUpcomingInvoice` · `getUpcomingInvoice` · `GetUpcomingInvoiceAsync`); change the quantity, prorated, after previewing it (`previewQuantity` / `setQuantity` · `preview_quantity_change` / `change_subscription_quantity` · `PreviewQuantityChange` / `ChangeSubscriptionQuantity`); preview, make or schedule a plan change and undo a scheduled one (`previewPlanChange` · `changePlan` · `cancelScheduledChange`, `preview_plan_change` · `change_plan` · `cancel_scheduled_change` in Python and Ruby). Ramps: `subscriptions.setPhases` in TypeScript | | Retention | The retention offer and its report; start a cancellation, then accept the offer or confirm (`retention.start` / `accept` / `confirm` · `start_cancellation` / `accept_retention_offer` / `confirm_cancellation` · `StartCancellation` / `AcceptRetentionOffer` / `ConfirmCancellation` · `startCancellation` · `StartCancellationAsync`). See [Retention offers](/guides/retention-offers) | | Organizations | List, create (`parentId` for a managed tenant), children, detach, members | | Managed tenants | Read and replace the inherited-role mapping | | White-label | Read and set a tenant's branding; claim, verify and remove its sign-in domain | | Webhooks | Verify a signature in constant time, with a timestamp tolerance; parse the event | ## Managed tenants, by language [#managed-tenants-by-language] The same calls, named the way each language names things. TypeScript groups them under `organizations` and `customers`. | Call | TypeScript | Python / Ruby | Go | Java · PHP · C# | | ----------------------- | ---------------------------------------------------------------------- | ---------------------------------------------------------- | --------------------------------- | ------------------------------------------------ | | Create a client tenant | `organizations.create({ parentId })` | `create_organization(parent_id=)` | `CreateOrganization` | `createOrganization` · `CreateOrganizationAsync` | | List a parent's clients | `organizations.children(id)` | `list_child_organizations` | `ListChildOrganizations` | `listChildOrganizations` | | Detach | `organizations.detach(id)` | `detach_organization` | `DetachOrganization` | `detachOrganization` | | Read the role mapping | `organizations.inheritedRoles(id)` | `get_inherited_roles` | `GetInheritedRoles` | `getInheritedRoles` | | Replace it | `organizations.setInheritedRoles(id, …)` | `set_inherited_roles` | `SetInheritedRoles` | `setInheritedRoles` | | What a firm pays for | `customers.billed(id)` | `list_billed_customers` | `ListBilledCustomers` | `listBilledCustomers` | | A tenant's branding | `organizations.branding` · `setBranding` | `get_` · `set_organization_branding` | `Get` · `SetOrganizationBranding` | `get` · `setOrganizationBranding` | | A tenant's domain | `organizations.domain` · `addDomain` · `verifyDomain` · `removeDomain` | `get_` · `add_` · `verify_` · `remove_organization_domain` | `…OrganizationDomain` | `…OrganizationDomain` | C# methods end in `Async` and take a `CancellationToken`. Setting branding is a patch in every language: leave a key out to keep it, pass an explicit null to clear it so the tenant falls back to its parent and then the environment. See [Managed tenants](/guides/managed-tenants) for what these do. ## Three things they do that hand-rolled code gets wrong [#three-things-they-do-that-hand-rolled-code-gets-wrong] **A secret key in a browser throws.** The TypeScript client refuses to construct in a browser with a `b316_sk_` key — the one mistake that leaks an entire environment and stays invisible afterwards, because the code keeps working. **Webhook comparison is constant time, and the timestamp is checked.** A plain `==` leaks how much of a forged signature was right; a missing timestamp check lets one captured delivery be replayed for ever. **Retries are selective.** A 429 or a 5xx is retried with jittered backoff, honouring `Retry-After`. A 4xx is not — it will fail the same way every time. ## Errors [#errors] Every client raises a typed error carrying `code`, `status` and `requestId`. The request id is the one value support can find a specific call by, so log it.