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_created
  • subscription_updated
  • subscription_cancelled
  • subscription_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 payments

Get 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).

Use Price IDs (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/stripe

Set 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.completed
  • customer.subscription.updated
  • customer.subscription.deleted
  • invoice.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 | null

Webhook Flow

All three webhook handlers follow the same pattern:

  1. Verify signature — reject if invalid (Stripe uses stripe-signature header, LemonSqueezy uses HMAC, Polar uses webhook-signature)
  2. Extract user_id — from event metadata
  3. Fallback lookup — if no user_id, find by email or customerId
  4. Update databaseUser.plan, User.subscriptionStatus, User.subscriptionId, User.customerId, User.planPeriodEnd
  5. Create notification — in-app bell notification for plan changes

Webhook routes:

  • POST /api/webhooks/lemonsqueezy
  • POST /api/webhooks/polar
  • POST /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/status every 3 seconds until the plan updates
  • Setup guide — shown when PAYMENT_PROVIDER is not configured, with links to configure each provider

Demo Mode — Explore freely. Some actions are restricted. demo@launchfst.dev / demo1234

Get LaunchFst →