@base316/sdk

中文文档
0.10.0发布于 2026/11/07MITTypeScriptESM + CJS零依赖近 30 天下载 2679

Official Base316 SDK: authentication, entitlements and usage metering.

原始使用文档
文档目录20 节
  1. Two clients, split by which key they hold
  2. Server
  3. May this account do that?
  4. Record what was used
  5. Customers, subscriptions and invoices
  6. Define what you sell
  7. Trials, and the reverse trial
  8. Users, tenants and roles
  9. Managed tenants: a firm over its clients
  10. Collections: your own data model
  11. Import and export
  12. What happens after they are paying
  13. Webhooks, registered from code
  14. Protecting your own API
  15. Quotes
  16. Reports, and a clock to test against
  17. Configuring an environment as code
  18. Browser
  19. Webhooks
  20. Errors

@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.detach and inheritedRoles manage the tree. access.check results carry permission.source and viaOrganizationId when 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.breakdown per client. An addon marked countsManagedTenants is kept at the firm's client count; with includedQuantity: 10 the 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_seats feature, per client; the firm's people count against seats.
  • organizations.setBranding and addDomain / verifyDomain put the firm's brand and sign-in domain on its clients' hosted pages; Base316Browser.billing.tenantBranding reads it with a publishable key.
  • Base316Browser.billing.subscription returns managedBy for a client, and self-serve checkout, plan change and cancel answer 403 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
  }
}

版本与发布记录

@base316/sdk 共发布 10 个版本,首次发布于 2026/09/13;过去 12 个月发布了 10 个版本。

版本发布日期距今
0.10.0latest2026/11/07今天
0.9.02026/10/299 天前
0.8.02026/10/2612 天前
0.7.12026/10/2217 天前
0.7.02026/10/1920 天前
0.6.02026/10/1424 天前

关于 @base316/sdk 的常见问题

@base316/sdk 是什么?

Official Base316 SDK: authentication, entitlements and usage metering.

如何安装 @base316/sdk?

在项目目录运行 npm install @base316/sdk,或使用 pnpm add / yarn add / bun add。

@base316/sdk 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@base316/sdk 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@base316/sdk 可以商用吗?

@base316/sdk 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@base316/sdk 的下载量有多少?

根据 npm 官方下载统计,@base316/sdk 在 2026/10/08 – 2026/11/06 这 30 天内被下载 2,679 次,日均约 89 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@base316/sdk 还在维护吗?

最新版本 0.10.0 发布于 2026/11/07(今天);至今共发布 10 个版本,其中过去 12 个月发布了 10 个;首次发布于 2026/09/13。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。