Skip to content
zap.ts
Esc
navigateopen⌘Jpreview
On this page

Billing

Take payments and manage subscriptions. Seven payment companies are supported, and you can switch between them by changing two lines.

@zap-ts/billing lets your app charge money. It handles subscriptions, checkout and the customer portal.

A subscription is a plan a customer pays for every month. Checkout is the page where they enter their card. The customer portal is where they change or cancel their plan later.

Seven payment companies are supported: Stripe, Polar, Chargebee, Dodo Payments, Creem, Autumn and Commet. Stripe is set up by default.

Each one is built the same way inside. Your app never names a payment company directly. It imports @zap-ts/billing/server and @zap-ts/billing/client. This is why you can switch companies later without rewriting your app.

How billing connects to the app

Billing plugs into Better Auth, the part of zap.ts that handles sign-in.

The server side handles checkout, the customer portal, and webhooks. A webhook is a message the payment company sends to your app when something changes, for example when a payment fails.

import { billingPlugin } from "@zap-ts/billing/server";

The client side gives the browser the two calls it needs.

import { checkout, openBillingPortal } from "@zap-ts/billing/client";

Subscriptions are saved in the subscription table in your own database. So when your app checks what a customer pays for, it reads your database. It does not ask the payment company. That is much faster.

Plans

A plan is one thing a customer can buy, for example “Starter” or “Team”.

Plans live in one file: packages/billing/src/plans.ts.

Each payment company gives its plans different ID numbers. One plan in this file holds the IDs for all seven companies at once.

export const plans: Plan[] = [
  {
    name: "starter",
    trialDays: 14,
    stripe: { priceId: "price_...", seatPriceId: "price_..." },
    polar: { productId: "...", slug: "starter" },
    chargebee: { itemPriceId: "starter-USD-Monthly" },
    // ...one line for each company
  },
];

Each company reads only its own line and ignores the others. This is why switching companies does not leave an old ID behind.

trialDays sets a free trial. Leave it out and the customer pays at once.

Test mode and live mode

zap.ts will not charge a real card by accident.

Billing runs in one of two modes: test or live. Test mode uses fake cards and charges nobody.

Some payment companies put the mode inside the secret key itself. For those, zap.ts reads the key and works the mode out:

const mode = isLiveKey(env.STRIPE_SECRET_KEY, "sk_live_") ? "live" : "test";

A new copy of zap.ts comes with STRIPE_SECRET_KEY=sk_test_local_dev. That key starts with sk_test_, so the app runs in test mode. The billing page tells the user this on screen.

Nothing charges a real card until you set a real live key.

Who is allowed to change billing

Not every member of a team should be able to cancel the subscription.

zap.ts checks this itself, in @zap-ts/authorization. It does not leave the decision to the payment company. Only owners and admins of an organisation can manage its billing.

import { canManageBilling } from "@zap-ts/billing/access";

All seven payment companies use this same check.

When something happens to a subscription, zap.ts sends an email. It does this when a trial is about to end, when a payment fails, and when a customer cancels. The email goes to the owners and admins. See Mail.

Switching to another payment company

You change two files. Each change is one import path.

  1. Open packages/billing/src/server.ts. Change "./stripe/server" to the company you want, for example "./polar/server".
  2. Open packages/billing/src/client.ts. Change the client path to match.
  3. Put that company’s keys in .env.schema.
  4. Put that company’s plan IDs in plans.ts.

Then run these two commands:

pnpm run auth:generate
pnpm run db:migrate

You need them because each company saves slightly different information about a subscription. The first command updates the shape of the table. The second applies that change to your database.

Nothing else in your app changes. Every company exports the same names: billingPlugin, billingMode, readSubscription and billingCredentialsFromEnv.

Settings

Stripe, as it comes:

STRIPE_SECRET_KEY=sk_test_local_dev
STRIPE_WEBHOOK_SECRET=whsec_local_dev

These two lines live in the root .env.schema file. That file also holds a link to Stripe’s own documentation for each key.

See Environment to learn how zap.ts turns these lines into settings your code can read safely.

Last updated on September 22, 2026

Was this page helpful?