Billing & Subscriptions
Multi-provider billing with Creem, Stripe, LemonSqueezy, and Airwallex - subscriptions, one-time payments, and webhooks
Billing & Subscriptions
ProductReady uses @billing - a multi-provider billing abstraction that supports Creem (default), Stripe, LemonSqueezy, and Airwallex with a unified API.
Default Provider: Creem - ProductReady uses Creem as the default billing provider. Creem is a Merchant of Record (MoR) that handles taxes, compliance, and global payments for you.
Quick Overview
Supported providers:
- ✅ Creem - Merchant of record, global payments (default)
- ✅ Stripe - Industry standard (recommended for US/EU)
- ✅ LemonSqueezy - Merchant of record (handles taxes/compliance)
- ✅ Airwallex - Great for Asia-Pacific region
Key features:
- 🔒 Type-safe - Full TypeScript support with Zod validation
- 🎯 Provider abstraction - Consistent interface across all providers
- 🚀 Lazy creation - Products/prices created on-demand
- 📦 Tree-shakeable - Import only what you need
- 🔄 Easy migration - Switch providers without rewriting code
What you can do:
- ✅ One-time payments
- ✅ Recurring subscriptions (monthly/yearly)
- ✅ Usage-based billing
- ✅ Customer management
- ✅ Webhook handling
- ✅ Subscription lifecycle (cancel, update, reactivate)
Installation
pnpm add billingNo additional dependencies needed - provider SDKs are bundled.
Quick Start
Step 1: Choose Provider
Best for: Global businesses, handles taxes/compliance automatically
Environment variables:
CREEM_API_KEY=creem_test_... # or creem_live_... for production
CREEM_WEBHOOK_SECRET=your-webhook-secretGet keys:
- Sign up at creem.io
- Dashboard → API Keys
- For webhooks: Settings → Webhooks → Add endpoint
Auto-detection: The API URL is automatically detected based on your key prefix:
creem_test_*→ Uses test API (https://test-api.creem.io)creem_live_*→ Uses production API (https://api.creem.io)
Best for: US/EU businesses, maximum flexibility
Environment variables:
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Get keys:
- Sign up at stripe.com
- Dashboard → Developers → API keys
- For webhooks: Developers → Webhooks → Add endpoint
Best for: Solo founders (handles VAT/taxes for you)
Environment variables:
LEMONSQUEEZY_API_KEY=your-api-key
LEMONSQUEEZY_STORE_ID=your-store-id
LEMONSQUEEZY_WEBHOOK_SECRET=your-webhook-secretGet keys:
- Sign up at lemonsqueezy.com
- Settings → API → Create API key
- Get Store ID from Settings → Stores
Best for: APAC businesses
Environment variables:
AIRWALLEX_API_KEY=your-api-key
AIRWALLEX_CLIENT_ID=your-client-id
AIRWALLEX_ACCOUNT_ID=your-account-id
AIRWALLEX_WEBHOOK_SECRET=your-webhook-secretStep 2: Create Provider Instance
ProductReady uses Creem by default. The provider is configured in src/lib/billing/provider.ts:
// src/lib/billing/provider.ts (default configuration)
import { createBillingProvider } from 'billing';
export function getBillingProvider() {
const apiKey = process.env.CREEM_API_KEY;
if (!apiKey) {
throw new Error("CREEM_API_KEY environment variable is required");
}
return createBillingProvider({
provider: 'creem',
apiKey,
webhookSecret: process.env.CREEM_WEBHOOK_SECRET,
options: {
baseUrl: apiKey.startsWith('creem_test_')
? 'https://test-api.creem.io'
: 'https://api.creem.io',
},
});
}To switch providers, modify the configuration:
// Example: Switch to Stripe
export const billingProvider = createBillingProvider({
provider: 'stripe',
apiKey: process.env.STRIPE_SECRET_KEY!,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
});Step 3: Create Checkout
import { billingProvider } from '~/lib/billing';
// Create checkout session
const checkout = await billingProvider.createCheckout({
userId: user.id,
productId: 'prod_pro_plan',
priceId: 'price_monthly_29',
successUrl: 'https://productready.com/success',
cancelUrl: 'https://productready.com/pricing',
customerEmail: user.email,
});
// Redirect user to checkout URL
return { checkoutUrl: checkout.url };Core Concepts
Products & Prices
Products represent what you're selling (e.g., "Pro Plan") Prices define how much it costs (e.g., "$29/month")
// Get or create product (creates if doesn't exist)
const product = await billingProvider.getOrCreateProduct({
name: 'Pro Plan',
description: 'Professional features for teams',
});
// Get or create price (creates if doesn't exist)
const price = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents: 2900, // $29.00
currency: 'usd',
interval: 'monthly', // or 'yearly'
});Lazy Creation Pattern
Products/prices are created on-demand - no need to manually create them in provider dashboards:
// First call: Creates product + price in Stripe/LemonSqueezy
const product = await billingProvider.getOrCreateProduct({
name: 'Pro Plan',
description: 'Professional features',
});
const price = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents: 2900,
currency: 'usd',
interval: 'monthly',
});
// Subsequent calls: Returns existing product/price
// No duplicate creation!Creating Subscriptions
tRPC Integration
// src/server/routers/billing.ts
import { createTRPCRouter, protectedProcedure } from '../trpc';
import { billingProvider } from '~/lib/billing';
import { z } from 'zod';
export const billingRouter = createTRPCRouter({
createCheckout: protectedProcedure
.input(z.object({
planId: z.enum(['starter', 'pro', 'enterprise']),
interval: z.enum(['monthly', 'yearly']),
}))
.mutation(async ({ ctx, input }) => {
// Map plan to pricing
const pricing = {
starter: { monthly: 900, yearly: 9000 },
pro: { monthly: 2900, yearly: 29000 },
enterprise: { monthly: 9900, yearly: 99000 },
};
const amountCents = pricing[input.planId][input.interval];
// Get or create product
const product = await billingProvider.getOrCreateProduct({
name: `${input.planId} Plan`,
description: `ProductReady ${input.planId} subscription`,
});
// Get or create price
const price = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents,
currency: 'usd',
interval: input.interval === 'monthly' ? 'monthly' : 'yearly',
});
// Create checkout session
const checkout = await billingProvider.createCheckout({
userId: ctx.user.id,
productId: product.id,
priceId: price.id,
successUrl: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?success=1`,
cancelUrl: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
customerEmail: ctx.user.email,
});
return { checkoutUrl: checkout.url };
}),
});Frontend Usage
'use client';
import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
export function PricingCard({ plan }: { plan: 'starter' | 'pro' | 'enterprise' }) {
const createCheckout = trpc.billing.createCheckout.useMutation({
onSuccess: (data) => {
// Redirect to checkout
window.location.href = data.checkoutUrl;
},
});
return (
<div>
<h3>{plan} Plan</h3>
<Button
onClick={() => createCheckout.mutate({ planId: plan, interval: 'monthly' })}
disabled={createCheckout.isPending}
>
{createCheckout.isPending ? 'Loading...' : 'Subscribe'}
</Button>
</div>
);
}Webhook Handling
Webhooks notify your app about payment events (subscription created, payment failed, etc.)
Setup Webhook Endpoint
// src/app/api/webhooks/billing/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { billingProvider } from '~/lib/billing';
import { db } from '~/db';
import { users } from '~/db/schema';
import { eq } from 'drizzle-orm';
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('stripe-signature') || // Stripe
request.headers.get('x-signature') || // LemonSqueezy
request.headers.get('x-airwallex-signature') || ''; // Airwallex
// Verify webhook signature
const event = await billingProvider.verifyWebhook(body, signature);
// Handle different event types
switch (event.type) {
case 'subscription.created':
case 'subscription.updated': {
// Update user subscription status
await db
.update(users)
.set({
subscriptionId: event.data.id,
subscriptionStatus: event.data.status,
planId: event.data.planId,
})
.where(eq(users.id, event.data.userId));
break;
}
case 'subscription.cancelled': {
// Mark subscription as cancelled
await db
.update(users)
.set({
subscriptionStatus: 'cancelled',
subscriptionEndsAt: event.data.endsAt,
})
.where(eq(users.id, event.data.userId));
break;
}
case 'payment.succeeded': {
// Log successful payment
console.log('Payment succeeded:', event.data);
break;
}
case 'payment.failed': {
// Notify user about failed payment
console.error('Payment failed:', event.data);
// TODO: Send email notification
break;
}
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 400 });
}
}Register Webhook URL
- Go to Creem Dashboard → Settings → Webhooks
- Click "Add Webhook"
- Enter URL:
https://yourapp.com/api/billing/webhook - Select events:
subscription.createdsubscription.updatedsubscription.activesubscription.canceledsubscription.expiredcheckout.completed
- Copy webhook secret to
.envasCREEM_WEBHOOK_SECRET
- Go to Stripe Dashboard → Developers → Webhooks
- Click "Add endpoint"
- Enter URL:
https://yourapp.com/api/webhooks/billing - Select events:
customer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_succeededinvoice.payment_failed
- Copy webhook secret to
.env
- Go to Settings → Webhooks
- Create webhook
- URL:
https://yourapp.com/api/webhooks/billing - Copy signing secret to
.env
- Go to Settings → Webhooks
- Add endpoint
- URL:
https://yourapp.com/api/webhooks/billing - Copy webhook secret to
.env
Test Webhooks Locally
Use Stripe CLI for local testing:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to local server
stripe listen --forward-to localhost:3000/api/webhooks/billing
# Trigger test events
stripe trigger customer.subscription.createdSubscription Management
Get User Subscription
// src/server/routers/billing.ts
export const billingRouter = createTRPCRouter({
getSubscription: protectedProcedure
.query(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
return null;
}
// Fetch subscription from provider
const subscription = await billingProvider.getSubscription(
user.subscriptionId
);
return subscription;
}),
});Cancel Subscription
export const billingRouter = createTRPCRouter({
cancelSubscription: protectedProcedure
.mutation(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
throw new Error('No active subscription');
}
// Cancel subscription (keep until end of billing period)
const subscription = await billingProvider.cancelSubscription({
subscriptionId: user.subscriptionId,
immediately: false, // false = cancel at end of period
});
// Update database
await db
.update(users)
.set({
subscriptionStatus: 'cancelled',
subscriptionEndsAt: subscription.currentPeriodEnd,
})
.where(eq(users.id, ctx.user.id));
return { success: true };
}),
});Update Subscription (Change Plan)
export const billingRouter = createTRPCRouter({
updateSubscription: protectedProcedure
.input(z.object({
newPlanId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
throw new Error('No active subscription');
}
// Get new price
const newPrice = await billingProvider.getOrCreatePrice({
productId: input.newPlanId,
amountCents: 2900, // Fetch from your pricing config
currency: 'usd',
interval: 'monthly',
});
// Update subscription
const subscription = await billingProvider.updateSubscription({
subscriptionId: user.subscriptionId,
priceId: newPrice.providerPriceId!,
});
return { success: true, subscription };
}),
});Reactivate Cancelled Subscription
export const billingRouter = createTRPCRouter({
reactivateSubscription: protectedProcedure
.mutation(async ({ ctx }) => {
const user = await db.query.users.findFirst({
where: eq(users.id, ctx.user.id),
});
if (!user?.subscriptionId) {
throw new Error('No subscription to reactivate');
}
// Reactivate subscription (Stripe-specific)
const subscription = await billingProvider.updateSubscription({
subscriptionId: user.subscriptionId,
cancelAtPeriodEnd: false, // Prevent cancellation
});
await db
.update(users)
.set({
subscriptionStatus: 'active',
subscriptionEndsAt: null,
})
.where(eq(users.id, ctx.user.id));
return { success: true };
}),
});Customer Portal
Let users manage their subscriptions:
Create Portal Session
// src/server/routers/billing.ts
export const billingRouter = createTRPCRouter({
createPortalSession: protectedProcedure
.mutation(async ({ ctx }) => {
// Stripe example
const session = await stripe.billingPortal.sessions.create({
customer: ctx.user.stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing`,
});
return { url: session.url };
}),
});Frontend
'use client';
import { trpc } from '~/lib/trpc/client';
import { Button } from 'kui/button';
export function ManageSubscriptionButton() {
const createPortal = trpc.billing.createPortalSession.useMutation({
onSuccess: (data) => {
window.location.href = data.url;
},
});
return (
<Button onClick={() => createPortal.mutate()}>
Manage Subscription
</Button>
);
}One-Time Payments
For non-subscription purchases:
const checkout = await billingProvider.createCheckout({
userId: user.id,
productId: 'prod_ebook',
priceId: 'price_ebook_9',
mode: 'payment', // One-time payment (vs 'subscription')
successUrl: 'https://productready.com/download',
cancelUrl: 'https://productready.com/shop',
});Usage-Based Billing
Charge based on usage (API calls, storage, etc.):
// Report usage to provider
await billingProvider.reportUsage({
subscriptionItemId: 'si_xxx',
quantity: 100, // e.g., 100 API calls
timestamp: new Date(),
});Multi-Currency Support
// USD pricing
const usdPrice = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents: 2900,
currency: 'usd',
interval: 'monthly',
});
// EUR pricing
const eurPrice = await billingProvider.getOrCreatePrice({
productId: product.providerProductId!,
amountCents: 2700, // €27
currency: 'eur',
interval: 'monthly',
});Testing
Test Mode
All providers support test mode:
# Stripe test keys start with sk_test_
STRIPE_SECRET_KEY=sk_test_...
# LemonSqueezy sandbox mode
LEMONSQUEEZY_API_KEY=test_...Test Card Numbers
Stripe:
- Success:
4242 4242 4242 4242 - Decline:
4000 0000 0000 0002 - Requires auth:
4000 0027 6000 3184
LemonSqueezy:
- Use any email + card
4242 4242 4242 4242
Test Webhooks
# Stripe CLI
stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failedMigration Between Providers
Switch providers without rewriting code:
// src/lib/billing.ts
// Before: Stripe
const billingProvider = createBillingProvider({
provider: 'stripe',
apiKey: process.env.STRIPE_SECRET_KEY!,
});
// After: LemonSqueezy
const billingProvider = createBillingProvider({
provider: 'lemonsqueezy',
apiKey: process.env.LEMONSQUEEZY_API_KEY!,
storeId: process.env.LEMONSQUEEZY_STORE_ID!,
});All your tRPC endpoints and webhook handlers remain unchanged!
Database Schema
Add billing fields to your users table:
// src/db/schema/users.ts
import { pgTable, text, timestamp } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: text('id').primaryKey(),
email: text('email').notNull().unique(),
// Billing fields
stripeCustomerId: text('stripe_customer_id'),
subscriptionId: text('subscription_id'),
subscriptionStatus: text('subscription_status'), // active, cancelled, past_due
planId: text('plan_id'), // starter, pro, enterprise
subscriptionEndsAt: timestamp('subscription_ends_at'),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});Security Best Practices
✅ Do's
- Verify webhooks - Always validate webhook signatures
- Use HTTPS - Never send payment data over HTTP
- Store provider IDs - Save customer/subscription IDs in database
- Handle errors - Gracefully handle payment failures
- Test thoroughly - Test all payment flows in test mode
- Log events - Keep audit trail of billing events
❌ Don'ts
- Don't store card details - Let provider handle it
- Don't skip webhook validation - Security risk
- Don't trust client-side - Always verify on server
- Don't hardcode prices - Use provider API
- Don't ignore webhooks - They're critical for subscription updates
Troubleshooting
Webhook Not Received
- Check webhook URL is publicly accessible
- Verify webhook secret is correct
- Check provider dashboard for delivery logs
- Use ngrok for local testing:
ngrok http 3000
Payment Fails
- Check test card numbers are correct
- Verify API keys are valid (not expired)
- Check for rate limiting
- Review provider logs/dashboard
Subscription Not Updating
- Verify webhook handler is processing events
- Check database is being updated
- Review webhook event logs in provider dashboard
Next Steps
- Use Creem (default) - Already configured, just add your API key
- Set up webhooks - Configure webhook endpoints at
/api/billing/webhook - Create products - Products are created lazily on first checkout
- Build UI - Create pricing page and checkout flow
- Test thoroughly - Test all payment scenarios
ProductReady uses Creem by default - it handles global taxes and compliance automatically. If you need more control, switch to Stripe for US/EU markets. Use LemonSqueezy as another MoR alternative.