# API Reference All methods are available on `billing.api` for direct server-side usage. Customers [#customers] createCustomer [#createcustomer] Create a billing customer linked to your user. ```typescript const customer = await billing.api.createCustomer({ externalId: user.id, // Your user ID email: user.email, name: user.name, // Optional }); ``` | Parameter | Type | Required | Description | | ------------ | -------- | -------- | -------------------------- | | `externalId` | `string` | Yes | Your application's user ID | | `email` | `string` | Yes | Billing email | | `name` | `string` | No | Customer name | **Returns:** `Customer` getCustomer [#getcustomer] Get customer by external ID. ```typescript const customer = await billing.api.getCustomer({ externalId: user.id, }); ``` **Returns:** `Customer | null` Plans [#plans] listPlans [#listplans] List all public plans. Plans with `isPublic: false` are excluded. ```typescript const plans = await billing.api.listPlans(); ``` **Returns:** `Plan[]` getPlan [#getplan] Get a specific plan by code. ```typescript const plan = await billing.api.getPlan({ code: "pro" }); ``` **Returns:** `Plan | null` Subscriptions [#subscriptions] createSubscription [#createsubscription] Create a subscription. With Stripe, this returns a checkout URL for the user to complete payment. ```typescript const result = await billing.api.createSubscription({ customerId: user.id, // externalId planCode: "pro", interval: "monthly", // Optional, default: "monthly" successUrl: "https://app.com/success", cancelUrl: "https://app.com/pricing", }); if (result.redirectUrl) { // User needs to complete payment redirect(result.redirectUrl); } else { // Subscription activated immediately (e.g., free plan) console.log(result.subscription.status); // "active" } ``` | Parameter | Type | Required | Description | | ------------ | ----------------------- | -------- | ------------------------ | | `customerId` | `string` | Yes | Customer's externalId | | `planCode` | `string` | Yes | Plan to subscribe to | | `interval` | `"monthly" \| "yearly"` | No | Billing interval | | `successUrl` | `string` | Yes\* | Redirect after payment | | `cancelUrl` | `string` | Yes\* | Redirect if user cancels | \*Required for payment adapters that use redirects (Stripe). **Returns:** `{ subscription: Subscription; redirectUrl?: string }` getSubscription [#getsubscription] Get customer's active subscription. ```typescript const subscription = await billing.api.getSubscription({ customerId: user.id, }); if (subscription) { console.log(subscription.status); // "active" | "trialing" | "canceled" | ... console.log(subscription.planCode); // "pro" console.log(subscription.currentPeriodEnd); } ``` **Returns:** `Subscription | null` cancelSubscription [#cancelsubscription] Cancel a subscription. ```typescript const subscription = await billing.api.cancelSubscription({ customerId: user.id, cancelAt: "period_end", // or "immediately" }); ``` | Parameter | Type | Required | Description | | ------------ | ------------------------------- | -------- | ---------------------------------------- | | `customerId` | `string` | Yes | Customer's externalId | | `cancelAt` | `"period_end" \| "immediately"` | No | When to cancel (default: `"period_end"`) | **Returns:** `Subscription | null` Features [#features] checkFeature [#checkfeature] Check if a customer has access to a feature. ```typescript const { allowed } = await billing.api.checkFeature({ customerId: user.id, feature: "api_access", // Type-safe }); if (!allowed) { throw new Error("Upgrade required"); } ``` **Returns:** `{ allowed: boolean }` listFeatures [#listfeatures] List all features available to a customer based on their plan. ```typescript const features = await billing.api.listFeatures({ customerId: user.id, }); // [{ code: "export", name: "Export Data", enabled: true }, ...] ``` **Returns:** `FeatureAccess[]` Trial Helpers [#trial-helpers] Utility functions for checking trial status. Import from `billsdk`: ```typescript import { isInTrial, daysRemainingInTrial, getTrialInfo } from "billsdk"; ``` isInTrial [#isintrial] Check if a subscription is currently in a trial period. ```typescript if (isInTrial(subscription)) { // Show trial UI } ``` **Returns:** `boolean` daysRemainingInTrial [#daysremainingintrial] Get the number of days remaining in the trial. ```typescript const days = daysRemainingInTrial(subscription); if (days <= 3) { // Show "trial ending soon" banner } ``` | Parameter | Type | Required | Description | | -------------- | -------------- | -------- | ------------------------------------ | | `subscription` | `Subscription` | Yes | The subscription to check | | `now` | `Date` | No | Current time (default: `new Date()`) | **Returns:** `number` (0 if not trialing) getTrialInfo [#gettrialinfo] Get full trial information for a subscription. ```typescript const trial = getTrialInfo(subscription); if (trial?.isTrialing) { console.log(`${trial.daysRemaining} days left`); console.log(`Trial ends: ${trial.trialEnd}`); } ``` | Parameter | Type | Required | Description | | -------------- | -------------- | -------- | ------------------------------------ | | `subscription` | `Subscription` | Yes | The subscription to check | | `now` | `Date` | No | Current time (default: `new Date()`) | **Returns:** `{ isTrialing: boolean; daysRemaining: number; trialEnd: Date | null } | null` Returns `null` if the subscription has never had a trial. Health [#health] health [#health-1] Health check endpoint. ```typescript const { status, timestamp } = await billing.api.health(); // { status: "ok", timestamp: "2024-01-15T10:30:00.000Z" } ``` Types [#types] Customer [#customer] ```typescript interface Customer { id: string; externalId: string; email: string; name?: string; providerCustomerId?: string; metadata?: Record; createdAt: Date; updatedAt: Date; } ``` Subscription [#subscription] ```typescript interface Subscription { id: string; customerId: string; planCode: string; interval: "monthly" | "quarterly" | "yearly"; status: SubscriptionStatus; providerSubscriptionId?: string; currentPeriodStart: Date; currentPeriodEnd: Date; canceledAt?: Date; cancelAt?: Date; trialStart?: Date; trialEnd?: Date; createdAt: Date; updatedAt: Date; } type SubscriptionStatus = | "active" | "trialing" | "past_due" | "canceled" | "paused" | "incomplete" | "pending_payment"; ``` Plan [#plan] ```typescript interface Plan { code: string; name: string; description?: string; isPublic: boolean; prices: PlanPrice[]; features: string[]; } interface PlanPrice { amount: number; currency: string; interval: "monthly" | "quarterly" | "yearly"; trialDays?: number; } ``` # Basic Usage After [installation](/docs/installation), you're ready to create customers and subscriptions. Customers [#customers] A customer represents who pays. Link it to your user system using `externalId`. Create Customer [#create-customer] ```ts title="server.ts" import { billing } from "@/lib/billing"; await billing.api.createCustomer({ externalId: user.id, // your user ID email: user.email, name: user.name, // optional }); ``` `externalId` lets you reference customers by your user ID instead of BillSDK's internal ID. Get Customer [#get-customer] ```ts title="server.ts" const customer = await billing.api.getCustomer({ externalId: user.id, }); ``` Subscriptions [#subscriptions] A subscription connects a customer to a plan. Paid plans redirect to Stripe Checkout. Free plans activate immediately. Create Subscription [#create-subscription] ```ts title="actions.ts" import { billing } from "@/lib/billing"; import { redirect } from "next/navigation"; const result = await billing.api.createSubscription({ customerId: user.id, // externalId or customer.id planCode: "pro", interval: "monthly", // or "yearly" successUrl: "/dashboard", cancelUrl: "/pricing", }); // Paid plans return a Stripe Checkout URL if (result.redirectUrl) { redirect(result.redirectUrl); } // Free plans activate immediately (no redirect) ``` Get Subscription [#get-subscription] ```ts title="server.ts" const subscription = await billing.api.getSubscription({ customerId: user.id, }); // subscription.status: "active" | "trialing" | "canceled" | "past_due" | ... // subscription.planCode: "pro" // subscription.currentPeriodEnd: Date ``` Change Plan [#change-plan] Upgrades apply immediately with proration. Downgrades take effect at period end. ```ts title="actions.ts" await billing.api.changeSubscription({ customerId: user.id, newPlanCode: "enterprise", newInterval: "yearly", // optional }); ``` Cancel Subscription [#cancel-subscription] ```ts title="actions.ts" await billing.api.cancelSubscription({ customerId: user.id, cancelAt: "period_end", // keeps access until period ends }); // Or cancel immediately await billing.api.cancelSubscription({ customerId: user.id, cancelAt: "immediately", }); ``` Feature Access [#feature-access] Check if a customer has access to a feature based on their plan. ```ts title="server.ts" const hasAccess = await billing.api.checkFeature({ customerId: user.id, feature: "api_access", }); if (!hasAccess) { throw new Error("Upgrade to Pro to access the API"); } ``` Client Side [#client-side] Use React hooks to access billing data in your components. ```ts title="lib/billing-client.ts" import { createBillingClient } from "billsdk/react"; export const { useCustomer, useSubscription, usePlans } = createBillingClient(); ``` useSubscription [#usesubscription] ```tsx title="dashboard.tsx" import { useSubscription } from "@/lib/billing-client"; export function Dashboard() { const { data: subscription, isLoading } = useSubscription(); if (isLoading) return
Loading...
; return (

Plan: {subscription?.planCode}

Status: {subscription?.status}

); } ``` usePlans [#useplans] ```tsx title="pricing.tsx" import { usePlans } from "@/lib/billing-client"; export function Pricing() { const { data: plans } = usePlans(); return (
{plans?.map((plan) => (

{plan.name}

${plan.prices[0].amount / 100}/mo

))}
); } ``` Client hooks require the billing route handler to be mounted. See [Installation](/docs/installation). Server Side [#server-side] Use `billing.api` methods directly on the server. All methods are async. ```ts title="server.ts" import { billing } from "@/lib/billing"; // In a Server Action or API route const customer = await billing.api.getCustomer({ externalId: user.id }); const subscription = await billing.api.getSubscription({ customerId: user.id }); const plans = await billing.api.listPlans(); ``` # Configuration Options [#options] ```typescript const billing = billsdk({ database: drizzleAdapter(db, { schema, provider: "pg" }), secret: process.env.BILLSDK_SECRET!, trustedOrigins: ["https://myapp.com"], payment: stripePayment({ ... }), basePath: "/api/billing", features: [...], plans: [...], plugins: [...], hooks: { before, after }, logger: { level: "info", disabled: false }, }); ``` | Option | Type | Default | Description | | ---------------- | ----------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------- | | `database` | `DBAdapter` | `memoryAdapter()` | Database adapter | | `payment` | `PaymentAdapter` | `paymentAdapter()` | Payment provider adapter | | `basePath` | `string` | `/api/billing` | API base path | | `secret` | `string` | - | **Required.** Secret for signing CSRF tokens and Bearer auth. Min 32 chars. Falls back to `BILLSDK_SECRET` env var | | `trustedOrigins` | `string[]` | `[]` | Origins allowed to make mutating requests. Supports wildcards (`*.example.com`). Falls back to `BILLSDK_TRUSTED_ORIGINS` env var | | `features` | `FeatureConfig[]` | `[]` | Feature definitions | | `plans` | `PlanConfig[]` | `[]` | Plan definitions | | `plugins` | `Plugin[]` | `[]` | Plugins | | `hooks` | `object` | - | Request hooks | | `logger` | `object` | - | Logger configuration | Features [#features] Features are capabilities you can gate per plan. Currently only boolean (on/off) features are supported. ```typescript features: [ { code: "export", name: "Export Data" }, { code: "api_access", name: "API Access" }, { code: "priority_support", name: "Priority Support" }, ] ``` | Property | Type | Required | Description | | -------- | ----------------------------------- | -------- | ----------------------------------- | | `code` | `string` | Yes | Unique identifier | | `name` | `string` | Yes | Display name | | `type` | `"boolean" \| "metered" \| "seats"` | No | Feature type (default: `"boolean"`) | Feature codes are type-safe. When you call `checkFeature`, TypeScript only accepts codes you defined: ```typescript // Type error: "invalid_feature" is not a valid feature code await billing.api.checkFeature({ customerId, feature: "invalid_feature" }); ``` Plans [#plans] Plans define your pricing tiers. They exist only in code, not in the database. ```typescript plans: [ { code: "free", name: "Free", description: "Get started", isPublic: true, prices: [{ amount: 0, interval: "monthly" }], features: ["export"], }, { code: "pro", name: "Pro", prices: [ { amount: 2000, interval: "monthly", currency: "usd" }, { amount: 20000, interval: "yearly", trialDays: 14 }, ], features: ["export", "api_access"], }, { code: "enterprise", name: "Enterprise", isPublic: false, // Hidden from listPlans() prices: [{ amount: 9900, interval: "monthly" }], features: ["export", "api_access", "priority_support"], }, ] ``` Plan Properties [#plan-properties] | Property | Type | Required | Description | | ------------- | ------------------- | -------- | --------------------------------------- | | `code` | `string` | Yes | Unique identifier | | `name` | `string` | Yes | Display name | | `description` | `string` | No | Plan description | | `isPublic` | `boolean` | No | Show in `listPlans()` (default: `true`) | | `prices` | `PlanPriceConfig[]` | Yes | Pricing options | | `features` | `string[]` | No | Enabled feature codes | Price Properties [#price-properties] | Property | Type | Required | Description | | ----------- | -------------------------------------- | -------- | -------------------------------- | | `amount` | `number` | Yes | Price in cents (2000 = $20.00) | | `interval` | `"monthly" \| "quarterly" \| "yearly"` | Yes | Billing cycle | | `currency` | `string` | No | ISO 4217 code (default: `"usd"`) | | `trialDays` | `number` | No | Trial period length | Database Adapters [#database-adapters] Drizzle [#drizzle] ```typescript import { drizzleAdapter } from "@billsdk/drizzle-adapter"; database: drizzleAdapter(db, { schema, provider: "pg", // "pg" | "mysql" | "sqlite" }) ``` Memory (Testing) [#memory-testing] ```typescript import { memoryAdapter } from "@billsdk/memory-adapter"; database: memoryAdapter() ``` Payment Adapters [#payment-adapters] Default [#default] Activates subscriptions immediately without payment processing. Used automatically if you don't specify a payment adapter. ```typescript import { paymentAdapter } from "@billsdk/payment-adapter"; payment: paymentAdapter() ``` Use when: * All your plans are free * You're in development/testing * You handle payments outside BillSDK Stripe [#stripe] ```typescript import { stripePayment } from "@billsdk/stripe"; payment: stripePayment({ secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, }) ``` Hooks [#hooks] Execute code before or after every request. ```typescript hooks: { before: async ({ request, path, method }) => { // Return Response to short-circuit if (!isAuthenticated(request)) { return new Response("Unauthorized", { status: 401 }); } // Return undefined to continue }, after: async ({ request, path, method }) => { console.log(`[billing] ${method} ${path}`); }, } ``` Logger [#logger] ```typescript logger: { level: "debug", // "debug" | "info" | "warn" | "error" disabled: false, } ``` Security [#security] BillSDK protects all mutating endpoints (`POST`, `PUT`, `PATCH`, `DELETE`) automatically. `GET` requests and the `/webhook` endpoint are exempt. How It Works [#how-it-works] **Browser requests** are protected by two layers: 1. **Origin validation** — the `Origin` header must match one of your `trustedOrigins` 2. **CSRF token** — a signed token (using your `secret`) is required via cookie + header The BillSDK client handles CSRF tokens automatically. You don't need to do anything. **Server-to-server requests** can bypass origin and CSRF checks by sending the secret as a Bearer token: ```bash curl -X POST https://myapp.com/api/billing/customer \ -H "Authorization: Bearer $BILLSDK_SECRET" \ -H "Content-Type: application/json" \ -d '{"externalId": "user_123", "email": "user@example.com"}' ``` **Server-side direct calls** via `billing.api.*` don't go through HTTP, so security checks don't apply. Configuration [#configuration] ```typescript const billing = billsdk({ // Required. Throws if missing. Falls back to BILLSDK_SECRET env var. secret: process.env.BILLSDK_SECRET!, // Origins allowed to make mutating requests from the browser. // Supports wildcards. Falls back to BILLSDK_TRUSTED_ORIGINS env var (comma-separated). trustedOrigins: ["https://myapp.com", "*.myapp.com"], }); ``` Generate a secret: ```bash openssl rand -base64 32 ``` Trusted Origins [#trusted-origins] | Pattern | Matches | Doesn't match | | ----------------------- | ----------------------------------------------- | ------------------------------------------- | | `https://myapp.com` | `https://myapp.com` | `http://myapp.com`, `https://sub.myapp.com` | | `*.myapp.com` | `https://sub.myapp.com`, `http://app.myapp.com` | `https://myapp.com` | | `https://*.myapp.com` | `https://sub.myapp.com` | `http://sub.myapp.com` | | `http://localhost:3000` | `http://localhost:3000` | `http://localhost:3001` | Webhooks [#webhooks] The `/webhook` endpoint is exempt from origin and CSRF checks. It relies on the payment provider's signature verification (e.g., Stripe's webhook secret). # Introduction BillSDK is a TypeScript library that handles billing logic inside your app. Define plans in code, store data in your database, swap payment providers without rewriting your integration. No SaaS middleman, no revenue share, no vendor lock-in. LLMs.txt [#llmstxt] BillSDK exposes an `llms.txt` that helps AI models understand how to integrate and interact with your billing system. See it at [billsdk.com/llms.txt](https://billsdk.com/llms.txt). For complete documentation in a single file, use [billsdk.com/llms-full.txt](https://billsdk.com/llms-full.txt). # Installation Install the Packages [#install-the-packages] ```bash npm install billsdk @billsdk/stripe ``` `@billsdk/stripe` is optional if you only need free plans. The Drizzle adapter is included in `billsdk`. Set Environment Variables [#set-environment-variables] Create a `.env` file in the root of your project: ```txt title=".env" BILLSDK_SECRET=your-secret-key-at-least-32-characters STRIPE_SECRET_KEY=sk_test_... STRIPE_WEBHOOK_SECRET=whsec_... ``` Generate a secret with `openssl rand -base64 32`. Get Stripe keys from [Stripe Dashboard → API Keys](https://dashboard.stripe.com/apikeys) and [Webhooks](https://dashboard.stripe.com/webhooks). `BILLSDK_SECRET` is required. BillSDK will not start without it. Create Database Schema [#create-database-schema] Add the billing tables to your Drizzle schema: ```ts title="db/schema.ts" import { integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; export const customer = pgTable("customer", { id: uuid("id").primaryKey().defaultRandom(), externalId: text("external_id").unique(), email: text("email").notNull(), name: text("name"), providerCustomerId: text("provider_customer_id"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); export const subscription = pgTable("subscription", { id: uuid("id").primaryKey().defaultRandom(), customerId: uuid("customer_id").references(() => customer.id, { onDelete: "cascade" }).notNull(), planCode: text("plan_code").notNull(), interval: text("interval").default("monthly").notNull(), status: text("status").default("active").notNull(), providerSubscriptionId: text("provider_subscription_id"), providerCheckoutSessionId: text("provider_checkout_session_id"), currentPeriodStart: timestamp("current_period_start").defaultNow().notNull(), currentPeriodEnd: timestamp("current_period_end").notNull(), canceledAt: timestamp("canceled_at"), cancelAt: timestamp("cancel_at"), trialStart: timestamp("trial_start"), trialEnd: timestamp("trial_end"), scheduledPlanCode: text("scheduled_plan_code"), scheduledInterval: text("scheduled_interval"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); export const payment = pgTable("payment", { id: uuid("id").primaryKey().defaultRandom(), customerId: uuid("customer_id").references(() => customer.id, { onDelete: "cascade" }).notNull(), subscriptionId: uuid("subscription_id"), type: text("type").notNull(), status: text("status").default("pending").notNull(), amount: integer("amount").notNull(), currency: text("currency").default("usd").notNull(), providerPaymentId: text("provider_payment_id"), refundedAmount: integer("refunded_amount"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); ``` Then push to your database: ```bash npx drizzle-kit push ``` See [Drizzle Adapter](/docs/adapters/drizzle) for MySQL and SQLite schemas. Create Billing Instance [#create-billing-instance] Create a file named `billing.ts` in your `lib/` folder: ```ts title="lib/billing.ts" import { billsdk } from "billsdk"; import { drizzleAdapter } from "billsdk/adapters/drizzle"; import { stripePayment } from "@billsdk/stripe"; import { db } from "./db"; import * as schema from "./db/schema"; export const billing = billsdk({ database: drizzleAdapter(db, { schema, provider: "pg", // or "mysql", "sqlite" }), secret: process.env.BILLSDK_SECRET!, trustedOrigins: [process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000"], payment: stripePayment({ secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, }), features: [ { code: "api_access", name: "API Access" }, { code: "priority_support", name: "Priority Support" }, ], plans: [ { code: "free", name: "Free", prices: [{ amount: 0, interval: "monthly" }], features: [], }, { code: "pro", name: "Pro", prices: [ { amount: 2000, interval: "monthly" }, { amount: 20000, interval: "yearly" }, ], features: ["api_access", "priority_support"], }, ], }); ``` Prices are in cents. `2000` = $20.00. Mount Route Handler [#mount-route-handler] Create a catch-all route to handle billing API requests: ```ts title="app/api/billing/[...all]/route.ts" import { billingHandler } from "billsdk/next"; import { billing } from "@/lib/billing"; export const { GET, POST } = billingHandler(billing); ``` Create Client Instance [#create-client-instance] Create a client to interact with the billing API from your frontend: ```ts title="lib/billing-client.ts" import { createBillingClient } from "billsdk/react"; export const billingClient = createBillingClient(); export const { useCustomer, useSubscription, usePlans } = billingClient; ``` Configure Stripe Webhook [#configure-stripe-webhook] Add a webhook endpoint in Stripe Dashboard pointing to: ``` https://your-domain.com/api/billing/webhook ``` Select these events: * `checkout.session.completed` * `invoice.paid` * `invoice.payment_failed` * `customer.subscription.deleted` For local development, use [Stripe CLI](https://stripe.com/docs/stripe-cli) to forward webhooks: ```bash stripe listen --forward-to localhost:3000/api/billing/webhook ``` Done [#done] You're ready to use BillSDK. Continue to [Basic Usage](/docs/basic-usage) to create customers and subscriptions. # Drizzle Setup [#setup] ```typescript import { drizzleAdapter } from "@billsdk/drizzle-adapter"; database: drizzleAdapter(db, { schema, provider: "pg", // "pg" | "mysql" | "sqlite" }) ``` Schema (PostgreSQL) [#schema-postgresql] ```typescript title="db/schema.ts" import { integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; export const customer = pgTable("customer", { id: uuid("id").primaryKey().defaultRandom(), externalId: text("external_id").unique(), email: text("email").notNull(), name: text("name"), providerCustomerId: text("provider_customer_id"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); export const subscription = pgTable("subscription", { id: uuid("id").primaryKey().defaultRandom(), customerId: uuid("customer_id").references(() => customer.id, { onDelete: "cascade" }).notNull(), planCode: text("plan_code").notNull(), interval: text("interval").default("monthly").notNull(), status: text("status").default("active").notNull(), providerSubscriptionId: text("provider_subscription_id"), providerCheckoutSessionId: text("provider_checkout_session_id"), currentPeriodStart: timestamp("current_period_start").defaultNow().notNull(), currentPeriodEnd: timestamp("current_period_end").notNull(), canceledAt: timestamp("canceled_at"), cancelAt: timestamp("cancel_at"), trialStart: timestamp("trial_start"), trialEnd: timestamp("trial_end"), scheduledPlanCode: text("scheduled_plan_code"), scheduledInterval: text("scheduled_interval"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); export const payment = pgTable("payment", { id: uuid("id").primaryKey().defaultRandom(), customerId: uuid("customer_id").references(() => customer.id).notNull(), subscriptionId: uuid("subscription_id"), type: text("type").notNull(), // "subscription" | "renewal" | "upgrade" | "refund" status: text("status").notNull(), // "pending" | "succeeded" | "failed" | "refunded" amount: integer("amount").notNull(), currency: text("currency").default("usd").notNull(), providerPaymentId: text("provider_payment_id"), refundedAmount: integer("refunded_amount"), metadata: jsonb("metadata"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); ``` For MySQL/SQLite, adapt the types accordingly. Migrations [#migrations] ```bash npx drizzle-kit push ``` # Memory ```typescript import { memoryAdapter } from "@billsdk/memory-adapter"; database: memoryAdapter() ``` Data lost on restart. Use for tests. ```typescript import { billsdk, memoryAdapter, paymentAdapter } from "billsdk"; const billing = billsdk({ database: memoryAdapter(), payment: paymentAdapter(), // Mock payments plans: [...], }); // Test const customer = await billing.api.createCustomer({ ... }); const { subscription } = await billing.api.createSubscription({ ... }); expect(subscription.status).toBe("active"); ``` # Stripe Setup [#setup] ```typescript import { stripePayment } from "@billsdk/stripe"; payment: stripePayment({ secretKey: process.env.STRIPE_SECRET_KEY!, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!, }) ``` Webhooks [#webhooks] **Endpoint:** `/api/billing/webhook` **Events to enable:** * `checkout.session.completed` — activates subscription * `payment_intent.payment_failed` — marks as failed Stripe Dashboard [#stripe-dashboard] 1. [Developers → Webhooks](https://dashboard.stripe.com/webhooks) → Add endpoint 2. URL: `https://your-domain.com/api/billing/webhook` 3. Copy signing secret to `STRIPE_WEBHOOK_SECRET` Local Development [#local-development] ```bash stripe listen --forward-to localhost:3000/api/billing/webhook ``` Payment Modes [#payment-modes] | Price | Mode | Behavior | | ----- | ------- | ---------------------------------- | | `0` | Setup | Collects payment method, no charge | | `>0` | Payment | Charges immediately, saves method | Renewals [#renewals] ```typescript await billing.api.processRenewals(); await billing.api.processRenewals({ dryRun: true }); await billing.api.processRenewals({ customerId: user.id }); ``` Refunds [#refunds] ```typescript await billing.api.createRefund({ paymentId: "...", amount: 1000, // partial, in cents }); ``` # Behaviors Available [#available] | Behavior | Default | | ---------------------- | ---------------------------- | | `onRefund` | Refund + cancel subscription | | `onPaymentFailed` | Mark as `past_due` | | `onSubscriptionCancel` | Cancel at period end | | `onTrialEnd` | Charge or cancel | Usage [#usage] ```typescript billsdk({ behaviors: { onRefund: async (ctx, params, defaultBehavior) => { // Option 1: Custom logic await ctx.paymentAdapter.refund({ ... }); return { refund, originalPayment }; // Option 2: Run default + side effects const result = await defaultBehavior(); await sendEmail("Refund processed"); return result; }, }, }); ``` Example: Refund Without Canceling [#example-refund-without-canceling] ```typescript onRefund: async (ctx, params) => { const payment = await ctx.internalAdapter.findOne("payment", { where: [{ field: "id", operator: "eq", value: params.paymentId }], }); await ctx.paymentAdapter.refund({ providerPaymentId: payment.providerPaymentId!, }); // Don't cancel subscription return { refund: payment, originalPayment: payment }; }, ``` Example: Downgrade Instead of Cancel [#example-downgrade-instead-of-cancel] ```typescript onSubscriptionCancel: async (ctx, params) => { await ctx.internalAdapter.update("subscription", { where: [{ field: "customerId", operator: "eq", value: params.customerId }], data: { planCode: "free" }, }); return { subscription, canceledImmediately: false }; }, ``` Example: Extend Trial [#example-extend-trial] Override `onTrialEnd` to extend the trial for engaged users instead of charging immediately: ```typescript onTrialEnd: async (ctx, params, defaultBehavior) => { const subscription = await ctx.internalAdapter.findSubscriptionById( params.subscriptionId, ); // Extend trial by 7 more days for engaged users if (await isEngagedUser(subscription.customerId)) { const newTrialEnd = new Date(subscription.trialEnd!); newTrialEnd.setDate(newTrialEnd.getDate() + 7); const updated = await ctx.internalAdapter.updateSubscription( subscription.id, { trialEnd: newTrialEnd }, ); return { subscription: updated, converted: false }; } // Otherwise, run default (charge or cancel) return defaultBehavior(); }, ``` Example: Downgrade to Free on Trial End [#example-downgrade-to-free-on-trial-end] ```typescript onTrialEnd: async (ctx, params) => { const subscription = await ctx.internalAdapter.findSubscriptionById( params.subscriptionId, ); // Instead of charging, downgrade to free plan const updated = await ctx.internalAdapter.updateSubscription( subscription.id, { planCode: "free", status: "active" }, ); return { subscription: updated, converted: false }; }, ``` # CLI The BillSDK CLI helps you generate database schemas from your billing configuration. It reads your `billing.ts` config file, merges schemas from any plugins you're using, and generates the appropriate ORM-specific code. Generate [#generate] The `generate` command creates the database schema required by BillSDK. It reads your config, detects your adapter, and generates the schema file. ```bash npx @billsdk/cli generate ``` Options [#options] | Option | Description | Default | | --------------------------- | ------------------------------------------- | ------------------- | | `-c, --config ` | Path to billing config file | Auto-detected | | `-o, --output ` | Output file path | `billing-schema.ts` | | `-p, --provider ` | Database provider (`pg`, `mysql`, `sqlite`) | `pg` | | `-y, --yes` | Skip confirmation prompts | `false` | Examples [#examples] ```bash # Generate with defaults npx @billsdk/cli generate # Custom output path npx @billsdk/cli generate --output src/lib/db/billing-schema.ts # Specify provider npx @billsdk/cli generate --provider sqlite # Skip prompts (CI/CD) npx @billsdk/cli generate --yes ``` Output [#output] The command generates a Drizzle schema file with: * Tables for `customer`, `subscription`, `payment` * Plugin tables (e.g., `time_travel_state` if using time-travel plugin) * Relations between tables * Proper column types for your provider ```typescript // Generated billing-schema.ts import { pgTable, text, integer, timestamp, boolean } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; export const customer = pgTable("customer", { id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()), externalId: text("external_id").notNull().unique(), email: text("email"), name: text("name"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }); // ... more tables ``` After Generating [#after-generating] Run your ORM's migration tools: ```bash npx drizzle-kit generate npx drizzle-kit migrate ``` Init [#init] The `init` command scaffolds BillSDK in your project. It creates a `billing.ts` config file and optionally installs dependencies. ```bash npx @billsdk/cli init ``` Options [#options-1] | Option | Description | Default | | ----------- | ----------------------------- | ------- | | `-y, --yes` | Skip prompts and use defaults | `false` | Interactive Prompts [#interactive-prompts] Without `--yes`, the CLI asks: 1. **Database adapter** — Drizzle ORM (recommended) or In-Memory (testing) 2. **Database provider** — PostgreSQL, MySQL, or SQLite (if using Drizzle) 3. **Install dependencies** — Whether to run the install command Generated Config [#generated-config] ```typescript // billing.ts import { billsdk } from "billsdk"; import { drizzleAdapter } from "@billsdk/drizzle-adapter"; import { db } from "./db"; import * as schema from "./billing-schema"; export const billing = billsdk({ database: drizzleAdapter(db, { schema, provider: "pg", }), plans: [ { code: "free", name: "Free", description: "Get started for free", price: { monthly: 0, yearly: 0 }, features: { projects: { type: "limit", value: 3 }, }, }, { code: "pro", name: "Pro", description: "For growing teams", price: { monthly: 1900, yearly: 19000 }, features: { projects: { type: "limit", value: 100 }, analytics: { type: "boolean", value: true }, }, }, ], }); ``` Dependencies Installed [#dependencies-installed] For Drizzle with PostgreSQL: ```bash billsdk @billsdk/drizzle-adapter drizzle-orm drizzle-kit postgres ``` Config File Discovery [#config-file-discovery] The CLI searches for your billing config in these locations: * `billing.ts` / `billing.js` * `src/billing.ts` / `src/billing.js` * `lib/billing.ts` / `lib/billing.js` Use `--config` to specify a custom path. Plugin Schema Support [#plugin-schema-support] The CLI automatically merges schemas from plugins. If you're using the time-travel plugin: ```typescript import { timeTravelPlugin } from "@billsdk/time-travel"; export const billing = billsdk({ // ... plugins: [timeTravelPlugin()], }); ``` Running `generate` will include the plugin's `time_travel_state` table in the output. # Subscription Lifecycle States [#states] | Status | When | | ----------------- | --------------------------- | | `pending_payment` | Waiting for Stripe Checkout | | `trialing` | In trial period | | `active` | Paid and active | | `past_due` | Payment failed | | `canceled` | Ended | | `paused` | Temporarily paused | | `incomplete` | Setup incomplete | Flow [#flow] ``` pending_payment ──webhook──► active └► trialing ──trial ends──► active (charged) └► past_due (charge failed) └► canceled (no payment method) active ──payment fails──► past_due ──retries fail──► canceled ``` Cancellation [#cancellation] ```typescript // Keep access until period ends await billing.api.cancelSubscription({ customerId: user.id, cancelAt: "period_end", }); // status stays "active", cancelAt is set // End immediately await billing.api.cancelSubscription({ customerId: user.id, cancelAt: "immediately", }); // status becomes "canceled" ``` Trials [#trials] Configure per price: ```typescript prices: [ { amount: 2000, interval: "monthly", trialDays: 14 }, ] ``` Trial Flow [#trial-flow] 1. **Checkout**: User subscribes to a plan with `trialDays`. Stripe Checkout collects a payment method without charging (`mode: "setup"`). 2. **Webhook**: After checkout completes, subscription stays `"trialing"` (not overwritten to `"active"`). 3. **Trial period**: User has full access. `subscription.status === "trialing"` and `subscription.trialEnd` is set. 4. **Trial ends**: The `processRenewals()` cron finds expired trials and runs the `onTrialEnd` behavior: * **Has payment method**: Charges the first billing period, sets `currentPeriodStart`/`currentPeriodEnd`, transitions to `"active"`. * **Charge fails**: Transitions to `"past_due"`. * **No payment method**: Transitions to `"canceled"`. Plan Change During Trial [#plan-change-during-trial] Changing plans during a trial ends the trial immediately. The customer is charged the full price of the new plan and a new billing period starts. No proration is applied since nothing was charged during the trial. # Next.js Route Handler [#route-handler] ```typescript title="app/api/billing/[...all]/route.ts" import { billingHandler } from "billsdk/next"; import { billing } from "@/lib/billing"; export const { GET, POST } = billingHandler(billing); ``` Server Actions [#server-actions] ```typescript title="app/actions.ts" "use server"; import { billing } from "@/lib/billing"; import { redirect } from "next/navigation"; import { revalidatePath } from "next/cache"; export async function subscribe(planCode: string) { const user = await getCurrentUser(); const result = await billing.api.createSubscription({ customerId: user.id, planCode, successUrl: `${process.env.NEXT_PUBLIC_URL}/success`, cancelUrl: `${process.env.NEXT_PUBLIC_URL}/pricing`, }); if (result.redirectUrl) redirect(result.redirectUrl); revalidatePath("/dashboard"); } ``` Server Components [#server-components] ```tsx title="app/dashboard/page.tsx" import { billing } from "@/lib/billing"; export default async function DashboardPage() { const user = await getCurrentUser(); const subscription = await billing.api.getSubscription({ customerId: user.id }); return
Plan: {subscription?.planCode}
; } ``` Client Components [#client-components] For client-side interactivity, use the [React hooks](/docs/integrations/react). ```typescript title="lib/billing-client.ts" import { createBillingClient } from "billsdk/react"; export const billingClient = createBillingClient(); // Re-export hooks for convenience export const { useCustomer, useSubscription, usePlans } = billingClient; ``` ```tsx title="components/plan-badge.tsx" "use client"; import { useSubscription } from "@/lib/billing-client"; export function PlanBadge() { const { data: subscription, isLoading } = useSubscription(); if (isLoading) return Loading…; return {subscription?.planCode ?? "Free"}; } ``` Set the customer ID after authentication: ```tsx title="app/providers.tsx" "use client"; import { useEffect } from "react"; import { billingClient } from "@/lib/billing-client"; export function BillingProvider({ userId, children }: { userId: string | null; children: React.ReactNode; }) { useEffect(() => { billingClient.setCustomerId(userId ?? ""); }, [userId]); return children; } ``` Cron for Renewals [#cron-for-renewals] ```json title="vercel.json" { "$schema": "https://openapi.vercel.sh/vercel.json", "crons": [ { "path": "/api/billing/renewals", "schedule": "0 * * * *" } ] } ``` # React Setup [#setup] ```typescript title="lib/billing-client.ts" import { createBillingClient } from "billsdk/react"; export const billing = createBillingClient({ baseURL: "/api/billing", }); ``` | Option | Type | Default | | ------------- | -------------------------------------- | ---------------- | | `baseURL` | `string` | `"/api/billing"` | | `credentials` | `"include" \| "omit" \| "same-origin"` | `"include"` | Hooks [#hooks] All return `{ data, isLoading, error, refresh }`. ```tsx // Subscription const { data: subscription } = billing.useSubscription(); subscription?.status // "active" | "trialing" | ... subscription?.planCode // Plans const { data: plans } = billing.usePlans(); // Customer const { data: customer } = billing.useCustomer(); ``` Set Customer ID [#set-customer-id] ```tsx // After auth billing.setCustomerId(user.id); // On logout billing.setCustomerId(""); ``` Refresh [#refresh] ```tsx // Global refresh billing.refresh(); // Per-hook refresh const { refresh } = billing.useSubscription(); ``` # Time Travel Development only. Setup [#setup] ```typescript import { timeTravelPlugin } from "@billsdk/time-travel"; billsdk({ plugins: [timeTravelPlugin()], }); ``` Add `time_travel_state` table to your schema. React Overlay [#react-overlay] ```tsx import { TimeTravelOverlay } from "@billsdk/time-travel/react"; {process.env.NODE_ENV === "development" && ( )} ``` | Prop | Default | | ------------------ | ---------------- | | `baseUrl` | `"/api/billing"` | | `position` | `"bottom-right"` | | `defaultCollapsed` | `true` | API [#api] | Endpoint | Body | | --------------------------- | ---------------------------------------- | | `POST /time-travel/advance` | `{ customerId, days?, hours?, months? }` | | `POST /time-travel/set` | `{ customerId, date }` | | `POST /time-travel/reset` | `{ customerId }` | | `POST /time-travel/get` | `{ customerId }` | | `GET /time-travel/list` | — | Test Renewal [#test-renewal] ```typescript // Advance past period end await fetch("/api/billing/time-travel/set", { method: "POST", body: JSON.stringify({ customerId: user.id, date: subscription.currentPeriodEnd, }), }); // Process await billing.api.processRenewals({ customerId: user.id }); ```