Subscription Billing
Implement a complete subscription billing flow with plans, checkout, and plan changes.
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.
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 plans to createPayKit:
import { free, pro } from "./plans";
export const paykit = createPayKit({
// ...
plans: [free, pro],
});For the full reference on plan groups, feature types, and pricing options, see Plans & 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:
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:
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.
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);
}import { paykitClient } from "@/lib/paykit-client";
<Button
onClick={async () => {
const { paymentUrl } = await paykitClient.subscribe({
planId: "pro",
successUrl: "/billing/success",
cancelUrl: "/billing",
});
if (paymentUrl) {
window.location.href = paymentUrl;
}
}}
>
Upgrade to Pro
</Button>Don't treat the subscription as active yet if paymentUrl is set. It activates after the provider confirms payment via webhook.
Handle the webhook
After checkout, your payment provider 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 for details on how PayKit processes and deduplicates incoming events.
Check entitlements
Use check() to gate features based on the customer's active plan.
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 for the full pattern including report().
Upgrade
Upgrading moves the customer to a higher-priced plan in the same group. It takes effect immediately.
await paykit.subscribe({
customerId: "user_123",
planId: "pro", // moving up from free
});
// subscription is active immediatelyAny prorated amount is handled by the provider 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.
await paykit.subscribe({
customerId: "user_123",
planId: "free", // moving down from pro
});
// customer stays on pro until period endsYou 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.
await paykit.subscribe({
customerId: "user_123",
planId: "free",
});
// paid plan stays active until period end, then customer moves to freeIf 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.
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.
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.