Multi-Provider Payments: Stripe, Polar, and Lemonsqueezy in One Boilerplate
Every SaaS needs payments, but choosing the right provider is complicated. Stripe is the industry standard but complex. Polar.sh is perfect for developer tools. Lemonsqueezy handles global taxes as a merchant of record. Fabrk supports all three with identical patterns, so you're never locked in.
Why Multi-Provider Support Matters
Business Model Evolution
- Start simple with Lemonsqueezy for tax compliance
- Scale to Stripe for enterprise features
- Add Polar for open source monetization
Regional Requirements
- Some providers have better coverage in specific regions
- Tax laws may favor merchant-of-record models
- Local payment methods vary by provider
Risk Mitigation
- Provider outages happen
- Terms of service change
- Having options protects your business
Provider Comparison
| Feature | Stripe | Polar.sh | Lemonsqueezy | |---------|--------|----------|--------------| | Best For | Enterprise, complex billing | Developer tools, OSS | Solo founders, global | | Pricing | 2.9% + $0.30 | 5% platform fee | 5% + payment fees | | MoR | No | No | Yes | | Handles Taxes | Partial | No | Yes (globally) | | Setup Complexity | High | Low | Low | | Metered Billing | Yes | Limited | No | | GitHub Integration | No | Yes | No |
Identical API Patterns
Each provider follows the exact same pattern in Fabrk:
| Provider | Checkout Endpoint | Webhook Endpoint |
|----------|-------------------|------------------|
| Stripe | /api/stripe/checkout | /api/stripe/webhook |
| Polar | /api/polar/checkout | /api/polar/webhook |
| Lemonsqueezy | /api/lemonsqueezy/checkout | /api/lemonsqueezy/webhook |
This means you can switch providers by changing environment variables—no code changes required.
Setting Up Stripe
1. Create a Stripe Account
- Sign up at stripe.com
- Complete business verification
- Go to Developers → API Keys
- Copy your secret key and publishable key
2. Configure Products and Prices
$# Create products in Stripe Dashboard or via API$# Each price has a unique price_id like: price_1234567890
3. Set Environment Variables
$# .env.local$PAYMENT_PROVIDER=stripe$# API Keys$STRIPE_SECRET_KEY=sk_test_... # Use sk_live_... for production$STRIPE_PUBLISHABLE_KEY=pk_test_...$# Webhook (generate in Stripe Dashboard → Webhooks)$STRIPE_WEBHOOK_SECRET=whsec_...$# Price IDs from your Stripe products$STRIPE_PRICE_MONTHLY=price_...$STRIPE_PRICE_YEARLY=price_...
4. Configure Webhook Endpoint
In Stripe Dashboard → Webhooks:
- Add endpoint:
https://yourdomain.com/api/stripe/webhook - Select events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
Setting Up Polar.sh
1. Create a Polar Account
- Sign up at polar.sh
- Connect your GitHub account
- Create an organization
- Navigate to Settings → Developers
2. Create Products
In Polar Dashboard:
- Go to Products → Create Product
- Set pricing (one-time or subscription)
- Copy the product ID
3. Set Environment Variables
$# .env.local$PAYMENT_PROVIDER=polar$# API Key (from Settings → Developers → Access Tokens)$POLAR_ACCESS_TOKEN=polar_at_...$# Organization ID$POLAR_ORGANIZATION_ID=...$# Webhook Secret (from Settings → Webhooks)$POLAR_WEBHOOK_SECRET=...$# Product IDs$POLAR_PRODUCT_MONTHLY=...$POLAR_PRODUCT_YEARLY=...
4. Configure Webhook
In Polar Dashboard → Settings → Webhooks:
- URL:
https://yourdomain.com/api/polar/webhook - Events: All subscription and order events
Setting Up Lemonsqueezy
1. Create a Lemonsqueezy Account
- Sign up at lemonsqueezy.com
- Complete store setup
- Add products with pricing
2. Get API Credentials
- Go to Settings → API
- Generate an API key
- Copy your store ID
3. Set Environment Variables
$# .env.local$PAYMENT_PROVIDER=lemonsqueezy$# API Key$LEMONSQUEEZY_API_KEY=...$# Store ID$LEMONSQUEEZY_STORE_ID=...$# Webhook Secret (from Settings → Webhooks)$LEMONSQUEEZY_WEBHOOK_SECRET=...$# Variant IDs (Lemonsqueezy uses variants for pricing)$LEMONSQUEEZY_VARIANT_MONTHLY=...$LEMONSQUEEZY_VARIANT_YEARLY=...
4. Configure Webhook
In Lemonsqueezy Dashboard → Settings → Webhooks:
- URL:
https://yourdomain.com/api/lemonsqueezy/webhook - Events:
subscription_createdsubscription_updatedsubscription_cancelledorder_created
Client-Side Checkout Flow
The checkout flow is identical regardless of provider:
// src/components/billing/checkout-button.tsx'use client';import { Button } from '@/components/ui/button';import { useState } from 'react';interface CheckoutButtonProps {priceId: string;label?: string;}export function CheckoutButton({ priceId, label = '> SUBSCRIBE' }: CheckoutButtonProps) {const [loading, setLoading] = useState(false);const handleCheckout = async () => {setLoading(true);try {const response = await fetch('/api/checkout', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ priceId }),});const { url, error } = await response.json();if (error) {console.error('Checkout error:', error);return;}// Redirect to provider's checkout pagewindow.location.href = url;} catch (error) {console.error('Checkout failed:', error);} finally {setLoading(false);}};return (<Button onClick={handleCheckout} disabled={loading}>{loading ? 'LOADING...' : label}</Button>);}
Unified Checkout API
The checkout API routes to the correct provider:
// src/app/api/checkout/route.tsimport { auth } from '@/lib/auth';import { env } from '@/lib/env';import { NextResponse } from 'next/server';export async function POST(request: Request) {const session = await auth();if (!session?.user) {return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });}const { priceId } = await request.json();const provider = env.PAYMENT_PROVIDER;// Route to provider-specific handlerswitch (provider) {case 'stripe':return handleStripeCheckout(session.user, priceId);case 'polar':return handlePolarCheckout(session.user, priceId);case 'lemonsqueezy':return handleLemonsqueezyCheckout(session.user, priceId);default:return NextResponse.json({ error: 'Invalid payment provider' },{ status: 500 });}}
Stripe Checkout Implementation
// src/lib/payments/stripe.tsimport Stripe from 'stripe';import { env } from '@/lib/env';const stripe = new Stripe(env.STRIPE_SECRET_KEY);export async function createStripeCheckout(user: { id: string; email: string },priceId: string) {// Find or create customerconst customers = await stripe.customers.list({email: user.email,limit: 1,});let customerId = customers.data[0]?.id;if (!customerId) {const customer = await stripe.customers.create({email: user.email,metadata: { userId: user.id },});customerId = customer.id;}// Create checkout sessionconst session = await stripe.checkout.sessions.create({customer: customerId,mode: 'subscription',line_items: [{ price: priceId, quantity: 1 }],success_url: `${env.NEXT_PUBLIC_APP_URL}/billing?success=true`,cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pricing?canceled=true`,metadata: { userId: user.id },});return { url: session.url };}
Webhook Handling
Stripe Webhook
// src/app/api/stripe/webhook/route.tsimport Stripe from 'stripe';import { prisma } from '@/lib/prisma';import { env } from '@/lib/env';const stripe = new Stripe(env.STRIPE_SECRET_KEY);export async function POST(request: Request) {const body = await request.text();const signature = request.headers.get('stripe-signature')!;let event: Stripe.Event;try {event = stripe.webhooks.constructEvent(body,signature,env.STRIPE_WEBHOOK_SECRET);} catch (error) {console.error('Webhook signature verification failed:', error);return new Response('Webhook Error', { status: 400 });}switch (event.type) {case 'checkout.session.completed': {const session = event.data.object as Stripe.Checkout.Session;await handleCheckoutComplete(session);break;}case 'customer.subscription.updated': {const subscription = event.data.object as Stripe.Subscription;await updateSubscription(subscription);break;}case 'customer.subscription.deleted': {const subscription = event.data.object as Stripe.Subscription;await cancelSubscription(subscription.id);break;}case 'invoice.payment_failed': {const invoice = event.data.object as Stripe.Invoice;await handlePaymentFailed(invoice);break;}}return new Response('OK', { status: 200 });}async function handleCheckoutComplete(session: Stripe.Checkout.Session) {const userId = session.metadata?.userId;if (!userId) return;const subscription = await stripe.subscriptions.retrieve(session.subscription as string);await prisma.subscription.create({data: {id: subscription.id,userId,provider: 'stripe',providerCustomerId: session.customer as string,status: subscription.status,priceId: subscription.items.data[0].price.id,currentPeriodEnd: new Date(subscription.current_period_end * 1000),},});}async function updateSubscription(subscription: Stripe.Subscription) {await prisma.subscription.update({where: { id: subscription.id },data: {status: subscription.status,priceId: subscription.items.data[0].price.id,currentPeriodEnd: new Date(subscription.current_period_end * 1000),},});}async function cancelSubscription(subscriptionId: string) {await prisma.subscription.update({where: { id: subscriptionId },data: { status: 'canceled' },});}
Provider-Agnostic Database Schema
The subscription schema works with all providers:
// prisma/schema.prismamodel Subscription {id String @iduserId Stringprovider String // 'stripe', 'polar', 'lemonsqueezy'providerCustomerId Stringstatus String // 'active', 'canceled', 'past_due', etc.priceId String // Provider-specific price/variant IDcurrentPeriodEnd DateTimecancelAtPeriodEnd Boolean @default(false)createdAt DateTime @default(now())updatedAt DateTime @updatedAtuser User @relation(fields: [userId], references: [id])@@index([userId])@@index([provider, providerCustomerId])}
Checking Subscription Status
// src/lib/subscription.tsimport { prisma } from '@/lib/prisma';export async function getUserSubscription(userId: string) {const subscription = await prisma.subscription.findFirst({where: {userId,status: { in: ['active', 'trialing'] },},});return subscription;}export async function isUserSubscribed(userId: string): Promise<boolean> {const subscription = await getUserSubscription(userId);return !!subscription;}export async function getSubscriptionTier(userId: string): Promise<string> {const subscription = await getUserSubscription(userId);if (!subscription) return 'free';// Map price IDs to tier namesconst tierMap: Record<string, string> = {[process.env.STRIPE_PRICE_MONTHLY!]: 'pro',[process.env.STRIPE_PRICE_YEARLY!]: 'pro',// Add other provider mappings};return tierMap[subscription.priceId] || 'pro';}
Feature Gating
// src/components/billing/feature-gate.tsx'use client';import { useSubscription } from '@/hooks/use-subscription';import { Card } from '@/components/ui/card';import { Button } from '@/components/ui/button';import Link from 'next/link';interface FeatureGateProps {children: React.ReactNode;requiredTier: string;}export function FeatureGate({ children, requiredTier }: FeatureGateProps) {const { tier, loading } = useSubscription();if (loading) {return <div className="animate-pulse">Loading...</div>;}const tierHierarchy = ['free', 'pro', 'enterprise'];const userTierIndex = tierHierarchy.indexOf(tier);const requiredTierIndex = tierHierarchy.indexOf(requiredTier);if (userTierIndex < requiredTierIndex) {return (<Card className="border border-border p-6 text-center"><p className="font-mono text-sm text-muted-foreground mb-4">This feature requires {requiredTier.toUpperCase()} plan</p><Link href="/pricing"><Button>> UPGRADE</Button></Link></Card>);}return <>{children}</>;}
Customer Portal
Stripe Customer Portal
// src/app/api/billing/portal/route.tsimport Stripe from 'stripe';import { auth } from '@/lib/auth';import { prisma } from '@/lib/prisma';import { env } from '@/lib/env';const stripe = new Stripe(env.STRIPE_SECRET_KEY);export async function POST() {const session = await auth();if (!session?.user) {return Response.json({ error: 'Unauthorized' }, { status: 401 });}const subscription = await prisma.subscription.findFirst({where: { userId: session.user.id, provider: 'stripe' },});if (!subscription) {return Response.json({ error: 'No subscription found' }, { status: 404 });}const portalSession = await stripe.billingPortal.sessions.create({customer: subscription.providerCustomerId,return_url: `${env.NEXT_PUBLIC_APP_URL}/billing`,});return Response.json({ url: portalSession.url });}
Switching Providers
To switch from one provider to another:
-
Update Environment Variables
$# Change this line$PAYMENT_PROVIDER=polar # was 'stripe'$# Add new provider credentials$POLAR_ACCESS_TOKEN=... -
Update Webhook URLs in new provider's dashboard
-
Create Products in new provider matching your pricing
-
Migrate Existing Subscriptions (optional)
- Export customer data from old provider
- Create customers in new provider
- Offer transition period for existing subscribers
-
Deploy - No code changes needed
Testing Webhooks Locally
Use provider CLIs to forward webhooks during development:
Stripe
$# Install Stripe CLI$brew install stripe/stripe-cli/stripe$# Login$stripe login$# Forward webhooks$stripe listen --forward-to localhost:3000/api/stripe/webhook
Polar
$# Use ngrok or similar for local webhook testing$ngrok http 3000$# Add ngrok URL to Polar webhook settings
Best Practices
- Always verify webhook signatures - Never trust unverified webhooks
- Make webhooks idempotent - Handle duplicate events gracefully
- Log all payment events - For debugging and auditing
- Test in sandbox/test mode - Never test with real payments
- Handle failures gracefully - Show clear error messages to users
- Monitor webhook delivery - Set up alerts for failed webhooks
Troubleshooting
"Webhook signature verification failed"
- Ensure you're using the correct webhook secret
- Check that you're reading the raw body (not parsed JSON)
- Verify the webhook is from the correct environment (test vs live)
Subscription not updating
- Check webhook logs in provider dashboard
- Verify all required events are subscribed
- Check database for constraint violations
Checkout redirects to error
- Verify price/product IDs are correct
- Check that customer creation succeeds
- Review server logs for detailed errors
Next Steps
- Set up your preferred provider - Start with one, add others later
- Create your products - Define pricing tiers in provider dashboard
- Test the full flow - Checkout, webhook, portal
- Deploy - Configure production webhooks
Payment flexibility from day one. Switch providers without rewriting code.