GameCraftGameCraft

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 billing

No 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-secret

Get keys:

  1. Sign up at creem.io
  2. Dashboard → API Keys
  3. 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:

  1. Sign up at stripe.com
  2. Dashboard → Developers → API keys
  3. 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-secret

Get keys:

  1. Sign up at lemonsqueezy.com
  2. Settings → API → Create API key
  3. 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-secret

Step 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

  1. Go to Creem Dashboard → Settings → Webhooks
  2. Click "Add Webhook"
  3. Enter URL: https://yourapp.com/api/billing/webhook
  4. Select events:
    • subscription.created
    • subscription.updated
    • subscription.active
    • subscription.canceled
    • subscription.expired
    • checkout.completed
  5. Copy webhook secret to .env as CREEM_WEBHOOK_SECRET
  1. Go to Stripe Dashboard → Developers → Webhooks
  2. Click "Add endpoint"
  3. Enter URL: https://yourapp.com/api/webhooks/billing
  4. Select events:
    • customer.subscription.created
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.payment_succeeded
    • invoice.payment_failed
  5. Copy webhook secret to .env
  1. Go to Settings → Webhooks
  2. Create webhook
  3. URL: https://yourapp.com/api/webhooks/billing
  4. Copy signing secret to .env
  1. Go to Settings → Webhooks
  2. Add endpoint
  3. URL: https://yourapp.com/api/webhooks/billing
  4. 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.created

Subscription 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_failed

Migration 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

  1. Check webhook URL is publicly accessible
  2. Verify webhook secret is correct
  3. Check provider dashboard for delivery logs
  4. Use ngrok for local testing: ngrok http 3000

Payment Fails

  1. Check test card numbers are correct
  2. Verify API keys are valid (not expired)
  3. Check for rate limiting
  4. Review provider logs/dashboard

Subscription Not Updating

  1. Verify webhook handler is processing events
  2. Check database is being updated
  3. 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.

On this page