Payments
LemonSqueezy, Polar, and Stripe payment integration with provider-agnostic architecture. Switch providers with one environment variable.
LaunchFst uses a provider-agnostic payment system. Three providers are supported out of the box: LemonSqueezy, Polar, and Stripe. Switch between them by changing a single environment variable — no code changes required.
Architecture
The payment system is built around the PaymentProvider interface in lib/payments/index.ts:
export interface PaymentProvider {
createCheckout(params: CheckoutParams): Promise<{ url: string }>
cancelSubscription(subscriptionId: string): Promise<void>
pauseSubscription(subscriptionId: string): Promise<void>
resumeSubscription(subscriptionId: string): Promise<void>
getPortalUrl(customerId: string): Promise<{ url: string }>
}The getPaymentProvider() factory reads PAYMENT_PROVIDER from the environment and returns the corresponding implementation. If no provider is configured, it returns a mock that throws descriptive errors when called (billing page shows a setup guide instead).
# .env.local — choose one
PAYMENT_PROVIDER="lemonsqueezy"
# PAYMENT_PROVIDER="polar"
# PAYMENT_PROVIDER="stripe"LemonSqueezy
Get your API key
Go to app.lemonsqueezy.com → Settings → API → create a new API key.
Get your Store ID
Go to Settings → Stores → copy your Store ID (a number).
Create products and variants
Go to Products → create a product → create Variant(s). Each variant must be type Subscription (not one-time payment).
Copy the Variant ID (a number like 123456) from the Variants tab. This is NOT the product ID or a UUID.
Add environment variables
PAYMENT_PROVIDER="lemonsqueezy"
LEMONSQUEEZY_API_KEY="your-api-key"
LEMONSQUEEZY_STORE_ID="your-store-id"
LEMONSQUEEZY_WEBHOOK_SECRET="your-webhook-secret"Set variant IDs in pricing config
// lib/config/pricing.ts
{
id: "PRO",
lemonsqueezy: {
monthlyVariantId: "123456", // numeric Variant ID
annualVariantId: "123457",
},
}Configure webhook
Webhook URL: https://yourdomain.com/api/webhooks/lemonsqueezy
Events to subscribe to:
subscription_createdsubscription_updatedsubscription_cancelledsubscription_payment_success
Copy the signing secret shown after creating the webhook → set as LEMONSQUEEZY_WEBHOOK_SECRET.
Polar
Create an account and organization
Go to polar.sh and create an organization.
Set sandbox vs production mode
POLAR_MODE="sandbox" # for testing
# POLAR_MODE="production" # for live paymentsGet your access token
Polar Dashboard → Settings → API → create a personal access token.
Create products
Create products in Polar Dashboard. Copy each product's UUID (format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
Add environment variables
PAYMENT_PROVIDER="polar"
POLAR_ACCESS_TOKEN="your-access-token"
POLAR_WEBHOOK_SECRET="your-webhook-secret"
POLAR_MODE="sandbox"Set product IDs in pricing config
// lib/config/pricing.ts
{
id: "PRO",
polar: {
monthlyProductId: "uuid-here",
annualProductId: "uuid-here",
},
}Configure webhook
Webhook URL: https://yourdomain.com/api/webhooks/polar
The webhook passes user_id via metadata for user matching. There is also a fallback verification endpoint at /api/billing/verify-polar that the billing page polls after checkout completes (in case the webhook hasn't fired yet).
Stripe
Create a Stripe account
Go to stripe.com and create an account.
Get API keys
Stripe Dashboard → Developers → API keys → copy the Secret key and Publishable key.
Create products and recurring prices
Go to Products → Add a product → Add a price. Set the price type to Recurring (not one-time). Copy the Price ID (price_xxx).
price_xxx), NOT Product IDs (prod_xxx). The Price ID is found inside the product detail page, under the Pricing section. The checkout will fail if you use a Product ID.Add environment variables
PAYMENT_PROVIDER="stripe"
STRIPE_SECRET_KEY="sk_live_xxx"
STRIPE_PUBLISHABLE_KEY="pk_live_xxx"
STRIPE_WEBHOOK_SECRET="whsec_xxx"For local testing, use sk_test_ keys and the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripeSet price IDs in pricing config
// lib/config/pricing.ts
{
id: "PRO",
stripe: {
monthlyPriceId: "price_1abc...", // PRICE ID, not prod_xxx
annualPriceId: "price_1def...",
},
}Configure webhook
Webhook URL: https://yourdomain.com/api/webhooks/stripe
Events to subscribe to:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.payment_failed
Copy the webhook signing secret → set as STRIPE_WEBHOOK_SECRET.
Enable Stripe Portal (optional)
The billing page can redirect users to the Stripe Customer Portal for self-serve management (update payment method, view invoices). Enable it in Stripe Dashboard → Billing → Customer Portal → Activate.
Pricing Plans
Defined in lib/config/pricing.ts. Four tiers: FREE, STARTER, PRO, ENTERPRISE.
export const pricingPlans: PlanConfig[] = [
{ id: "FREE", price: { monthly: 0, annual: 0 }, /* ... */ },
{ id: "STARTER", price: { monthly: 9, annual: 7 }, /* ... */ },
{ id: "PRO", price: { monthly: 29, annual: 24 }, popular: true, /* ... */ },
{ id: "ENTERPRISE", price: { monthly: 99, annual: 79 }, /* ... */ },
]Helper functions:
import { getCheckoutId, getPlanByCheckoutId } from "@/lib/config/pricing"
// Get provider-specific ID for a plan + interval
const id = getCheckoutId("PRO", "monthly") // → "price_xxx" | "123456" | "uuid"
// Reverse lookup — used in webhook handlers
const plan = getPlanByCheckoutId(checkoutId) // → PlanConfig | nullWebhook Flow
All three webhook handlers follow the same pattern:
- Verify signature — reject if invalid (Stripe uses
stripe-signatureheader, LemonSqueezy uses HMAC, Polar useswebhook-signature) - Extract user_id — from event metadata
- Fallback lookup — if no user_id, find by email or customerId
- Update database —
User.plan,User.subscriptionStatus,User.subscriptionId,User.customerId,User.planPeriodEnd - Create notification — in-app bell notification for plan changes
Webhook routes:
POST /api/webhooks/lemonsqueezyPOST /api/webhooks/polarPOST /api/webhooks/stripe
Billing Page Features
The dashboard billing page (/dashboard/billing) includes:
- Current plan display — name, status badge (ACTIVE / TRIALING / PAST_DUE / CANCELLED / PAUSED / INACTIVE), period end date
- Upgrade/downgrade — starts a new checkout flow; cancels existing subscription automatically before creating the new one
- Cancel subscription — with confirmation dialog; immediate cancellation
- Pause/resume — supported by all three providers (Stripe via
pause_collection) - Processing state — shown after returning from checkout while webhook is pending; billing page polls
/api/billing/statusevery 3 seconds until the plan updates - Setup guide — shown when
PAYMENT_PROVIDERis not configured, with links to configure each provider