Multi-provider billing and subscription system for this Next.js application. Covers Gateway Factory pattern, Stripe integration, Polar.sh integration, Better Auth plugin, plans configuration, checkout flow, customer portal, webhooks, and usage tracking. Use this skill when implementing billing features or working with subscription management.
Multi-provider billing system with Gateway Factory pattern. Supports Stripe, Polar.sh, and future providers through a unified interface.
BILLING ARCHITECTURE:
Configuration Layer:
contents/themes/{theme}/config/billing.config.ts
├── provider: 'stripe' | 'polar' # (paddle, lemonsqueezy, mercadopago: type defined, not yet implemented)
├── currency: 'usd' | 'eur' | ...
├── defaultPlan: 'free'
├── features: { featureSlug: FeatureDefinition }
├── limits: { limitSlug: LimitDefinition }
├── plans: PlanDefinition[]
└── actionMappings: ActionMappings
Core Library:
core/lib/billing/
├── config-types.ts # BillingConfig, PlanDefinition interfaces
├── types.ts # PlanType, SubscriptionStatus, PaymentProvider
├── schema.ts # Zod validation schemas
├── gateways/
│ ├── types.ts # Provider-agnostic result types
│ ├── interface.ts # BillingGateway interface (contract)
│ ├── factory.ts # getBillingGateway() factory
│ ├── stripe.ts # StripeGateway implements BillingGateway
│ └── polar.ts # PolarGateway implements BillingGateway
├── queries.ts # Database queries
├── enforcement.ts # Limit/feature enforcement
├── helpers.ts # Utility functions
└── jobs.ts # Background jobs
Services Layer:
core/lib/services/
├── subscription.service.ts # Subscription CRUD (uses factory)
├── plan.service.ts # Plan management (getPriceId generic)
└── usage.service.ts # Usage tracking
API Endpoints:
app/api/v1/billing/
├── checkout/route.ts # Create checkout session (via factory)
├── portal/route.ts # Customer portal access (via factory)
├── plans/route.ts # List available plans
├── cancel/route.ts # Cancel subscription (via factory)
├── change-plan/route.ts # Upgrade/downgrade
├── check-action/route.ts # Permission check
├── webhooks/stripe/route.ts # Stripe webhooks (provider-specific)
└── webhooks/polar/route.ts # Polar webhooks (provider-specific)
Key Principle: Consumers never import from a specific provider. They use getBillingGateway() which returns the correct implementation based on billing.config.ts.
// core/lib/billing/gateways/interface.ts
export interface BillingGateway {
// Checkout
createCheckoutSession(params: CreateCheckoutParams): Promise<CheckoutSessionResult>
createPortalSession(params: CreatePortalParams): Promise<PortalSessionResult>
// Customers
getCustomer(customerId: string): Promise<CustomerResult>
createCustomer(params: CreateCustomerParams): Promise<CustomerResult>
// Subscription Management
updateSubscriptionPlan(params: UpdateSubscriptionParams): Promise<SubscriptionResult>
cancelSubscriptionAtPeriodEnd(subscriptionId: string): Promise<SubscriptionResult>
cancelSubscriptionImmediately(subscriptionId: string): Promise<SubscriptionResult>
reactivateSubscription(subscriptionId: string): Promise<SubscriptionResult>
// Webhooks (Stripe passes string signature, Polar passes headers Record)
verifyWebhookSignature(payload: string | Buffer, signatureOrHeaders: string | Record<string, string>): WebhookEventResult
// Dashboard & Metadata
getProviderName(): string
getSubscriptionDashboardUrl(externalSubscriptionId: string | null | undefined): string | null
getResourceHintDomains(): { preconnect: string[]; dnsPrefetch: string[] }
}
// core/lib/billing/gateways/types.ts
// Return types - NO Stripe.* or Polar.* imports
export interface CheckoutSessionResult {
id: string
url: string | null
}
export interface PortalSessionResult {
url: string
}
export interface SubscriptionResult {
id: string
status: string
cancelAtPeriodEnd: boolean
}
export interface CustomerResult {
id: string
email: string | null
name: string | null
}
export interface WebhookEventResult {
id: string
type: string
data: Record<string, unknown>
}
// CORRECT: Use factory everywhere
import { getBillingGateway } from '@nextsparkjs/core/lib/billing/gateways/factory'
const session = await getBillingGateway().createCheckoutSession(params)
const portal = await getBillingGateway().createPortalSession(params)
await getBillingGateway().cancelSubscriptionAtPeriodEnd(subId)
// Provider metadata
const name = getBillingGateway().getProviderName() // "Stripe" or "Polar"
const url = getBillingGateway().getSubscriptionDashboardUrl(id) // Dashboard URL or null
// Resource hints (used in layout.tsx automatically)
import { getBillingResourceHints } from '@nextsparkjs/core/lib/billing/gateways/factory'
const { preconnect, dnsPrefetch } = getBillingResourceHints()
// WRONG: Import from specific provider
import { createCheckoutSession } from '.../gateways/stripe' // DEPRECATED
Plans use providerPriceIds for price configuration (works with any provider):
// PlanDefinition in config-types.ts
{
slug: 'pro',
providerPriceIds: {
monthly: 'price_xxx_monthly',
yearly: 'price_xxx_yearly',
},
}
// PlanService.getPriceId() reads from providerPriceIds
const priceId = PlanService.getPriceId('pro', 'monthly')
# Required
pnpm add stripe
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
// core/lib/billing/gateways/stripe.ts
export class StripeGateway implements BillingGateway {
async createCheckoutSession(params) {
const session = await getStripe().checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: params.successUrl,
cancel_url: params.cancelUrl,
metadata: { teamId, planSlug, billingPeriod },
})
return { id: session.id, url: session.url } // Provider-agnostic
}
async createPortalSession(params) {
const session = await getStripe().billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
})
return { url: session.url }
}
verifyWebhookSignature(payload, signature) {
const event = getStripe().webhooks.constructEvent(payload, signature, secret)
return { id: event.id, type: event.type, data: event.data }
}
// ... other methods
}
| Event | Action |
|---|---|
checkout.session.completed | Create/update subscription with Stripe IDs |
invoice.paid | Update period dates, sync invoice |
invoice.payment_failed | Mark subscription as past_due |
customer.subscription.updated | Sync status and plan changes |
customer.subscription.deleted | Mark subscription as canceled |
// app/api/v1/billing/webhooks/stripe/route.ts
// NOTE: Webhook routes stay provider-specific by design.
// They need raw provider types for proper type narrowing.
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: NextRequest) {
const payload = await request.text()
const signature = request.headers.get('stripe-signature')!
const event = stripe.webhooks.constructEvent(
payload, signature, process.env.STRIPE_WEBHOOK_SECRET!
)
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object as Stripe.Checkout.Session
// Handle with full Stripe types
break
}
}
# Core SDK
pnpm add @polar-sh/sdk
# Next.js adapter (optional - pre-built route handlers)
pnpm add @polar-sh/nextjs
# Better Auth plugin (optional - auth-integrated billing)
pnpm add @polar-sh/better-auth
POLAR_ACCESS_TOKEN=pat_... # Organization Access Token
POLAR_WEBHOOK_SECRET=whsec_...
POLAR_SERVER=sandbox # 'sandbox' or 'production'
This is the primary integration path. Consistent with Stripe, goes through getBillingGateway().
// core/lib/billing/gateways/polar.ts
import { Polar } from '@polar-sh/sdk'
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'
import type { BillingGateway } from './interface'
let polarInstance: Polar | null = null
function getPolar(): Polar {
if (!polarInstance) {
polarInstance = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: (process.env.POLAR_SERVER as 'sandbox' | 'production') || 'production',
})
}
return polarInstance
}
export class PolarGateway implements BillingGateway {
async createCheckoutSession(params) {
const priceId = getPriceIdFromRegistry(params.planSlug, params.billingPeriod)
const result = await getPolar().checkouts.create({
productPriceId: priceId, // Polar uses productPriceId
successUrl: params.successUrl,
returnUrl: params.cancelUrl, // Polar calls it returnUrl
customerEmail: params.customerEmail,
metadata: { teamId: params.teamId, planSlug: params.planSlug },
})
return { id: result.id, url: result.url }
}
async createPortalSession(params) {
const result = await getPolar().customerSessions.create({
customerId: params.customerId,
returnUrl: params.returnUrl,
})
return { url: result.customerPortalUrl } // Different field name
}
async getCustomer(customerId) {
const customer = await getPolar().customers.get({ id: customerId })
return { id: customer.id, email: customer.email, name: customer.name }
}
async createCustomer(params) {
const customer = await getPolar().customers.create({
email: params.email,
name: params.name,
externalId: params.metadata?.userId, // Map to app userId
})
return { id: customer.id, email: customer.email, name: customer.name }
}
async updateSubscriptionPlan(params) {
const result = await getPolar().subscriptions.update({
id: params.subscriptionId,
subscriptionUpdate: { productPriceId: params.newPriceId },
})
return {
id: result.id,
status: result.status,
cancelAtPeriodEnd: result.cancelAtPeriodEnd,
}
}
async cancelSubscriptionAtPeriodEnd(subscriptionId) {
// Polar: customer portal cancel = cancel at period end
const result = await getPolar().subscriptions.update({
id: subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: true },
})
return { id: result.id, status: result.status, cancelAtPeriodEnd: true }
}
async cancelSubscriptionImmediately(subscriptionId) {
// Polar: revoke = immediate cancel
const result = await getPolar().subscriptions.revoke({ id: subscriptionId })
return { id: result.id, status: 'canceled', cancelAtPeriodEnd: false }
}
async reactivateSubscription(subscriptionId) {
const result = await getPolar().subscriptions.update({
id: subscriptionId,
subscriptionUpdate: { cancelAtPeriodEnd: false },
})
return { id: result.id, status: result.status, cancelAtPeriodEnd: false }
}
verifyWebhookSignature(payload, signature) {
// Polar validates against headers object, not a single signature string
// The signature param here carries the headers as JSON for compatibility
const headers = JSON.parse(signature as string)
const event = validateEvent(payload, headers, process.env.POLAR_WEBHOOK_SECRET!)
return { id: event.data.id, type: event.type, data: event.data as any }
}
}
| Event | Action |
|---|---|
checkout.created | Checkout started |
checkout.updated | Checkout updated |
order.created | Order placed |
order.paid | Payment confirmed - create/update subscription |
order.refunded | Refund processed |
subscription.created | New subscription |
subscription.updated | Subscription changed |
subscription.active | Subscription activated |
subscription.canceled | Subscription canceled |
subscription.revoked | Subscription immediately revoked |
subscription.uncanceled | Cancellation reversed |
customer.created | New customer |
customer.state_changed | Customer state updated |
// app/api/v1/billing/webhooks/polar/route.ts
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'
export async function POST(request: NextRequest) {
const payload = await request.text()
const headers = Object.fromEntries(request.headers.entries())
try {
const event = validateEvent(payload, headers, process.env.POLAR_WEBHOOK_SECRET!)
switch (event.type) {
case 'order.paid':
await handleOrderPaid(event.data)
break
case 'subscription.active':
await handleSubscriptionActive(event.data)
break
case 'subscription.canceled':
await handleSubscriptionCanceled(event.data)
break
case 'subscription.revoked':
await handleSubscriptionRevoked(event.data)
break
case 'subscription.uncanceled':
await handleSubscriptionUncanceled(event.data)
break
}
return NextResponse.json({ received: true })
} catch (error) {
if (error instanceof WebhookVerificationError) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 403 })
}
throw error
}
}
Pre-built route handlers for simple setups:
// app/api/polar/checkout/route.ts
import { Checkout } from '@polar-sh/nextjs'
export const GET = Checkout({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
successUrl: process.env.NEXT_PUBLIC_APP_URL + '/dashboard/settings/billing?success=true',
server: 'sandbox',
})
// app/api/polar/portal/route.ts
import { CustomerPortal } from '@polar-sh/nextjs'
export const GET = CustomerPortal({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
getCustomerId: (req) => getCustomerIdFromSession(req),
returnUrl: process.env.NEXT_PUBLIC_APP_URL + '/dashboard/settings/billing',
})
// app/api/polar/webhooks/route.ts
import { Webhooks } from '@polar-sh/nextjs'
export const POST = Webhooks({
webhookSecret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: async (payload) => { /* sync subscription */ },
onSubscriptionActive: async (payload) => { /* activate */ },
onSubscriptionCanceled: async (payload) => { /* cancel */ },
onSubscriptionRevoked: async (payload) => { /* revoke */ },
})
For projects using Better Auth, Polar provides a first-party plugin that auto-creates customers on signup and provides client-side billing methods.
// lib/auth.ts (server)
import { betterAuth } from 'better-auth'
import { polar, checkout, portal, usage, webhooks } from '@polar-sh/better-auth'
import { Polar } from '@polar-sh/sdk'
const polarClient = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: 'sandbox',
})
export const auth = betterAuth({
plugins: [
polar({
client: polarClient,
createCustomerOnSignUp: true, // Auto-create Polar customer
use: [
checkout({
products: [
{ productId: 'prod_xxx', slug: 'pro' },
{ productId: 'prod_yyy', slug: 'enterprise' },
],
successUrl: '/dashboard/settings/billing?success=true',
authenticatedUsersOnly: true,
}),
portal(),
usage(),
webhooks({
secret: process.env.POLAR_WEBHOOK_SECRET!,
onOrderPaid: (payload) => { /* handle */ },
onCustomerStateChanged: (payload) => { /* handle */ },
}),
],
}),
],
})
// lib/auth-client.ts (client)
import { createAuthClient } from 'better-auth/react'
import { polarClient } from '@polar-sh/better-auth/client'
export const authClient = createAuthClient({
plugins: [polarClient()],
})
// Usage in components:
await authClient.checkout({ products: ['prod_xxx'], slug: 'pro' })
await authClient.customer.portal()
const { data: state } = await authClient.customer.state()
const { data: subs } = await authClient.customer.subscriptions.list({ query: { active: true } })
// Usage-based billing
await authClient.usage.ingest({ event: 'api-call', metadata: { endpoint: '/api/v1/products' } })
const { data: meters } = await authClient.usage.meters.list()
| Path A: PolarGateway | Path B: Better Auth Plugin | |
|---|---|---|
| Use when | Multi-provider support needed | Polar-only project |
| Architecture | Goes through gateway factory | Bypasses factory, auth-integrated |
| Customer creation | Manual via gateway | Automatic on signup |
| Client-side API | Custom hooks + API routes | authClient.checkout() etc. |
| Usage metering | UsageService (our own) | authClient.usage.ingest() |
| Webhook handling | Custom route | Better Auth plugin handles it |
| Recommended for | NextSpark core (multi-provider) | Theme-specific Polar-only apps |
For NextSpark core: Use Path A (PolarGateway) to maintain provider abstraction. Optionally layer Path B for customer auto-creation on signup.
| Operation | Stripe | Polar |
|---|---|---|
| SDK init | new Stripe(secretKey) | new Polar({ accessToken }) |
| Checkout | stripe.checkout.sessions.create() | polar.checkouts.create() |
| Portal | stripe.billingPortal.sessions.create() | polar.customerSessions.create() |
| Get customer | stripe.customers.retrieve(id) | polar.customers.get({ id }) |
| Create customer | stripe.customers.create(params) | polar.customers.create({ externalId }) |
| Update sub | stripe.subscriptions.update() (needs item ID) | polar.subscriptions.update({ productPriceId }) |
| Soft cancel | subscriptions.update({ cancel_at_period_end }) | Customer portal cancel / update |
| Hard cancel | subscriptions.cancel() | polar.subscriptions.revoke() |
| Verify webhook | constructEvent(body, signature, secret) | validateEvent(body, headers, secret) |
| Env key | STRIPE_SECRET_KEY | POLAR_ACCESS_TOKEN |
| Env webhook | STRIPE_WEBHOOK_SECRET | POLAR_WEBHOOK_SECRET |
| Price ref | priceId (string) | productPriceId (string) |
stripe-signature header. Polar validates against ALL request headers.cancel_at_period_end flag + cancel(). Polar uses revoke() for immediate, update for period-end.externalId to map customers to your app's userId. Stripe uses metadata.productPriceId directly.The billing system uses a three-layer model (provider-agnostic):
RESULT = Permission (RBAC) AND Feature (Plan) AND Quota (Limits)
actionMappings: {
permissions: {
'team.billing.manage': 'team.billing.manage',
'team.settings.edit': 'team.settings.edit',
}
}