@base316/sdk
Authentication, entitlements and usage metering for Base316.
npm install @base316/sdk
Two clients, split by which key they hold
import { Base316, Base316Browser } from "@base316/sdk";
Base316 holds a secret key and belongs on your server. Base316Browser
holds a publishable key and is safe to ship to a browser.
The split is enforced rather than advised: constructing a Base316 in a browser
throws, because a leaked secret key is total compromise of an environment.
Server
import { Base316 } from "@base316/sdk";
const base316 = new Base316({ secretKey: process.env.BASE316_SECRET_KEY });
May this account do that?
const decision = await base316.access.check({
externalId: "acct_42", // your own id for the account
feature: "projects",
requested: 1, // what is about to be consumed
});
if (!decision.allowed) {
throw new Error(decision.reason);
}
requested is what makes this useful: the check refuses before the work
happens rather than reporting an overage after it.
Record what was used
await base316.events.record({
externalId: "acct_42",
name: "api.call",
value: 1,
idempotencyKey: crypto.randomUUID(),
});
Pass an idempotencyKey. A retry after a network timeout is otherwise a second
event, and a customer billed twice for one request is a support ticket you
cannot answer from the data.
Customers, subscriptions and invoices
// Look one up by your own id rather than paging the whole list.
const customer = await base316.customers.byExternalId("acct_42");
const subscriptions = await base316.subscriptions.list({ customerId: customer.id });
const invoices = await base316.invoices.list({ customerId: customer.id });
byExternalId returns null when you have not created the customer yet. That
is a normal state to handle, not an error.
Invoices, credit notes and quotes download as PDFs, returned as a Uint8Array:
import { writeFile } from "node:fs/promises";
await writeFile(`${invoice.number}.pdf`, await base316.invoices.pdf(invoice.id));
await base316.creditNotes.pdf(creditNoteId);
await base316.quotes.pdf(quoteId);
In the browser, billing.invoicePdf(session, id) returns the signed-in user's
own invoice the same way. Pass it to a Blob with the type application/pdf
to offer it as a download.
Define what you sell
const meter = await base316.catalog.meters.create({
key: "words",
name: "Words generated",
unit: "words",
eventName: "draft.generated",
aggregation: "SUM",
valueProperty: "words",
});
const feature = await base316.catalog.features.create({
key: "words",
name: "Words generated",
type: "METERED",
meterId: meter.id,
});
const plan = await base316.catalog.plans.create({
key: "pro",
name: "Pro",
currency: "USD",
interval: "MONTH",
prices: [
{ model: "FLAT", amount: "29.00" },
{ model: "PER_UNIT", featureId: feature.id, amount: "0.001" },
],
entitlements: [{ featureId: feature.id, limit: 10_000 }],
});
await base316.subscriptions.create({
customerId: customer.id,
planVersionId: plan.versions[0].id,
});
To charge a different unit price per value of an event field — per AI model,
per region — use a MATRIX price. Each row's dimensions name the same keys,
each one of the meter's groupBy fields, and the meter must aggregate with
SUM or COUNT. amount is the default row for usage that matches none;
leave it out and unmatched usage appears on the invoice as unpriced and is
charged nothing.
{
model: "MATRIX",
featureId: tokens.id,
amount: "0.00001",
rows: [
{ dimensions: { model: "opus" }, unitAmount: "0.000015" },
{ dimensions: { model: "haiku" }, unitAmount: "0.000001" },
],
}
Invoice and upcoming-invoice lines from a matrix carry the row's dimensions
({} for the default row), and customers.entitlements() splits the period's
usage per row in byDimension.
A plan carries the identity; a version carries the terms, and that is what
a subscription points at. Publishing a new version with
catalog.plans.createVersion therefore never reprices anyone already on the
old one.
If you know only the key — the usual case in application code — ask for the version directly:
const version = await base316.catalog.plans.currentVersion("pro");
null means the plan has nothing published. A draft cannot be subscribed to.
Trials, and the reverse trial
A version's trialDays opens new subscriptions as TRIALING; its
trialEndAction decides what the trial becomes. CONVERT (the default) bills
the plan. DOWNGRADE moves a payer with no card on file to
trialDowngradePlanVersionId instead — ACTIVE, same subscription id, no
invoice — and converts one with a card:
const free = await base316.catalog.plans.currentVersion("free");
const team = 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" }],
});
// A no-card trial without checkout; `trialDays` overrides the version's (0 = none).
await base316.subscriptions.create({
externalId: "acct_42",
planVersionId: team.versions[0].id,
trialDays: 14,
});
The downgrade arrives as subscription.updated with change: "trial_downgraded".
PublicPlan carries both fields, so a pricing page can say what follows the
trial.
Users, tenants and roles
Your application's own end users — not the people who sign in to the Base316 dashboard.
const admin = await base316.roles.create({
key: "admin",
name: "Administrator",
// Your strings. Base316 matches them without interpreting them, and a
// trailing `.*` grants everything under that prefix.
permissions: ["billing.*", "reports.export"],
});
const tenant = await base316.organizations.create({ name: "Petronella Ltd", externalId: "t_42" });
const user = await base316.users.create({ externalId: "u_7", email: "[email protected]" });
await base316.organizations.addMember(tenant.id, { userId: user.id, roleId: admin.id });
This is the migration path: people and tenants exist here before anyone signs
in, so the first person to log in joins their own history rather than starting a
fresh account beside it. emailVerified: true carries over a confirmation they
already gave your old system.
Then link the tenant to the account that pays for it, or every permission check
answers NOT_A_MEMBER while the billing looks perfectly healthy:
await base316.customers.create({ externalId: "acct_42", runtimeOrganizationId: tenant.id });
await base316.access.check({ customerId, userId: user.id, permission: "billing.refund" });
users.disable locks an account and keeps the record — an employee who left, a
suspected compromise, an account in dispute. users.erase is the other thing,
and it returns a receipt saying what was kept and why.
Managed tenants: a firm over its clients
A tenant can run others — an accounting firm over its client companies, an agency over its brands. One level deep.
const firm = await base316.organizations.create({ name: "Hartley & Co" });
const client = await base316.organizations.create({ name: "Acme Ltd", parentId: firm.id });
// The firm's "accountant" acts as "editor" in every client. Unmapped roles grant nothing.
await base316.organizations.setInheritedRoles(firm.id, [
{ parentRoleId: accountant.id, childRoleId: editor.id },
]);
// The client's account bills to the firm's; this is what each client used this period.
const { data } = await base316.customers.billed(firmCustomer.id);
organizations.children,organizations.detachandinheritedRolesmanage the tree.access.checkresults carrypermission.sourceandviaOrganizationIdwhen access came through the firm.- A client's entitlements, credits and usage are the firm's plan's; its events
bill the firm, with
InvoiceLine.breakdownper client. An addon markedcountsManagedTenantsis kept at the firm's client count; withincludedQuantity: 10the first ten clients are free and each one after is billed at the addon's amount. - A client's own members count against the firm's plan's
client_seatsfeature, per client; the firm's people count againstseats. organizations.setBrandingandaddDomain/verifyDomainput the firm's brand and sign-in domain on its clients' hosted pages;Base316Browser.billing.tenantBrandingreads it with a publishable key.Base316Browser.billing.subscriptionreturnsmanagedByfor a client, and self-serve checkout, plan change and cancel answer403 billed_by_parent.
Collections: your own data model
Defining a collection is a server operation; reading and writing its records is a browser one. That split is why these live on different clients.
await base316.collections.create({
key: "tasks",
name: "Task",
fields: [
{ key: "title", label: "Title", type: "TEXT", required: true },
{ key: "status", label: "Status", type: "SELECT", options: ["open", "done"] },
{ key: "due", label: "Due", type: "DATE" },
],
});
A collection is created with tasks.read and tasks.write permissions already
in place, so a <DataTable collection="tasks" /> dropped onto a page enforces a
rule that exists rather than one somebody has to remember to add.
A field can be relabeled, reordered and made required or not. Its type cannot
change: every value already stored was validated against the old one.
Import and export
// Find out what a file will do before doing it.
const preview = await base316.transfer.importCsv("customers", { csv, dryRun: true });
console.log(preview.mapping.providerName); // "Chargebee", detected from the header
console.log(preview.mapping.unmapped); // columns nothing read
console.log(preview.failed); // rows that will not land
const summary = await base316.transfer.importCsv("customers", { csv });
Bad rows fail individually rather than rejecting the file, so one malformed line
in five thousand costs you one line. Omit provider and it is detected from the
header; the resolved mapping comes back either way, so you can check what a
detection decided before trusting it.
transfer.exportCsv("customers") returns CSV text, not JSON — write it to a
file and open it.
What happens after they are paying
// A coupon on every invoice this subscription raises.
await base316.subscriptions.applyDiscount(subscriptionId, { code: "half-off" });
// A seat added mid-month.
await base316.addons.attach({ subscriptionId, addonId, quantity: 3 });
// A callout, billed on the next invoice.
await base316.pendingCharges.create({
customerId,
description: "Emergency callout",
currency: "USD",
amount: "250.00",
});
// A check that bounced. The invoice reopens.
await base316.invoices.reversePayment(invoiceId, paymentId, { reason: "bounced" });
A customer whose card expired, or whose auto-recharge needs one, replaces it on Stripe's hosted page, from the billing portal's payment methods section or the credits page. Your server can see and tidy the cards on file:
// A single-use link straight to the portal's payment methods section.
const { url } = await base316.billingPortal.createSession(customerId, {
page: "payment-methods",
});
const { data: cards } = await base316.paymentMethods.list(customerId);
await base316.paymentMethods.setDefault(customerId, cards[1]!.id);
Two rules worth knowing before you build against these:
A wallet top-up needs an idempotencyKey. A retry after a network timeout
is otherwise a second credit, and an account credited twice for one payment is a
support ticket nobody can answer from the data. duplicate: true on the result
is how you know a retry was recognized.
An issued invoice is never rewritten. It is a document the customer may have
paid and a tax authority may have seen. To reduce one, raise a credit note —
creditNotes.create — which is the opposite entry rather than an edit. void
is only for an invoice that should never have existed.
Webhooks, registered from code
const endpoint = await base316.webhooks.create({
url: "https://yourapp.com/hooks/base316",
eventTypes: ["invoice.paid", "subscription.canceled"],
});
// Store `endpoint.secret` now. Only a hash is kept here.
Then verify a delivery in your handler:
const event = await constructWebhookEvent({
payload: rawBody, // the exact bytes, not a re-serialized object
signature: request.headers.get("base316-signature"),
secret: process.env.BASE316_WEBHOOK_SECRET,
});
When an integration goes quiet, webhooks.deliveries() says what was sent and
what the far end answered. webhooks.events() is the other question — what
happened, whether or not anyone was listening — and is how you forward events to
a machine the delivery worker cannot reach, by polling with the cursor from
the previous page.
Protecting your own API
const api = await base316.apiResources.create({
name: "Your API",
audience: "https://api.yourapp.com",
});
await base316.apiResources.addScope(api.id, {
key: "invoices:read",
// Gated on a plan feature: a customer without it gets a token without the
// scope, rather than a token that works and a refusal deeper in.
featureKey: "invoice_api",
});
const credential = await base316.apiClients.create({
customerId,
name: "Nightly sync",
audience: "https://api.yourapp.com",
scopes: ["invoices:read"],
});
rateLimits.set takes the first match — the customer's own policy, then their
plan's, then the resource default — so one account can be lifted above its plan
without inventing a plan for them.
Quotes
const quote = await base316.quotes.create({ customerId, planVersionId });
await base316.quotes.send(quote.id);
const accepted = await base316.quotes.accept(quote.id);
// accepted.subscriptionId — acceptance is what creates it
Nothing bills while a quote is open, and the terms are frozen in the meantime.
A quote with lines instead of a planVersionId is a quote for work, and
accepting one produces a draft invoice rather than a subscription.
Read status, not storedStatus: the first applies expiry, so a quote whose
date has passed reads as EXPIRED whether or not a sweep has noticed.
Reports, and a clock to test against
const { byCurrency } = await base316.reports.mrr();
const aging = await base316.reports.ageing({ worstCustomerLimit: 10 });
MRR is not converted to a single currency — that needs an exchange rate and a
date, and inventing either makes the number wrong in a way nobody can see. Pass
at to reproduce a figure as of a past date.
In a sandbox, you can move time:
await base316.sandbox.seed();
await base316.testClocks.create({ name: "Renewal rehearsal" });
const { effects } = await base316.testClocks.advance("2027-01-01T00:00:00Z");
// effects.subscriptionsRenewed — assert on this instead of polling
This is what makes "prove this renews before shipping it" a call rather than a month's wait. Both are refused on production, where advancing time would renew every subscription and charge real cards.
Configuring an environment as code
authConfig, email and paymentAccounts cover the setup that otherwise only
happens in a dashboard — the difference between an environment that is
reproducible and one that is a record of what somebody clicked. Each save is an
upsert, so a deployment that runs twice converges rather than failing.
auditLogs.list is read-only by construction. There is no write method and
there will not be one: a log an API can edit is not an audit log.
Browser
import { Base316Browser } from "@base316/sdk";
const base316 = new Base316Browser({ publishableKey: "b316_pk_live_..." });
const result = await base316.auth.signIn({ email, password });
Webhooks
Verify before you trust. An unverified webhook body is an unauthenticated request from anyone who learned your endpoint.
import { constructWebhookEvent } from "@base316/sdk";
const event = constructWebhookEvent({
payload: rawBody, // the raw bytes, not the parsed JSON
signature: request.headers.get("base316-signature"),
secret: process.env.BASE316_WEBHOOK_SECRET,
});
Parsing the body first and re-serializing it changes the bytes, and the signature will not match.
Errors
Every failure is a Base316Error with a stable code, so you can branch on the
cause rather than on a message that may be reworded.
import { Base316Error } from "@base316/sdk";
try {
await base316.events.record({ ... });
} catch (error) {
if (error instanceof Base316Error && error.code === "rate_limited") {
// back off
}
}