Drop-in auth + billing for any Stripe + WorkOS app, usable by humans and AI agents,
exposed as MCP tools, a REST API, and a CLI, over one storage-agnostic engine.
Billing Tools packages the "get money in" plumbing you'd otherwise rebuild in every SaaS: API-key auth on top of WorkOS, credit + subscription billing on top of Stripe, and (because the next wave of customers is autonomous) first-class agent rails: auth.md self-registration and MPP machine payments. Wire it once; serve humans through a browser and agents through headless HTTP with the same engine. Storage is pluggable behind one small adapter (use the built-in WorkOS store, or mirror into your own Postgres).
- Ship tools, not billing plumbing
- Key Features
- Getting Started
- From sandbox to production
- Pricing examples
- How it works
- Agent auth (auth.md)
- Machine payments (MPP)
- MCP OAuth proxy (dynamic clients)
- CLI
- Configuration
- Roadmap
- Contributing
- License
The next generation of software is agents, and an agent is only as useful as the tools it can call. Tools are the product now.
But you don't go to war naked. Ship a tool into the wild with no auth and no way to get paid, and you're just donating GPU time to strangers. Someone still has to meter every call, hold a balance, bill the workspace, enforce seats, and cut off the freeloaders: the boring, identical, get-it-wrong-once-and-bleed-money layer that every AI product rebuilds from scratch.
Billing Tools is the armor. Bring a Stripe + WorkOS stack and you inherit, out of the box, the exact monetization model the frontier labs run on. The same shape as Anthropic, OpenAI, and xAI (Grok):
- 💰 A price per credit: every tool call costs credits, metered and deducted automatically.
- 🏢 Workspaces: the billable account, with one Stripe customer + balance each.
- 👥 Users per workspace: seats, invitations, and roles.
- 🪙 Credits per user: per-seat grants, seat limits, and auto-reload.
So you do the one thing only you can do (build great tools) and monetize them the same afternoon. The get-paid layer is already handled, battle-tested, and agent-ready. 🛡️
- 🔑 API-key auth: WorkOS organization API keys (
Authorization: Bearer sk_…), validated on every request. - ✉️ Passwordless magic-auth: email a 6-digit code, get back a workspace/org + API key. No passwords.
- 🤖 auth.md agent self-registration: the WorkOS auth.md protocol (RFC 9728 Protected-Resource-Metadata + RFC 8414 Authorization-Server-Metadata +
/agent/identityanonymous or verified-email + the claim ceremony), so agents onboard with no human signup. - ♻️ RFC 7009 revocation: revoke a key by value; always-200, no token-existence leak.
- 🎫 Optional OAuth-JWT hook: bring your own MCP OAuth proxy via a single adapter method.
- 🪙 Usage-metered credit wallet: held in the native Stripe customer credit balance (1 credit = 1¢), with idempotent credit/debit.
- 🛒 Checkout top-ups: Stripe Checkout that auto-offers cards + Apple Pay / Google Pay / Link.
- 📦 Declarative subscription plans: describe plans in code; products/prices are auto-provisioned in Stripe via
lookup_key(immutable-price safe, orphan-cleaning). Zero dashboard clicks. - 🎟️ Per-seat / per-cycle credit grants + seat limits: included credits scale with active members.
- 🔁 Auto-reload: off-session saved-card recharge when the balance drops below a threshold.
- 🧾 Invoices + 🏛️ Customer Portal: list invoices, and a one-call Stripe Billing Portal URL for self-serve upgrade/downgrade/cancel + card updates.
- 🔒 Idempotency on every money move: welcome bonus, checkout credit, and per-cycle grants each carry a stable key, so retries/replays never double-charge.
- 📄 auth.md: the agent onboarding standard, served for you (
/auth.mdnarrative + discovery metadata). - 🏧 MPP machine payments: Stripe's Machine Payments Protocol: a HTTP 402 +
WWW-Authenticate: Paymentchallenge for pay-per-request (SPT card or crypto/USDC), the payment sibling of auth.md's 401. - 🧰 MCP server tools:
get_api_key,get_credit_balance,buy_credits,list_plans,get_billing_portal, and more, drop straight into Claude, Cursor, or any MCP client. - 🧭 Discovery hints: every 401/402 advertises
resource_metadata, so an agent can bootstrap unattended.
- 🛠️ MCP:
createMcpTransport({ register, adapter }). - 🌐 REST:
createToolListHandler+createToolDispatchHandler(429/401/400/404 mapping built in). - ⌨️ CLI:
registerBillingCommands(program, …)gives youauth,keys,balance,buy,invoices. - 🪝 Stripe webhook:
createStripeWebhookHandler()for instant checkout crediting.
- 🇪🇺 VAT/GST from code:
sales-tax+ VIES work the rate out and it's applied as an explicit Stripe TaxRate — no per-transaction fee, no Dashboard. - 1️⃣ One line, every charge:
tax: { origin: "IT" }in your config and the seat checkout, the top-up and the auto-reload invoice all carry the right rate. Reverse charge for cross-border EU B2B included. - 🔁 Live re-tax:
updateCheckoutSessionTaxRatesrecalculates an open session when the customer types a different country. - ☑️ Stripe Tax is opt-in:
tax: { mode: "stripe" }when you want it. Nothing infers it — with no active registration it computes 0% silently.
- 🏠 Local payments, no tunnel:
npx billing-tools devdownloads the Stripe CLI if needed, forwards webhooks to localhost (nostripe login), and writes the session's signing secret into.env.local. - 🩺
npx billing-tools doctor: audits the environment yourSTRIPE_SECRET_KEYpoints at, exit-code and all, for the misconfigurations that produce no error. - 🚀
setupBilling({ config, plans, webhookUrl }): provisions the few things that can't be lazy (webhook endpoint + its once-only secret, tax registrations) and then runs the doctor. Idempotent — safe on every deploy.
- 📡 Event polling: reconcile Stripe and WorkOS via their Events APIs. No webhook endpoints, no signing secrets.
- ⏱️ Any scheduler: run it in-process with
sync.start()(persistent hosts) or as a serverless cron viacreateSyncRoute(). - 🪞 Generic DB mirror: shadow WorkOS orgs/users into your own tables (
createMirror), driver-agnostic. - 📉 Dunning hook:
invoice.payment_failedflips the org topast_dueand fires anonPaymentFailedhook (Stripe Smart Retries do the retries).
- ⚡ Web-standard handlers: everything returns
RequesttoResponse; mount in Next.js, Hono, Bun, Deno, or Cloudflare Workers. - 🧱 No framework imports: runtime deps are just
stripe+@workos-inc/node. - 🗄️ No database, at all: usage — including per-member included allowances — is counted in Stripe. No
pg, no Redis, no table to migrate. - 🔌 Pluggable storage adapter: WorkOS-only, or WorkOS + your DB mirror, behind one interface.
- ☁️ No lock-in: runs on any Node host (Railway / Render / Fly / Vercel / self-host).
Counting usage in Stripe means the limit is Stripe's rate limits, not a table's
size. Live, Stripe allows 100 req/s globally and 25 req/s per endpoint — and
every usage window is read through the same endpoint (listEventSummaries), so
that endpoint is what binds.
Measured against a real account (scripts/load-metering.mjs, which counts the HTTP
requests the SDK actually sends), per metered call on a per-seat plan with a
caller-scoped limit:
| caller | uncached | with cachedUsageLedger |
|---|---|---|
| member | 3.95 requests | 1.95 |
| API key | 1.30 | 1.00 |
Which puts sustained metering in the region of 15–20 calls/second for the whole account, all customers combined — idle users cost nothing, since the limits are per account rather than per customer. Comfortable for most SaaS; a ceiling you would hit building high-volume metering, and worth knowing before you adopt this rather than after.
Three things keep it there, and all are on by default except the first:
cachedUsageLedger(ledger, { ttlMs }) (opt-in — a cached window is a stale window,
and the gate reads through it), a plan's windows over one caller answered in a
single bucketed read, and UsageQuery.sources skipping the leg that cannot
contribute. If you outgrow it, the levers are asking Stripe to raise the account
limit, or bringing your own ledger — the seam is unchanged.
Worth knowing before you adopt it, rather than after.
- No per-action audit trail. Usage is counted, not logged: a total can say a
member spent 412 credits this cycle, never which calls made it up. That is the
price of having no database. If you owe customers an itemised breakdown —
enterprise contracts often do — bring your own
ledger; the seam is unchanged and takes a store alongside. - Counting degrades, it does not stop the product. A read that cannot be
answered serves the last known value (or 0), and a write that cannot be delivered
is retried and then dropped. Both report through
onUsageFault— wire it, or your only signal is one line on stderr. - The wallet read fails closed. If the customer's balance cannot be read the metered call is refused, because a stale balance would let someone spend what they do not have. Usage windows degrade; money does not.
- Versioning is semantic and moves fast. Releases are automated from Conventional Commits, so anything that changes a signature or a default lands as a major — several have. Pin a major range and read the changelog; the majors are honest, not cosmetic.
- 📐 SDK-first: thin-wraps the Stripe & WorkOS SDKs (SDK types, pagination, typed errors, idempotency) so the lib evolves with the platforms instead of drifting.
- 🎯 One memoized client per SDK: lazy, never constructed at import.
- 🔒 Secure by default: AES-256-GCM session encryption at rest, SHA-256-hashed claim tokens, verified-domain-only internal-org checks.
- 🧪 Offline-testable: the auth.md + MPP protocol surfaces are pure
RequesttoResponse, unit-testable without a live account.
Five steps to a workspace that can be billed, and the last two are one command each.
- Node 18+
- A Stripe secret key — test mode is fine, and it decides which environment everything below reads and writes
- A WorkOS API key + client id
npm install @arnaudjnn/billing-tools
# peers (provide the ones you use): @modelcontextprotocol/sdk mcp-handler zodShips compiled dist/, so there's no build step or transpilePackages needed in the consumer.
The catalogue is the input everything else is derived from: Stripe products and prices, which tools get registered, what each plan includes, and every pricing surface. Nothing here is a Dashboard click.
// plans.ts
import { definePlans } from "@arnaudjnn/billing-tools/plans";
export const PLANS = definePlans({
hobby: {
sells: { kind: "flat", price: { monthly: 1000, yearly: 10000 } }, // cents
grant: { kind: "none" }, // included allowance is COUNTED, not credited
cap: { kind: "pool", credits: 1_000, onExhausted: "wallet" },
replenish: { purchase: { packs: [500, 2_000] }, autoReload: { threshold: 200, reloadTo: 2_000 } },
sale: "self_serve", // required, never inferred
},
});grant vs cap is the one distinction worth reading twice: a Stripe credit balance auto-applies to the next invoice, so crediting a plan's own included allowance discounts its own renewal. Include it as a cap; credit only what a customer buys. checkPlansConfig fails a plan that does both.
Four to set. The library reads no others.
STRIPE_SECRET_KEY=sk_test_…
WORKOS_API_KEY=sk_…
WORKOS_CLIENT_ID=client_…
REFRESH_TOKEN_SECRET=… # only if you mount the MCP OAuth proxy (`openssl rand -hex 32`)Plus two you don't type by hand, and one optional:
STRIPE_WEBHOOK_SECRET=whsec_… # written by `npx billing-tools dev` locally; printed once by `billing setup` in a deployed env
BILLING_WEBHOOK_URL=https://… # where THIS environment's endpoint lives, read by the doctor
INTERNAL_ORG_DOMAINS=acme.com # optional: orgs with these verified domains are unmeteredBoth keys must name the same environment. A live Stripe key beside a staging WorkOS key charges real cards against orgs and sk_ keys in the wrong environment, and writes the mapping between them where nobody is looking. The doctor errors on it — when the WorkOS key states its environment (older sk_test_/sk_live_ keys do; newer sk_<key id> ones carry no marker, so it stays silent rather than guessing).
createBilling() composes every surface from a single config — one module instance, so runWithAuth in the routes and enforceAccess inside the tools share one AsyncLocalStorage.
// billing.ts
import { createBilling } from "@arnaudjnn/billing-tools";
import { WorkOSOrgAdapter } from "@arnaudjnn/billing-tools/adapters/workos-org";
import { PLANS } from "./plans";
const billing = createBilling({
adapter: new WorkOSOrgAdapter(), // WorkOS is the source of truth
config: { currency: "usd", baseUrl: process.env.APP_URL!, tax: { origin: "US", mode: "stripe" } },
plans: PLANS,
registerTools: (server) => registerMyProductTools(server), // your own tools, alongside the billing ones
agentAuth: { branding: { productName: "Acme" } }, // enables auth.md (optional)
mcp: { requireAuth: true }, // gate the MCP handshake, not just the tool calls
});
export const { mcp, restList, restDispatch, webhook, agentAuth, meter, api } = billing;
export const BILLING_CLI = billing.cli; // for step 5Prefer fine-grained control? Every factory is exported individually (registerBillingTools, createMcpTransport, createToolListHandler, createToolDispatchHandler, createStripeWebhookHandler, createAgentAuth, …) so you can build your own composition root — but note that the composition is where five decisions live (webhook idempotency, subscription mirroring, the 402/401/429 mapping, handshake gating, which ledger counts), so hand-wiring means re-deciding each one.
Every route file is a re-export.
// app/[transport]/route.ts → export const { GET, POST } = mcp
// app/api/v0/route.ts → export const GET = restList
// app/api/v0/[tool]/route.ts → export const POST = restDispatch
// app/api/stripe/webhook/route.ts → export const POST = webhook // raw body: keep it out of session middlewarePass onOtherEvent: createStripeEventHandler({ adapter, plans }) to webhook unless you run the event poller. Without one of the two, nothing mirrors customer.subscription.* onto the org, so resolvePlan reads null for ever and no subscriber is given the allowance they paid for.
plans and config are TypeScript values, so this is a script the app owns rather than a bin subcommand — but it holds no facts of its own.
// scripts/billing.ts
import { runBillingCli } from "@arnaudjnn/billing-tools";
import { BILLING_CLI } from "../billing";
runBillingCli(BILLING_CLI); // call it; it exits the process itself{ "scripts": { "billing": "tsx --env-file-if-exists=.env.local scripts/billing.ts" } }pnpm billing |
audit, read-only. The default, because the default must be the verb that cannot change anything |
pnpm billing setup |
provision, then audit. Idempotent, safe on every deploy |
--no-webhook |
there is no endpoint here, by design (correct on a laptop) |
--url <url> / --prune |
check a different endpoint / delete duplicates on the same URL |
billing.cli carries the catalogue, the config, the wired ledger's coverage, whether a checkout is mounted and whether the OAuth proxy is — read off the composition, not restated. A script that declares its own ledger coverage can be right while the app is wrong, which is how a wallet-only ledger once counted pooled usage as 0 and gave every subscriber unlimited requests with every check passing.
No tunnel, no stripe login, no registered endpoint:
npx billing-tools dev # fetches the Stripe CLI if needed, forwards to localhost,
# and writes the session's whsec_ into .env.localThe dotenv write is the point: stripe listen mints a new secret per session and your dev server is a different process, so a file is the only channel both see.
curl https://your-app.com/api/v0 # tools + costs
curl -X POST https://your-app.com/api/v0/get_credit_balance \
-H "Authorization: Bearer sk_…"Add agent onboarding with createAgentAuth and pay-per-call with createMachinePaymentHandler.
Nothing is copied between environments. The same catalogue evaluated against a different key produces equivalent objects — which is why a key swap is almost the whole story, and why anything you clicked together by hand in a Dashboard is not.
That is the test: did code create it, or did you click it?
| On what trigger | |
|---|---|
| Stripe products + prices | first checkout, or billing setup |
| Usage meter | first metered call |
| Payment-method configuration (card + Apple Pay + Google Pay) | first payment form |
| Stripe TaxRate objects | first taxed charge |
| Stripe customers | first billed request per org |
WorkOS orgs, memberships, sk_ API keys |
per customer, on demand |
WorkOS roles you declared in workos: { roles } |
billing setup |
| Why it cannot be automatic | |
|---|---|
STRIPE_WEBHOOK_SECRET |
Stripe returns it once, at creation. No request can put it in your env store, so you cannot set it in advance — billing setup creates the endpoint and prints the secret |
| AuthKit redirect URI | WorkOS exposes no API for it in v10 (its only writable redirect_uris belong to a Connect application, a different object). pnpm billing prints the exact string to allowlist |
| AuthKit appearance/settings | same: Dashboard only |
| Stripe Tax registrations | only a human knows where the business collects. Skipped unless config.tax.mode is "stripe" |
admin and member ship with a WorkOS environment, so there is nothing to do there — ensureWorkOSRoles has no default list and creates only roles you name.
# 1. set the four env vars (live Stripe key + prod WorkOS key)
pnpm billing setup # → prints STRIPE_WEBHOOK_SECRET=… ONCE
# 2. paste it into Vercel/Railway, redeploy
# 3. allowlist the redirect URI the report printed
pnpm billing # exits non-zero: gate the pipeline on itRun setup by hand, not from a build step — the signing secret would print into a build log nobody reads, and Stripe never shows it again. Because everything else provisions lazily, a broken config otherwise surfaces on a customer's first request; pnpm billing in the pipeline is what moves that into the deploy.
Five independent axes, so a catalogue describes a product rather than picking from a
menu. Only sells is a union — it alone decides which fields are required and what
Stripe objects get minted.
| axis | values | decides |
|---|---|---|
sells |
nothing | seats | flat |
what Stripe charges for |
grant |
none | purchased_seats | per_member | fixed |
what is CREDITED as money on invoice.paid |
cap |
wallet | per_seat | pool |
what is INCLUDED, as a counted window |
replenish |
{purchase?, autoReload?, request?} |
how to get more. purchase.min/max bound ONE purchase (default 5 / 200 000 currency units); request.percent / maxPercent / presets / step bound and shape one grant (default 25 / 500 / [25,50,100] / 25). Read them with purchaseBounds(model) / requestBounds(model) so a buy form and the tool that refuses it cannot disagree |
sale |
free | self_serve | quote | legacy |
whether it can be bought. Required, never inferred |
Every example below is typechecked against the published types in CI.
The shape for a product sold by volume rather than by seat — an API, an agent platform. One pool for the whole workspace; overage draws the prepaid wallet so a long run never stops halfway.
The arithmetic that makes a tier worth buying: top-ups are fixed at 1 credit per
cent ($1 = 100 credits), so included credits have to cost less than that, and
less at each step, or a customer who does the division has no reason to subscribe —
let alone upgrade.
export const PLANS = definePlans({
starter: {
sells: { kind: "flat", price: { monthly: 3_000, yearly: 30_000 } }, // $30 → 0.75¢/credit, 25% off
grant: { kind: "none" },
cap: { kind: "pool", credits: 4_000, onExhausted: "wallet" },
replenish: { purchase: { packs: [1_000, 5_000] } },
sale: "self_serve",
},
pro: {
sells: { kind: "flat", price: { monthly: 9_000, yearly: 90_000 } }, // $90 → 0.60¢/credit, 40% off
grant: { kind: "none" },
cap: { kind: "pool", credits: 15_000, onExhausted: "wallet" },
replenish: {
purchase: { packs: [5_000, 20_000] },
autoReload: { threshold: 2_000, reloadTo: 15_000, enabledByDefault: true },
},
sale: "self_serve",
display: { name: "Pro", featured: true, badge: "Most popular" },
},
});No limits.members means unlimited — a seat was never what this product sells, and a
ceiling on people only pushes a team onto one shared key.
Each member draws their own included pack, and a member who runs out can ask the owner
for more instead of being blocked. cap: per_seat is the only shape that needs a
per-member counter, so it also needs a ledger that can count one — see
Scale.
export const PLANS = definePlans({
team: {
sells: {
kind: "seats",
seatTypes: {
standard: { price: { monthly: 2_000, yearly: 20_000 }, credits: 1_000 },
premium: { price: { monthly: 9_000, yearly: 90_000 }, credits: 5_000 },
},
},
grant: { kind: "none" },
cap: { kind: "per_seat", onExhausted: "block" }, // a committed pack's overage is a renegotiation
replenish: { request: {} }, // request_top_up → approve_top_up
sale: "self_serve",
},
});onExhausted: "block" refuses even when the wallet could pay. "wallet" falls through
instead, so a top-up funds the overage.
No plan to be on: a wallet, and credits bought as needed. cap: wallet includes
nothing, so every call is funded by the balance.
export const PLANS = definePlans({
payg: {
sells: { kind: "nothing" },
grant: { kind: "none" },
cap: { kind: "wallet" },
replenish: { purchase: { packs: [1_000, 5_000, 20_000] }, autoReload: { threshold: 500, reloadTo: 5_000 } },
sale: "free",
},
});checkPlansConfig warns if you advertise cap: wallet without any replenish — a
plan that promises pay-as-you-go and cannot take the money.
sale is what makes a plan buyable, and quote keeps Enterprise off the self-serve
path — an agent holding a workspace key cannot subscribe an org to it at its
placeholder amount. limits.rate is a sixth axis and NOT the same as cap: a cap is
the commercial ceiling over the billing cycle, a rate limit is the pace, and a month's
allowance spent in one afternoon sits inside the cap.
export const PLANS = definePlans({
free: {
sells: { kind: "nothing" },
grant: { kind: "none" },
cap: { kind: "pool", credits: 200 },
limits: { rate: [{ every: "day", credits: 50 }] },
sale: "free",
},
growth: {
sells: { kind: "flat", price: { monthly: 4_900, yearly: 49_000 } },
grant: { kind: "none" },
cap: { kind: "pool", credits: 10_000, onExhausted: "wallet" },
replenish: { purchase: {} },
limits: { rate: [{ every: "hour", credits: 600, callerKind: "api" }] },
sale: "self_serve",
},
enterprise: {
sells: { kind: "flat", price: { monthly: 100_000, yearly: 1_000_000 } },
grant: { kind: "none" },
cap: { kind: "pool", credits: 250_000, onExhausted: "wallet" },
sale: "quote", // listed, not buyable
},
});Rate limits fund nothing and never fall through to the wallet — a limit a top-up could
lift is not a limit. The refusal is its own reason (rate_limit_reached) and carries
retryAt, because it is the one refusal that fixes itself.
The catalogue is not just prices. The tool surface and the CLI surface are derived from it, so a shape that cannot happen is not advertised:
| flat + pool | seats + per_seat + request |
|
|---|---|---|
| tools registered | 26 | 33 (adds seats + top-ups) |
| CLI commands | no seats / topup |
all groups |
That is the point of deriving rather than listing: an agent cannot tell a tool that
always fails from one it is holding wrong, and a customer cannot tell a dead command
from a mistake. Pass plans to registerBillingCommands to gate the CLI the same way.
change_plan is the single entry point for up, down and off. On a first purchase there
is no subscription to prorate, so it opens a Checkout Session — hosted for the tool
(a URL any caller can open), elements for your own UI (a client secret you mount).
Same session either way: same tax, same payment-method configuration.
await changePlan(adapter, orgId, { plans: PLANS, to: { plan: "pro" }, config, uiMode: "hosted" });Quote it first with previewPlanChange(adapter, orgId, { plans, to, proration }) — it
shares desiredPrices and diffItems with changePlan, so the quoted number is the
charged number. Pass it the same proration you will pass to changePlan, or you are
quoting a different policy from the one you apply. (It takes no config: it only reads,
so it never needs to create a customer.)
WorkOS is always the source of truth. Your app's orgId is opaque: implement the adapter and everything (auth, metering, Stripe math, all surfaces) works unchanged. Two shipped patterns:
| Pattern | What orgId maps to |
Storage | Use when |
|---|---|---|---|
| A: WorkOS-only | the WorkOS org id | none beyond WorkOS | you don't have (or need) your own DB rows |
| B: WorkOS + DB mirror | your own id (e.g. ws_…) |
a workos_org_id column + org.externalId, 1:1 |
you keep local rows and mirror WorkOS into them |
In both, API keys are WorkOS org keys and the Stripe pointer lives on the org. The WorkOSOrgAdapter (@arnaudjnn/billing-tools/adapters/workos-org) implements both; pass a map for Pattern B.
Implements the WorkOS auth.md spec.
import { createAgentAuth } from "@arnaudjnn/billing-tools";
export const agentAuth = createAgentAuth({
adapter, config,
branding: { productName: "Acme", logoUri: "https://acme.com/logo.svg" },
identityTypes: ["anonymous", "verified_email"],
});
// mount: /auth.md, /.well-known/oauth-{protected-resource,authorization-server},
// /agent/identity, /agent/identity/claim, /oauth/token, /oauth/revokeAn agent hits a 401, follows the resource_metadata hint to your metadata, reads /auth.md, then registers (either anonymously for an instant key, or via a verified-email claim ceremony where the user reads back a code) and starts calling tools. All handlers are Request to Response.
Implements Stripe's Machine Payments Protocol.
import { createMachinePaymentHandler } from "@arnaudjnn/billing-tools";
const pay = createMachinePaymentHandler({ methods: ["stripe"], amount: 50, currency: "usd" });
const gate = await pay.requirePayment(request);
if (gate instanceof Response) return gate; // 402 + WWW-Authenticate: Payment challenge
// else settlement succeeded, serve the paid resourceThe 402 challenge + credential parsing ship ready and are offline-testable. Actual settlement (card via shared payment tokens, or crypto/USDC) is injected via a settle function once your Stripe account is enabled for machine payments; until then the gate returns a clean "settlement not enabled" 402 (never a crash). createPaymentMd() serves an agent-facing /payment.md.
MCP clients like Claude Desktop and Claude.ai can't be handed an API key — they
register themselves (RFC 7591) and expect an OAuth 2.1 authorization-code flow.
oauthProxy gives you that on top of WorkOS AuthKit, in one option:
const billing = createBilling({
adapter, config,
agentAuth: { branding: { productName: "Acme" } },
oauthProxy: true, // needs REFRESH_TOKEN_SECRET
});
// app/oauth/authorize/route.ts export const GET = billing.oauth!.authorize
// app/oauth/register/route.ts export const POST = billing.oauth!.register
// app/oauth/callback/route.ts export const GET = billing.oauth!.callback
// app/oauth/token/route.ts export const POST = billing.oauth!.tokenThe user authenticates with AuthKit; the client receives the WorkOS access token
plus an HS256 refresh token that wraps the WorkOS one and is bound to the
client_id that obtained it. PKCE (S256) is enforced whenever the client sends a
challenge, authorization codes are single-use, and the /oauth/token route also
serves the auth.md claim grant so one route covers both flows.
Enabling it also makes discovery honest: authorization_endpoint and
registration_endpoint appear in /.well-known/oauth-authorization-server only
when the proxy is on, and authorization_code/refresh_token join
grant_types_supported. Consumers without the proxy no longer advertise an
/oauth/authorize they don't implement.
REFRESH_TOKEN_SECRET is required and has no fallback. Falling back to
WORKOS_CLIENT_ID — a public identifier — would let anyone who knows it forge a
30-day refresh token. Without the secret the token endpoint returns
server_error rather than signing with something guessable.
import { registerBillingCommands } from "@arnaudjnn/billing-tools";
import { Command } from "commander";
const program = new Command();
registerBillingCommands(program, {
configDir: "~/.acme",
envPrefix: "ACME",
defaultUrl: "https://acme.com",
// Gates the commands by the catalogue, exactly as the TOOLS are gated: a flat/pooled
// plan ships no `seats` or `topup` commands, which would otherwise call tools that
// were never registered and could only answer "Unknown tool". Omit to register all.
plans: PLANS,
});
// acme auth | keys | balance | buy | invoices | usage | limits | team-usage | plans | plan | spend | cards | …Every command hits the same REST endpoint an agent would, so the CLI can never do more
or less than the API. commander is yours, not a dependency of this package — the
parameter is typed structurally (CommandLike).
BillingConfig (pass to resolveConfig):
| Field | Default | Purpose |
|---|---|---|
baseUrl |
required | Checkout success/cancel + portal return URLs |
currency |
"usd" |
Stripe currency |
freeCredits |
100 |
Welcome credit on first customer creation |
internalDomains |
[] |
Orgs with these verified WorkOS domains are unmetered (see internalDomainsFromEnv) |
messages |
DEFAULT_MESSAGES |
Override the library's own strings (refusals, basket problems, plan tables) once, for the whole deployment. What a REFUSED caller reads comes back in them through the API and CLI too |
spendLimit.defaultCredits |
null |
The monthly ceiling a customer who set none is held to. The METER reads it, so it is a real limit and not a placeholder a settings page shows |
spendLimit.required |
false |
Refuse set_spend_controls clearing the ceiling |
paymentMethods.maxCards |
3 |
How many cards a customer keeps; prunePaymentMethods evicts least-recently-used, never the default |
roles.purchase |
"admin" |
Who may spend the workspace's money (buy_credits, set_auto_reload). "member" for a product whose members hold their own cards. Also what usageAction reads to decide "buy this" vs "ask an owner" |
tax.origin |
unset | Where you're established ("IT"). Decides domestic vs cross-border, which is the whole question a VAT rate turns on. Unset falls back to the Stripe account's country — mode: "none" is how you opt out of tax entirely |
tax.mode |
derived | "local" | "stripe" | "none". Overrides what origin implies |
import { sellerRegime } from "@arnaudjnn/billing-tools";
// A French micro-entreprise under the franchise: 0% to everyone, everywhere.
config: { baseUrl, currency: "eur", tax: sellerRegime({ country: "FR", vatRegistered: false }) }
// The same business past €10 000 of cross-border EU B2C, and now UK-registered.
tax: sellerRegime({
country: "FR",
vatRegistered: false, // franchise en base — a DOMESTIC relief
oss: true, // one registration covering every member state
alsoCollectIn: [{ country: "GB" }], // the UK has NO threshold for a non-established seller
})What each produces, asserted in tests/seller-regime.test.mjs:
| domestic | EU B2C | EU B2B (valid VAT id) | UK B2C | |
|---|---|---|---|---|
vatRegistered: false |
0% | 0% | 0% reverse charge | 0% |
+ oss: true |
0% | 22% IT / 19% DE | 0% reverse charge | 0% |
+ alsoCollectIn: [GB] |
0% | 22% / 19% | 0% reverse charge | 20% |
vatRegistered: true |
20% | 22% / 19% | 0% reverse charge | 20% |
vatRegistered is a domestic fact and changes nothing cross-border. Reverse charge on EU
B2B does not require the seller to be VAT-registered — the customer self-accounts —
and the mandatory mention (notes.reverseCharge) rides on the 0% rate, which is the
point of minting a TaxRate for it rather than omitting tax.
Every form the library builds — the seat checkout, the credit top-up, the card setup — collects a required billing address and a tax id, and writes both back to the Customer, so the next charge and the invoice have them.
Where you owe tax is library data; where you are registered is yours. What each
country demands of a seller with no establishment there lives in one file
(src/tax-obligations.ts), because those rules move and no developer should have to
research them. The doctor reads it against your real Stripe customers and tells you the
declaration is incomplete:
✗ Unregistered exposure: GB — 2 customer(s) are in GB, which taxes a non-established
seller from the first consumer sale (no threshold), and config.tax.registrations
does not include it
→ B2B there is reverse-charged and needs no registration, so requiring a VAT id is
the alternative to registering. Register (UK VAT registration (NETP)) and add
{ country: "GB" } to registrations, or do not sell B2C there.
A zero-threshold country (the UK) is an error — the obligation starts at the first consumer sale, so it needs no knowledge of your turnover. A thresholded one (Norway NOK 50 000, Australia A$75 000) can only warn. A country the file makes no claim about stays silent, because absence means "unknown", never "no obligation".
What no library can do: tell you when €10 000 was crossed, or that you now owe UK
VAT. Those are facts about your turnover. checkBillingSetup reports what the config
claims and cannot audit it — declaring vatRegistered: false with no registrations says
"I owe nothing anywhere", and the engine believes you.
- 🪙 x402 machine-payment protocol alongside MPP
Issues and PRs welcome. The engine is plain TypeScript compiled with tsc; dist/ is gitignored and built in CI, so it exists only in the published npm tarball. See AGENTS.md for the architecture, the adapter interface, the SDK-first doctrine, and the release flow.
MIT © Arnaud Jeannin