# CLI (/docs/cli) PayKit includes a CLI tool for project setup, database migrations, and plan syncing. Install it once and use it throughout the project lifecycle. ## paykitjs init [#paykitjs-init] ```bash npx paykitjs init ``` An interactive setup wizard that scaffolds everything you need to get started. It configures Stripe, then generates: * A `paykit.ts` config file with an example plan structure * A route handler for webhooks * An optional client file for frontend use Run this once when starting a new project. ## paykitjs push [#paykitjs-push] ```bash npx paykitjs push ``` The command you'll run most often. It does two things: 1. Applies any pending database migrations to keep your schema up to date 2. Syncs your plan definitions to the database and Stripe, creating or updating products and prices in Stripe Run it on initial setup and again whenever you change your plan configuration. ### How versioning works [#how-versioning-works] When you change a plan and push, PayKit creates a new version of that plan. Old versions are never modified or deleted. This means running instances of your app keep working on their matching version while new code picks up the new one. ### Production usage [#production-usage] Run `push` before your app starts or builds, as part of your deploy pipeline. This way the updated product versions are ready in the database by the time your new code goes live. ```bash paykitjs push -y && next build ``` This is similar to how you'd run database migrations on deploy. The `-y` flag skips the confirmation prompt. ## paykitjs listen [#paykitjs-listen] ```bash npx paykitjs listen ``` Runs PayKit's local webhook listener. It registers a Stripe webhook tunnel, replays missed events, and streams new webhooks while you develop. Pass your app's dev command after `--` to run together with dev server: ```bash npx paykitjs listen -- pnpm dev ``` Useful options: * `--config ` loads a specific PayKit config file when default discovery is not enough * `--retry ` retries failed deliveries from a recent window, such as `5m`, `30s`, or `none` * `--forward-to ` forwards webhooks to a local app origin instead of applying them directly ## paykitjs status [#paykitjs-status] ```bash npx paykitjs status ``` Validates your entire PayKit setup without making any changes. Useful when debugging a broken environment. It checks: * Configuration file validity * Database connection * Migration status (whether `push` needs to be run) * Stripe API connectivity * Sync status between your config, database, and Stripe Pass `--throw` to exit with code 1 on failures, useful for CI pipelines: ```bash npx paykitjs status --throw ``` ## Telemetry [#telemetry] Telemetry can be disabled via environment variables: * `PAYKIT_TELEMETRY_DISABLED=1` * `DO_NOT_TRACK=1` # Client (/docs/client) The PayKit client SDK lets you call billing operations from the browser. It's fully type-safe, and methods and their inputs are inferred from your server instance. ## Setup [#setup] Install the client and create an instance typed to your server paykit export. It uses `import type` to carry server types into the browser without bundling any server code. ```ts title="src/lib/paykit-client.ts" import { createPayKitClient } from "paykitjs/client"; import type { paykit } from "@/server/paykit"; export const paykitClient = createPayKitClient(); ``` The client resolves the current customer automatically on each request. You need `identify` configured on your server instance for this to work. See [customer identification](/docs/customers#customer-identification-client) for details. ## Available methods [#available-methods] The client exposes `subscribe` and `customerPortal`. Neither requires a `customerId` since it's resolved from the incoming request via `identify`. ## subscribe [#subscribe] Works the same as the server-side `subscribe`, but without `customerId`. Returns `{ paymentUrl }`. ```tsx // Subscribe from a React component ``` ## customerPortal [#customerportal] Opens Stripe's customer portal. Returns `{ url }`. ```ts // Open customer billing portal const { url } = await paykitClient.customerPortal({ returnUrl: window.location.href, }); window.location.href = url; ``` ## Custom base URL [#custom-base-url] If you changed `basePath` on your server instance, pass the PayKit root URL to the client. ```ts export const paykitClient = createPayKitClient({ baseURL: "/custom", }); ``` The client derives its API URL automatically, so `baseURL: "/custom"` maps to `/custom/api` under the hood. You can also pass an absolute URL like `https://example.com/custom`. ## Separate frontend and API origins [#separate-frontend-and-api-origins] When the browser application and PayKit API use different origins, add the browser origin to the server's `trustedOrigins`. This protects cookie-authenticated billing mutations and lets PayKit resolve relative provider return URLs against the browser application. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... trustedOrigins: ["https://app.example.com"], }); ``` ```ts title="paykit-client.ts" export const paykitClient = createPayKitClient({ baseURL: "https://api.example.com/paykit", }); ``` No `trustedOrigins` configuration is needed when the frontend and PayKit handler share an origin. If a cross-origin request is rejected, PayKit logs the rejected origin and the configuration needed to trust it. ## Type safety [#type-safety] The client infers available plan IDs directly from your server instance type. If you pass an invalid `planId`, TypeScript catches it at compile time. See [TypeScript](/docs/typescript) for more on how type inference works across the stack. # Customers (/docs/customers) A customer is your app's user or billing entity. It can be a user ID, an org ID, or any string that uniquely identifies who's paying. PayKit maps it to the Stripe customer ID internally, so your app never has to think about Stripe IDs. ## Creating customers [#creating-customers] `upsertCustomer` creates the customer if they don't exist, or updates their details if they do. ```ts await paykit.upsertCustomer({ id: "user_123", email: "jane@example.com", name: "Jane Doe", }); ``` If a default plan is configured, newly created customers are automatically subscribed to it. You don't need to call `subscribe` separately for first-time users. ## Getting a customer [#getting-a-customer] `getCustomer` returns the customer along with their active subscriptions and entitlements. ```ts const customer = await paykit.getCustomer({ id: "user_123" }); // customer.subscriptions: active subscriptions with planId, status, period dates // customer.entitlements: feature balances keyed by feature ID ``` `subscriptions` is an array of `{ planId, status, cancelAtPeriodEnd, currentPeriodStart, currentPeriodEnd }`. `entitlements` is a record keyed by feature ID with `{ balance, limit, usage, unlimited, nextResetAt }`. ## Listing customers [#listing-customers] `listCustomers` returns a paginated list. You can filter by plan to find everyone on a specific tier. ```ts const { data, total, hasMore } = await paykit.listCustomers({ limit: 50, offset: 0, planIds: ["pro", "ultra"], }); ``` ## Deleting customers [#deleting-customers] `deleteCustomer` removes the customer from PayKit and cancels any active subscriptions in Stripe. ```ts await paykit.deleteCustomer({ id: "user_123" }); ``` ## Customer identification (client) [#customer-identification-client] When requests come in over HTTP, PayKit needs to know which customer is making the call. The `identify` option on `createPayKit` resolves the authenticated customer from the incoming request. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... identify: async (request) => { const session = await auth.api.getSession({ headers: request.headers }); if (!session) return null; return { customerId: session.user.id, email: session.user.email, name: session.user.name, }; }, }); ``` `identify` is required if you're using the client SDK or the built-in HTTP router. If it returns `null`, the request is treated as unauthenticated and rejected. When requests go through the HTTP router, `identify()` is the trust boundary. Any explicitly passed `customerId` must match what `identify()` returns. PayKit will reject mismatches. # Dashboard (/docs/dashboard) Coming soon. # Database (/docs/database) PayKit uses your app's PostgreSQL database to store billing state. Entitlement checks are fast local queries, not Stripe API calls. No round-trips to Stripe on every `check()`. ## Configuration [#configuration] Pass a `pg.Pool` instance or a connection string to `createPayKit`. ```ts title="paykit.ts" import { Pool } from "pg"; export const paykit = createPayKit({ database: new Pool({ connectionString: process.env.DATABASE_URL, }), // ... }); ``` ```ts title="paykit.ts" // Or pass a connection string directly export const paykit = createPayKit({ database: process.env.DATABASE_URL, // ... }); ``` ## Tables [#tables] PayKit creates tables prefixed with `paykit_`. The key ones are: | Table | What it stores | | ----------------------- | ------------------------------------------- | | `paykit_customer` | Customer records and provider ID mappings | | `paykit_subscription` | Active and past subscriptions | | `paykit_entitlement` | Current feature access and metered balances | | `paykit_product` | Products synced from your config | | `paykit_feature` | Features defined in your plan config | | `paykit_invoice` | Invoice records synced from the provider | | `paykit_payment_method` | Saved payment methods | | `paykit_webhook_event` | Processed webhook events for deduplication | PayKit owns these tables. Don't write to them directly. Use the PayKit API. ## Migrations [#migrations] `paykitjs push` applies any pending migrations. Run it on initial setup and whenever you update your plan configuration. ```bash npx paykitjs push ``` ## What's synced [#whats-synced] PayKit keeps two sources of truth in sync: **From webhooks** (Stripe to database): Subscriptions, invoices, payment methods, and customer-Stripe ID mappings are synced automatically when Stripe events arrive. **From your config** (code to database): Plans, products, and features are synced from your `createPayKit` configuration when you run `paykitjs push`. Each time you change a plan and push, a new version is created. Previous versions are kept so running instances can continue serving existing subscriptions. # Entitlements (/docs/entitlements) Entitlements represent what a customer can currently do based on their active plan. When a customer subscribes to a plan, PayKit derives their entitlements from the features that plan includes. ## Checking access [#checking-access] `check()` tells you whether a customer can use a feature right now. ```ts const { allowed, balance } = await paykit.check({ customerId: "user_123", featureId: "messages", }); // allowed: true // balance: { limit: 2000, remaining: 1847, resetAt: "2026-05-01T00:00:00Z", unlimited: false } ``` ## Boolean features [#boolean-features] For boolean features, `check()` returns `{ allowed: true }` if the feature is included in the customer's plan. There's no balance to track. ```ts const { allowed } = await paykit.check({ customerId: "user_123", featureId: "pro_models", }); // allowed: true (no balance for boolean features) ``` If the customer's plan doesn't include the feature, `allowed` is `false`. ## Metered features [#metered-features] For metered features, `check()` returns a `balance` object alongside `allowed`. ```ts balance: { limit: 2000, // total units for the period remaining: 1847, // units left resetAt: "...", // when the balance resets unlimited: false, // true if the plan grants unlimited usage } ``` `allowed` is `true` when `remaining > 0` (or when `unlimited` is `true`). ## Reporting usage [#reporting-usage] Call `report()` after a customer consumes a metered resource. It decrements the balance and returns the updated state. ```ts const { success, balance } = await paykit.report({ customerId: "user_123", featureId: "messages", amount: 1, }); ``` If the customer doesn't have sufficient balance, `success` is `false` and the balance isn't decremented. ## Balance resets [#balance-resets] Metered balances reset lazily. PayKit doesn't run a scheduled job to reset balances at midnight. Instead, when a `check()` or `report()` happens after the reset time, PayKit detects the period has passed and resets the balance automatically. Reset intervals are set per feature grant in your plan definition: `day`, `week`, `month`, or `year`. Lazy resets mean a customer who doesn't use the app won't have their balance reset until they do. The reset time is still calculated from the period boundary, not from when the reset was detected. ## Practical pattern [#practical-pattern] Check before acting, then report after the action succeeds. Don't report if the action fails. ```ts export async function POST(request: Request) { const { allowed } = await paykit.check({ customerId: userId, featureId: "messages", }); if (!allowed) { return Response.json({ error: "Usage limit reached" }, { status: 403 }); } const response = await generateChatResponse(input); await paykit.report({ customerId: userId, featureId: "messages", amount: 1, }); return Response.json(response); } ``` This pattern ensures you don't charge usage for failed requests, and you don't serve responses to customers who've hit their limit. # Installation (/docs/installation) ## Steps [#steps] ### Install the package [#install-the-package] Let's start by adding PayKit to your project: ```bash npm install paykitjs ``` ### Create the PayKit instance [#create-the-paykit-instance] Create a file named `paykit.ts` anywhere in your app. Usually you would put it somewhere in `src/`, `src/lib/`. Import `createPayKit` and export your paykit instance. This is the main server entry point for all billing. ```ts title="paykit.ts" import { createPayKit } from "paykitjs"; export const paykit = createPayKit({ // ... }); ``` ### Configure Stripe [#configure-stripe] PayKit uses Stripe for billing. Pass your Stripe keys directly to the PayKit instance. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... stripe: { // [!code highlight] secretKey: process.env.STRIPE_SECRET_KEY!, // [!code highlight] webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,// [!code highlight] },// [!code highlight] }); ``` ### Configure the database [#configure-the-database] PayKit needs a database to store billing state, such as subscriptions. You can create a separate database, or simply plug it into the app's own db. Pass a connection string directly, or `pg.Pool`. ```ts title="paykit.ts" import { Pool } from "pg"; export const paykit = createPayKit({ // ... database: new Pool({// [!code highlight] connectionString: process.env.DATABASE_URL!,// [!code highlight] }),// [!code highlight] }); ``` It works by creating a few tables prefixed with `paykit_`. You can learn more [here](/docs/database). ### Mount request handler [#mount-request-handler] To handle webhooks and client API requests, you need to set up a request handler on your server. Create a new file or route in your framework's designated catch-all route handler. This route should handle requests for the path /paykit/\* (unless you've configured a different base path). ```ts title="src/app/paykit/[[...slug]]/route.ts" import { paykitHandler } from "paykitjs/handlers/next"; import { paykit } from "@/lib/paykit"; export const { GET, POST } = paykitHandler(paykit); ``` ```ts title="src/routes/paykit.$.ts" import { createFileRoute } from "@tanstack/react-router"; import { paykit } from "@/lib/paykit"; async function handle({ request }: { request: Request }) { return paykit.handler(request) } export const Route = createFileRoute("/paykit/$")({ server: { handlers: { GET: handle, POST: handle, }, }, }); ``` The core handler is a standard Web API function that takes a `Request` and returns a `Response`. You can use it with any framework that supports the Web API standard. ```ts title="server.ts" import { paykit } from "./paykit"; // paykit.handler: (request: Request) => Promise // Mount it on any path that catches /paykit/* app.all("/paykit/*", (req) => paykit.handler(req)); ``` ### Create client instance [#create-client-instance] The client-side library helps you interact with the server. PayKit client sdk suitable for almost all modern frameworks, including React. ```ts title="src/lib/paykit-client.ts" import { createPayKitClient } from "paykitjs/client"; import type { paykit } from "@/server/paykit"; export const paykitClient = createPayKitClient(); ``` To use client we also need to authenticate incoming requests on the server, to do this, add the `identify` option to your server instance. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... identify: async ({ headers }) => { const session = await auth.api.getSession({ headers }); if (!session) return null; return { customerId: session.user.id, email: session.user.email, name: session.user.name, // Just pass user's data, and Stripe customer gets synced automatically! }; }, }); ``` ### Define your products [#define-your-products] Optionally. PayKit provides a code-first way to create your plans, and a very useful usage billing with `track()` and `report()` out of the box. By defining your products in code, you don't have to touch Stripe's dashboard at all. PayKit automatically syncs your pricing with Stripe, and you get full type safety with inferred ID types! ```ts title="products.ts" import { feature, plan } from "paykitjs"; const messages = feature({ id: "messages", type: "metered" }); const proModels = feature({ id: "pro_models", type: "boolean" }); export const free = plan({ id: "free", name: "Free", group: "base", default: true, includes: [ messages({ limit: 100, reset: "month" }) ], }); export const pro = plan({ id: "pro", name: "Pro", group: "base", price: { amount: 19, interval: "month" }, includes: [ messages({ limit: 2000, reset: "month" }), proModels() ], }); ``` Then pass your products to the main options: ```ts title="paykit.ts" import { free, pro } from "./products"; export const paykit = createPayKit({ // ... products: [free, pro], }); ``` This is an example setup for AI chat app. Read further on how to build your own billing config. ### Push changes to DB [#push-changes-to-db] PayKit includes a CLI tool to keep your database in sync with your configuration. ```bash npx paykitjs push ``` This applies database migrations and syncs your plan definitions to Stripe's products.
Run it once on setup, and every time after you change your products configuration.
For production deployments, see the [CLI reference](/docs/cli#production-usage).
# Introduction (/docs/introduction) PayKit is an embedded Stripe billing framework for TypeScript apps. Define plans in code while PayKit handles subscription management, usage-based billing, entitlements, and Stripe lifecycle code inside your app. ## Features [#features] PayKit aims to be a complete billing framework. It provides a wide range of features out of the box and allows you to extend it with plugins. # Metered Usage (/docs/metered-usage) This guide walks through implementing usage-based billing: how to define metered features, check balances, report consumption, and handle resets. Define a metered feature Metered features track usage against a limit. Define one with `type: "metered"`. ```ts title="products.ts" import { feature } from "paykitjs"; const messages = feature({ id: "messages", type: "metered" }); ``` Include in plans with different limits Pass the feature into each plan with a `limit` and `reset` interval. ```ts title="products.ts" export const free = plan({ id: "free", name: "Free", group: "base", default: true, includes: [ messages({ limit: 100, reset: "month" }), ], }); export const pro = plan({ id: "pro", name: "Pro", group: "base", price: { amount: 19, interval: "month" }, includes: [ messages({ limit: 2_000, reset: "month" }), ], }); ``` Free customers get 100 messages per month; Pro customers get 2,000. Check before consuming Call `check` before performing the action. It returns `allowed` (whether the customer has remaining balance) and `balance` (how many units are left). ```ts const { allowed, balance } = await paykit.check({ customerId: userId, featureId: "messages", }); ``` If `allowed` is `false`, the customer has hit their limit. Return early instead of proceeding. Perform the action Only run the actual work if `allowed` is `true`. ```ts if (!allowed) { return Response.json({ error: "Usage limit reached" }, { status: 403 }); } const response = await generateChatResponse(input); ``` Report usage After the action succeeds, call `report` to decrement the customer's balance. ```ts await paykit.report({ customerId: userId, featureId: "messages", amount: 1, }); ``` Pass `amount` matching however many units the action consumed. For most cases that's `1`, but you can pass larger values for batch operations. Balance resets PayKit uses lazy resets. When the reset period passes, it doesn't reset balances proactively. Instead, the next `check` or `report` call detects that the period has expired and resets the balance automatically before returning. This means you don't need any cron jobs or scheduled tasks. Resets happen on-demand, exactly when needed. ## Complete example [#complete-example] A full API route handler for an AI chat endpoint: ```ts title="app/api/chat/route.ts" export async function POST(request: Request) { const { allowed } = await paykit.check({ customerId: userId, featureId: "messages", }); if (!allowed) { return Response.json({ error: "Usage limit reached" }, { status: 403 }); } const response = await generateChatResponse(input); await paykit.report({ customerId: userId, featureId: "messages", amount: 1, }); return Response.json(response); } ``` If you just need on/off access without tracking usage, use `boolean` type instead. `check` still returns `allowed`, but there's no balance to track or reset. # Plans & Features (/docs/plans-and-features) Plans are the core billing unit in PayKit. They define what customers subscribe to, and features define what those plans grant access to. ## Defining features [#defining-features] A feature is either a boolean access gate or a metered usage limit. You define it once and reuse it across plans. ```ts title="products.ts" import { feature, plan } from "paykitjs"; // Boolean feature: grants access to something const proModels = feature({ id: "pro_models", type: "boolean" }); // Metered feature: tracks usage with a limit const messages = feature({ id: "messages", type: "metered" }); ``` Feature IDs must be lowercase alphanumeric with dashes or underscores, max 64 characters. **Feature types:** * `boolean`: the customer either has access or they don't. No usage tracking. * `metered`: tracks how much the customer has used against a limit, with a reset interval. ## Including features in plans [#including-features-in-plans] When you include a feature in a plan, you call it as a function. For **boolean features**, call with no arguments: ```ts proModels() ``` For **metered features**, pass a `limit` and a `reset` interval: ```ts messages({ limit: 100, reset: "month" }) ``` The `reset` interval can be `day`, `week`, `month`, or `year`. ## Defining plans [#defining-plans] Use `plan()` to define a plan. Every plan needs an `id`. Everything else is optional, but you'll almost always want `group`, `includes`, and either `default: true` or a `price`. ```ts title="products.ts" export const free = plan({ id: "free", name: "Free", group: "base", default: true, includes: [ messages({ limit: 100, reset: "month" }), ], }); export const pro = plan({ id: "pro", name: "Pro", group: "base", price: { amount: 19, interval: "month" }, includes: [ messages({ limit: 2_000, reset: "month" }), proModels(), ], }); export const ultra = plan({ id: "ultra", name: "Ultra", group: "base", price: { amount: 49, interval: "month" }, includes: [ messages({ limit: 10_000, reset: "month" }), proModels(), prioritySupport(), ], }); ``` ## Plan groups [#plan-groups] A group is a mutually exclusive set of plans. A customer can have one active plan per group at a time. Groups let you model common billing patterns: * A `base` group with free, pro, and ultra tiers * An `addons` group for one-off upgrades * A `seats` group for per-seat pricing Every plan that has `default: true` must belong to a group. Without a group, PayKit doesn't know which set of plans a customer is being placed into. ## Default plans [#default-plans] The default plan is the fallback for a group. It's typically your free tier. When a customer doesn't have an active subscription in a group, PayKit treats them as on the default plan. No subscription record is created until they explicitly subscribe. ```ts export const free = plan({ id: "free", group: "base", default: true, // ... }); ``` Only one plan per group can be `default: true`. ## Pricing [#pricing] Plans without a `price` are free. Paid plans take an `amount` in dollars and an `interval`. ```ts price: { amount: 19, interval: "month" } ``` * `interval` can be `month` or `year` * `amount` is in dollars, max $999,999.99 ## Passing products to PayKit [#passing-products-to-paykit] Pass your products array to `createPayKit`. You can import them directly or re-export them from a module object. ```ts title="paykit.ts" import { free, pro, ultra } from "./products"; export const paykit = createPayKit({ // ... products: [free, pro, ultra], }); // Plan and feature IDs are now type-safe: await paykit.subscribe({ customerId: "user_123", planId: "pro" }); // ✓ await paykit.subscribe({ customerId: "user_123", planId: "typo" }); // ✗ type error ``` ## Type inference [#type-inference] PayKit infers plan IDs and feature IDs from your `products` array. Typos are caught at compile time, so `planId: "proe"` or `featureId: "mesages"` won't compile. This also means your editor's autocomplete knows every valid plan and feature ID across the whole app. # Plugins (/docs/plugins) Plugins extend PayKit by adding endpoints to its HTTP router. They're optional. Core PayKit works without any plugins. ## Using a plugin [#using-a-plugin] Install the plugin package, then pass it to `createPayKit` via the `plugins` array. ```ts title="paykit.ts" import { dash } from "@paykitjs/dash"; export const paykit = createPayKit({ // ... plugins: [dash()], }); ``` Each plugin mounts its own endpoints under the PayKit API base path automatically. No manual route wiring needed. ## Dashboard plugin [#dashboard-plugin] `@paykitjs/dash` adds an embedded billing dashboard your users can access directly from your app. It shows subscriptions, invoices, and lets customers manage their payment methods. ```bash npm install @paykitjs/dash ``` Pass an `authorize` function to control who can access it. Without `authorize`, the dashboard endpoint is public. PayKit does not run `identify` automatically for plugin endpoints. ```ts title="paykit.ts" import { dash } from "@paykitjs/dash"; export const paykit = createPayKit({ // ... plugins: [ dash({ authorize: async (request) => { const session = await auth.api.getSession({ headers: request.headers, }); if (!session) throw new Error("Not authenticated"); }, }), ], }); ``` Throwing inside `authorize` blocks access. Returning without throwing grants it. ## Endpoint security [#endpoint-security] Plugins choose their own access model. Endpoints created with `createPayKitEndpoint` receive no automatic customer authentication. Use `definePayKitMethod({ requireCustomer: true })` for customer-scoped endpoints, or verify a signature or custom authorization inside public callbacks and webhooks. ## Plugin interface [#plugin-interface] A plugin is a plain object with an `id` and an optional `endpoints` map. PayKit mounts each endpoint under the configured API base path (`/paykit/api/*` by default). ```ts type Plugin = { id: string; endpoints?: Record; }; ``` The `id` must be unique across all plugins. PayKit uses it to namespace routes and avoid collisions. # Quickstart (/docs/quickstart) This page assumes you have completed the [installation](/docs/installation) steps and have a running PayKit instance with plans defined. ## Sync a customer [#sync-a-customer] Before a customer can subscribe, they need to exist in PayKit. Client side syncs them automatically, but on the server side you create them manually. ```ts // call upsertCustomer when the user is created, or before the purchase: await paykit.upsertCustomer({ id: "user_123", // user or organization id email: "jane@example.com", name: "Jane Doe", }); // then, pass `customerId` to purchase: await paykit.subscribe({ customerId: "user_123", planId: "pro" }) ``` ```ts // server.ts const paykit = createPayKit({ // ... // client's customer is automatically identified here: identify: async ({ headers }) => { const session = await auth.api.getSession({ headers }); if (!session) return null; return { customerId: session.user.id, // user or organization id email: session.user.email, name: session.user.name, }; }, }); // client.ts const paykitClient = createPayKitClient(); // so you don't need to pass `customerId` to purchase on client: await paykitClient.subscribe({ planId: "pro" }) ``` The customer ID can be your app's own user ID, or an organization ID. ## Subscribe to a plan [#subscribe-to-a-plan] To subscribe a customer to a plan, call `subscribe` with the plan ID. For paid plans without a saved payment method, it returns a `paymentUrl` pointing to Stripe Checkout. ```ts title="server.ts" const result = await paykit.subscribe({ customerId: "user_123", planId: "pro", successUrl: "https://myapp.com/billing/success", cancelUrl: "https://myapp.com/billing", }); if (result.paymentUrl) { // redirect user to Stripe Checkout } ``` ```tsx title="component.tsx" import { paykitClient } from "@/lib/paykit-client"; ``` For free plans, the subscription activates immediately with no redirect. ## Usage billing [#usage-billing] Use `check` to verify the customer has remaining balance, perform the action, then `report` to decrement usage. Both are server-side operations. ```ts title="app/api/chat/route.ts" export async function POST(request: Request) { const { allowed } = await paykit.check({ customerId: userId, featureId: "messages", }); if (!allowed) { return Response.json({ error: "Usage limit reached" }, { status: 403 }); } const response = await generateChatResponse(input); await paykit.report({ customerId: userId, featureId: "messages", amount: 1, }); return Response.json(response); } ``` For boolean features like `pro_models`, `check` returns `allowed` without any balance tracking. ## Listen to events [#listen-to-events] To react to billing changes, add the `on` option to your PayKit instance. The `customer.updated` event fires after any subscription or entitlement change. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... on: { // [!code highlight] "customer.updated": ({ payload }) => { // [!code highlight] console.log("billing changed for", payload.customerId); // [!code highlight] }, // [!code highlight] }, // [!code highlight] }); ``` ## Next steps [#next-steps] * [Plans & Features](/docs/plans-and-features) - plan groups, defaults, and feature types * [Subscriptions](/docs/subscriptions) - upgrade, downgrade, and cancellation behavior * [Entitlements](/docs/entitlements) - access checks and metered usage # Skills (/docs/skills) [Agent skills](https://agentskills.io) are portable instruction files (for example `SKILL.md`) that teach your coding agent project conventions, safe patterns, and where to look in the docs. The **PayKit** skill pack lives in the [getpaykit/skills](https://github.com/getpaykit/skills) repository. Install with the [skills CLI](https://www.npmjs.com/package/skills) (uses `npx` so nothing global is required): ```bash title="terminal" npx skills add getpaykit/skills ``` Your editor or agent loads skills from its configured skills directory (often project-level). After installing, restart the agent or reload skills if your tool requires it. ## Available Skills [#available-skills] * **create-paykit** Scaffold billing in a new project. Detects your framework, configures the database, sets up Stripe, mounts the route handler, and creates the client. * **paykit-best-practices** General configuration reference for `createPayKit` options, customer identification, event handlers, and testing mode. * **plans-and-features** Schema DSL covering `feature()`, `plan()`, plan groups, boolean and metered features, and type inference. * **subscriptions** The `subscribe()` API and its transition semantics: upgrades, downgrades, cancellations, and checkout flows. * **metered-usage** Entitlement gating with `check()` and `report()`, usage tracking, and balance resets. * **stripe** Stripe setup, webhook configuration, `customerPortal()`, and syncing plans with `push`. ## Supported Agents [#supported-agents] Skills work with any agent that supports the [Agent Skills](https://agentskills.io) specification, including Claude Code, Cursor, Windsurf, Copilot, and others. # Subscription Billing (/docs/subscription-billing) This guide walks through a complete subscription billing flow: defining plans, syncing customers, handling checkout, and managing upgrades, downgrades, and cancellations. Define plans Start by defining your plans. A typical setup has a free tier as the default and one or more paid tiers in the same group. ```ts title="products.ts" import { feature, plan } from "paykitjs"; const messages = feature({ id: "messages", type: "metered" }); const proModels = feature({ id: "pro_models", type: "boolean" }); export const free = plan({ id: "free", name: "Free", group: "base", default: true, includes: [messages({ limit: 100, reset: "month" })], }); export const pro = plan({ id: "pro", name: "Pro", group: "base", price: { amount: 19, interval: "month" }, includes: [ messages({ limit: 2_000, reset: "month" }), proModels(), ], }); ``` Pass your products to `createPayKit`: ```ts title="paykit.ts" import { free, pro } from "./products"; export const paykit = createPayKit({ // ... products: [free, pro], }); ``` For the full reference on plan groups, feature types, and pricing options, see [Plans & Features](/docs/plans-and-features). Create a customer Before a customer can subscribe, they need to exist in PayKit. Call `upsertCustomer` when the user signs up, or before any purchase flow: ```ts await paykit.upsertCustomer({ id: "user_123", email: "jane@example.com", name: "Jane Doe", }); ``` Set up `identify` on your PayKit instance. The client SDK calls it automatically on every request, so customers are created on first use: ```ts title="paykit.ts" export const paykit = createPayKit({ // ... identify: async ({ headers }) => { const session = await auth.api.getSession({ headers }); if (!session) return null; return { customerId: session.user.id, email: session.user.email, name: session.user.name, }; }, }); ``` New customers are automatically on the default free plan. No subscription record is created until they explicitly subscribe to a paid plan. Subscribe to a paid plan Call `subscribe()` with the target plan ID. For paid plans without a saved payment method, it returns a `paymentUrl` for checkout. ```ts title="app/api/billing/subscribe/route.ts" const result = await paykit.subscribe({ customerId: "user_123", planId: "pro", successUrl: "https://myapp.com/billing/success", cancelUrl: "https://myapp.com/billing", }); if (result.paymentUrl) { return Response.redirect(result.paymentUrl); } ``` ```tsx title="components/upgrade-button.tsx" import { paykitClient } from "@/lib/paykit-client"; ``` Don't treat the subscription as active yet if `paymentUrl` is set. It activates after Stripe confirms payment via webhook. Handle the webhook After checkout, Stripe sends a webhook to PayKit's endpoint. PayKit verifies the signature, syncs the subscription locally, and fires a `customer.updated` event. This is fully automatic. You don't need to manually process Stripe events or update your database. By the time `customer.updated` fires, the customer's subscriptions and entitlements are already up to date. See [Webhook Events](/docs/webhook-events) for details on how PayKit processes and deduplicates incoming events. Check entitlements Use `check()` to gate features based on the customer's active plan. ```ts title="app/api/chat/route.ts" const { allowed } = await paykit.check({ customerId: userId, featureId: "messages", }); if (!allowed) { return Response.json({ error: "Usage limit reached" }, { status: 403 }); } // also check boolean features: const { allowed: canUseProModels } = await paykit.check({ customerId: userId, featureId: "pro_models", }); ``` For metered features, `check()` also returns a `balance` with `remaining`, `limit`, and `resetAt`. See [Entitlements](/docs/entitlements) for the full pattern including `report()`. Upgrade Upgrading moves the customer to a higher-priced plan in the same group. It takes effect immediately. ```ts await paykit.subscribe({ customerId: "user_123", planId: "pro", // moving up from free }); // subscription is active immediately ``` Any prorated amount is handled by Stripe based on your billing settings. Downgrade Downgrading moves the customer to a lower-priced plan. The current plan stays active until the end of the billing period, then the target plan activates automatically. ```ts await paykit.subscribe({ customerId: "user_123", planId: "free", // moving down from pro }); // customer stays on pro until period ends ``` You can also change the scheduled target before the period ends by calling `subscribe()` with a different lower-priced plan. The previous scheduled change is replaced. Cancel Cancellation works the same way as a downgrade: subscribe the customer to the default free plan. ```ts await paykit.subscribe({ customerId: "user_123", planId: "free", }); // paid plan stays active until period end, then customer moves to free ``` If you want to undo the cancellation before the period ends, subscribe them back to their current paid plan. PayKit clears the scheduled change and resumes the subscription. ```ts await paykit.subscribe({ customerId: "user_123", planId: "pro", // resume: clears the scheduled downgrade }); ``` Listen to changes Add `on` handlers to your PayKit instance to react to any billing change. `customer.updated` fires after every subscription or entitlement update, including webhook-driven changes. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... on: { "customer.updated": ({ payload }) => { console.log("billing changed for", payload.customerId); console.log("subscriptions:", payload.subscriptions); // sync to your own data layer, invalidate caches, etc. }, }, }); ``` The handler runs after PayKit has already applied the state change, so `payload.subscriptions` reflects the current state. # Subscriptions (/docs/subscriptions) `subscribe()` is the main lifecycle method in PayKit. It doesn't just start new subscriptions: it handles upgrades, downgrades, cancellations, and resumptions through the same unified call. ```ts const result = await paykit.subscribe({ customerId: "user_123", planId: "pro", successUrl: "https://myapp.com/billing/success", cancelUrl: "https://myapp.com/billing", }); if (result.paymentUrl) { // Redirect user to Stripe Checkout } ``` ```tsx const { paymentUrl } = await paykitClient.subscribe({ planId: "pro", successUrl: "/billing/success", cancelUrl: "/billing", }); if (paymentUrl) { window.location.href = paymentUrl; } ``` ## New subscriptions [#new-subscriptions] What happens when you call `subscribe()` for the first time depends on the plan and whether the customer already has a payment method on file. * **Free plan:** activates immediately. No Stripe call is made, no subscription record is created. The customer is treated as on the default plan. * **Paid plan with a payment method:** PayKit creates the subscription directly through Stripe. * **Paid plan without a payment method:** PayKit returns a `paymentUrl`. Redirect the user there to complete checkout. Once they do, Stripe sends a webhook and PayKit activates the subscription. ## Result shape [#result-shape] `subscribe()` returns `{ paymentUrl, invoice, requiredAction }`. * `paymentUrl`: set when the customer needs to go through a checkout flow. Redirect them there. * `invoice`: present when a charge was created immediately. * `requiredAction`: set when additional authentication (like 3D Secure) is required. If `paymentUrl` is set, don't assume the subscription is active yet. It becomes active after Stripe confirms payment via webhook. ## Upgrades [#upgrades] Moving to a higher-priced plan in the same group is an upgrade. PayKit switches the subscription immediately: the old plan ends and the new one starts. Any prorated amount is charged or credited according to your Stripe billing settings. ## Downgrades [#downgrades] Moving to a lower-priced plan is a downgrade. PayKit doesn't switch immediately. The current plan stays active until the end of the billing period, and the target plan is stored as a scheduled change. When the period ends, PayKit processes the webhook from Stripe and transitions the customer to the scheduled plan. ## Cancel to free [#cancel-to-free] Downgrading from a paid plan to the default free plan follows the same pattern as a downgrade. The paid plan stays active until the period ends, then the customer moves to free automatically. You don't need a separate `cancel()` method. Subscribing to the free plan is the cancellation path. ## Resume [#resume] If a customer has a pending downgrade or cancellation, subscribing to their current active plan clears the scheduled change and resumes the subscription as normal. ```ts // Customer is on "pro" with a pending downgrade to "free" await paykit.subscribe({ customerId: "user_123", planId: "pro" }); // Scheduled downgrade is cleared. Customer stays on "pro". ``` ## No-op [#no-op] If the customer is already on the target plan with no pending changes, `subscribe()` is a no-op. It returns the same result shape but takes no action. ## Change scheduled target [#change-scheduled-target] If a downgrade is already scheduled and you call `subscribe()` with a different lower-priced plan, the scheduled target is replaced. Only one downgrade can be pending at a time per group. ```ts // Customer is on "ultra" with a pending downgrade to "free" await paykit.subscribe({ customerId: "user_123", planId: "pro" }); // Scheduled target changes from "free" to "pro". ``` ## Behavior summary [#behavior-summary] | Scenario | Behavior | | --------------------------------------------- | --------------------------------- | | New subscription (free) | Activates immediately | | New subscription (paid, has payment method) | Creates subscription directly | | New subscription (paid, no payment method) | Returns `paymentUrl` for checkout | | Upgrade (higher price, same group) | Switches immediately | | Downgrade (lower price, same group) | Scheduled for period end | | Cancel to default free plan | Scheduled for period end | | Re-subscribe to current plan (pending cancel) | Resumes, clears cancellation | | Re-subscribe to current plan (no changes) | No-op | ## Input fields [#input-fields] | Field | Required | Description | | --------------- | ----------- | ------------------------------------------------------------------------------- | | `planId` | Yes | The target plan ID. Must be a valid plan from your config. | | `customerId` | Server only | The customer to subscribe. Not needed on the client (resolved from `identify`). | | `successUrl` | Recommended | Where to redirect after successful checkout. | | `cancelUrl` | Recommended | Where to redirect if the customer cancels checkout. | | `forceCheckout` | No | Forces a checkout flow even if the customer already has a payment method. | ## Return URL security [#return-url-security] Browser calls may use paths such as `/billing/success`. PayKit resolves them against the verified browser origin, not the API server origin. Same-origin applications need no configuration. If the frontend and PayKit API have different origins, add the frontend to `trustedOrigins` in `createPayKit`. Absolute browser return URLs must use HTTP or HTTPS and match either the verified browser origin or `trustedOrigins`. Direct server API calls may use any absolute HTTP or HTTPS return URL because they run as trusted application code. # TypeScript (/docs/typescript) PayKit is built TypeScript-first. Plan IDs, feature IDs, and method inputs are inferred from your configuration, so you don't need any manual type definitions. ## Inferred plan and feature IDs [#inferred-plan-and-feature-ids] When you pass products to `createPayKit`, all methods narrow their ID parameters to only accept valid values. Typos become compile errors. ```ts title="paykit.ts" // Types are inferred from your products export const paykit = createPayKit({ products: [free, pro, ultra], // ... }); // planId only accepts "free" | "pro" | "ultra" await paykit.subscribe({ customerId: "user_123", planId: "pro" }); // featureId only accepts "messages" | "pro_models" | "priority_support" await paykit.check({ customerId: "user_123", featureId: "messages" }); ``` Passing an invalid ID fails at compile time: ```ts // Type error: "typo" is not assignable to "free" | "pro" | "ultra" await paykit.subscribe({ customerId: "user_123", planId: "typo" }); ``` Your editor's autocomplete also knows every valid plan and feature ID across the whole app. ## The $infer helper [#the-infer-helper] `paykit.$infer` exposes the inferred union types so you can use them elsewhere in your app without re-declaring them. ```ts type PlanId = typeof paykit.$infer.planId; // => "free" | "pro" | "ultra" type FeatureId = typeof paykit.$infer.featureId; // => "messages" | "pro_models" | "priority_support" ``` Use these anywhere you need to accept or return a plan or feature ID: form props, route params, database columns, and so on. ## Client type safety [#client-type-safety] The PayKit client SDK inherits the server instance's types via `createPayKitClient()`. No server code is bundled; only the type is imported. ```ts title="src/lib/paykit-client.ts" // Client inherits server types import type { paykit } from "@/server/paykit"; const client = createPayKitClient(); await client.subscribe({ planId: "pro" }); // ✓ type-safe ``` Plan IDs are validated on the client too. Passing an invalid `planId` is a compile error regardless of where you call it. ## Product arrays [#product-arrays] Pass products to `createPayKit` as an array of values returned by `plan(...)`. ```ts import { free, pro, ultra } from "./products"; export const paykit = createPayKit({ products: [free, pro, ultra], // ... }); ``` This preserves full plan and feature ID inference. # Webhook Events (/docs/webhook-events) PayKit handles Stripe webhooks automatically. It verifies signatures, normalizes events, syncs local billing state, and then fires your application handlers. ## How it works [#how-it-works] When your provider sends a webhook, PayKit: 1. Verifies the signature using your provider secret 2. Normalizes the raw provider event into an internal event type 3. Applies state changes to your database (subscription status, invoice records, payment method updates) 4. Fires your `on` handlers with the normalized payload You don't touch raw Stripe events. PayKit translates them and keeps your local state in sync before your handler runs. ## App-level events [#app-level-events] Pass an `on` option to `createPayKit` to register handlers for PayKit events. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... on: { "customer.updated": ({ payload }) => { console.log("Billing changed for", payload.customerId); console.log("Subscriptions:", payload.subscriptions); }, }, }); ``` ## customer.updated [#customerupdated] Fires after any subscription or entitlement change. Use it to sync billing state into your own data layer, invalidate caches, or trigger downstream logic. Payload shape: | Field | Type | Description | | --------------- | ---------------- | ------------------------------------ | | `customerId` | `string` | Your internal customer identifier | | `subscriptions` | `Subscription[]` | The customer's updated subscriptions | ## Wildcard handler [#wildcard-handler] `"*"` catches all PayKit events. Useful for logging or debugging. ```ts title="paykit.ts" export const paykit = createPayKit({ // ... on: { "*": ({ event }) => { console.log(event.name, event.payload); }, }, }); ``` ## Webhook route [#webhook-route] The webhook endpoint is exposed by paykit handler at `/paykit/webhook` by default. See the [installation page](/docs/installation) for details on mounting it. ## Local development [#local-development] Use `paykitjs listen` to receive Stripe webhooks while your app runs locally: ```bash pnpm paykitjs listen ``` Or run with the web server together: ```bash pnpm paykitjs listen -- pnpm dev ``` ## Idempotency [#idempotency] PayKit records each incoming webhook event and processes it idempotently. If Stripe sends the same event more than once, PayKit skips the duplicate. Your `on` handlers won't fire twice for the same event. You don't need to manually handle Stripe webhook events. PayKit processes them internally and keeps your local billing state in sync.