How to structure payment code so checkout, webhooks, billing portals, and marketing checkout can switch providers without touching every page.
Most SaaS apps start with one payment provider.
That is fine. The mistake is letting that provider become the architecture.
If Stripe-specific objects leak into pages, server actions, admin tools, and webhook handlers, switching to LemonSqueezy or Polar becomes a rewrite. Even adding one-time products next to subscriptions can become awkward.
The payment adapter pattern keeps provider details behind a small interface.
Payment code usually appears in several places:
If each layer imports a provider SDK directly, every feature becomes provider-aware.
That makes the code harder to test and harder to change.
You do not need a large abstraction. Start with the operations the app actually performs:
export interface PaymentAdapter {
createCheckout(params: CheckoutParams): Promise<string>;
createMarketingCheckout(params: MarketingCheckoutParams): Promise<string>;
createPortalSession(customerId: string): Promise<string>;
handleWebhook(payload: string, signature: string): Promise<WebhookResult>;
refundPayment(params: RefundParams): Promise<RefundResult>;
}
The rest of the app imports getPaymentAdapter() instead of stripe, lemonsqueezy, or polar.
const adapter = await getPaymentAdapter();
const checkoutUrl = await adapter.createCheckout({
planId: 'pro',
interval: 'monthly',
seats: 5,
customerId,
});
That one boundary gives you room to change providers without changing every caller.
Plan definitions should describe your product, not your provider.
export const plans = [
{
id: 'pro',
name: 'Pro',
price: { monthly: 19, yearly: 190 },
limits: { projects: 'unlimited', aiCredits: 5000 },
},
] as const;
Provider-specific IDs belong in environment variables or provider mapping code:
PAYMENT_PROVIDER="stripe"
STRIPE_PRICE_PRO_MONTHLY="price_..."
# or
PAYMENT_PROVIDER="polar"
CHECKOUT_VARIANT_PRO="..."
This lets the app reason about pro while the adapter handles provider IDs.
Authenticated SaaS checkout usually attaches a customer, organization, subscription, and return URL.
Marketing checkout may sell source-code licenses or plugins before the buyer has an account.
That path needs its own method:
await adapter.createMarketingCheckout({
productKey: 'plugin-ai-kit',
successUrl,
cancelUrl,
});
The pricing page can route to /api/checkout?product=plugin-ai-kit, and the route can call the active adapter.
If you prefer direct hosted checkout URLs, keep those as the simpler path:
CHECKOUT_URL_PLUGIN_AI_KIT="https://provider.example/buy/plugin-ai-kit"
In Codapult, CHECKOUT_VARIANT_* wins over CHECKOUT_URL_*, so API checkout is used when a provider variant exists and direct links remain available as fallback.
Provider webhooks are not interchangeable.
Stripe, LemonSqueezy, and Polar use different event names, payload shapes, signature headers, and product identifiers. The adapter should normalize those into app-level events.
type WebhookResult =
| { type: 'subscription.updated'; subscriptionId: string; customerId: string }
| { type: 'subscription.deleted'; subscriptionId: string }
| { type: 'payment.refunded'; paymentId: string }
| { type: 'ignored' };
The route validates rate limits and signature, then passes the normalized result to application code.
Do not hide every provider feature.
Stripe Connect, LemonSqueezy licensing, and Polar's Merchant of Record model are not identical. If you need a provider-specific feature, keep it explicit at the edge of the adapter.
Good adapters remove duplication. Bad adapters pretend different products are the same.
With a payment adapter:
That is the useful level of abstraction: small enough to understand, strong enough to protect the app.
Codapult includes Stripe, LemonSqueezy, and Polar payment adapters; the payments docs show the provider switch and checkout paths.