Skip to content

Repository files navigation

Billing Tools, the get-paid engine for Stripe + WorkOS apps, for humans and AI agents

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.

npm version npm downloads License: MIT TypeScript Stripe WorkOS MCP compatible Framework agnostic Tests PRs welcome


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).

Table of contents

Ship tools, not billing plumbing

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. 🛡️

Key Features

🔐 Auth & identity

  • 🔑 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/identity anonymous 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.

💳 Billing & payments

  • 🪙 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.

🤖 Built for AI agents

  • 📄 auth.md: the agent onboarding standard, served for you (/auth.md narrative + discovery metadata).
  • 🏧 MPP machine payments: Stripe's Machine Payments Protocol: a HTTP 402 + WWW-Authenticate: Payment challenge 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.

🧩 Three surfaces, one engine

  • 🛠️ MCP: createMcpTransport({ register, adapter }).
  • 🌐 REST: createToolListHandler + createToolDispatchHandler (429/401/400/404 mapping built in).
  • ⌨️ CLI: registerBillingCommands(program, …) gives you auth, keys, balance, buy, invoices.
  • 🪝 Stripe webhook: createStripeWebhookHandler() for instant checkout crediting.

🧾 Tax, calculated here

  • 🇪🇺 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: updateCheckoutSessionTaxRates recalculates 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.

🧑‍💻 Set up an environment with one command

  • 🏠 Local payments, no tunnel: npx billing-tools dev downloads the Stripe CLI if needed, forwards webhooks to localhost (no stripe login), and writes the session's signing secret into .env.local.
  • 🩺 npx billing-tools doctor: audits the environment your STRIPE_SECRET_KEY points 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.

🔄 Zero-webhook sync

  • 📡 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 via createSyncRoute().
  • 🪞 Generic DB mirror: shadow WorkOS orgs/users into your own tables (createMirror), driver-agnostic.
  • 📉 Dunning hook: invoice.payment_failed flips the org to past_due and fires an onPaymentFailed hook (Stripe Smart Retries do the retries).

🌍 Framework and platform agnostic

  • Web-standard handlers: everything returns Request to Response; 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).

📈 Scale, stated plainly

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.

⚖️ What this deliberately does not do

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 onUsageFaultwire 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.

🏛️ Design doctrine

  • 📐 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 Request to Response, unit-testable without a live account.

Getting Started

Five steps to a workspace that can be billed, and the last two are one command each.

Prerequisites

  • 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

Install

npm install @arnaudjnn/billing-tools
# peers (provide the ones you use): @modelcontextprotocol/sdk mcp-handler zod

Ships compiled dist/, so there's no build step or transpilePackages needed in the consumer.

1. Declare what you sell

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.

2. Environment

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 unmetered

Both 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).

3. Wire it up (one call)

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 5

Prefer 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.

4. Mount the routes

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 middleware

Pass 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.

5. One command for the environment

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.

Local webhooks

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.local

The 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.

First call

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.

From sandbox to production

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?

Creates itself, from the keys alone

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

Needs you, once per environment

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.

The deploy

# 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 it

Run 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.

Pricing examples

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.

Flat subscription + one org-wide pool

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.

Per-seat packs, with a top-up queue

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.

Pure pay-as-you-go, no subscription

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.

Free → self-serve → quote-only, with a rate limit

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.

What a catalogue decides for you

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.

Buying one

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.)

How it works

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.

Agent auth (auth.md)

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/revoke

An 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.

Machine payments (MPP)

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 resource

The 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 OAuth proxy (dynamic clients)

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!.token

The 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.

CLI

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).

Configuration

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

Selling worldwide: three facts, not a tax course

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.

Roadmap

  • 🪙 x402 machine-payment protocol alongside MPP

Contributing

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.

License

MIT © Arnaud Jeannin

About

The get-paid engine for Stripe + WorkOS apps, for humans and AI agents.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages