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 tends to sprawl fast: pricing CTAs, authenticated checkout, one-time marketing checkout, billing portal links, webhooks, refunds, seat changes, usage credits. Import a provider SDK directly in even half of these, and every feature becomes provider-aware — which means every provider migration becomes an app-wide grep.
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>;
createGuestCheckout(params: GuestCheckoutParams): 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. Keep their names, prices, billing intervals, and entitlements in your application's plan source of truth; map provider-specific product and price identifiers at the adapter boundary.
Keep provider-specific product and price identifiers in server-side configuration or provider mapping code. Do not expose secrets or private provider identifiers to the browser.
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.createGuestCheckout({
productKey: 'your-product-key',
successUrl,
cancelUrl,
});
Your pricing page can send a stable product key to a server route, which resolves it to the active provider's product and creates the guest checkout session.
If you use provider-hosted checkout URLs instead, keep their mapping in server-side configuration and validate the destination before redirecting.
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 this boundary in place, pages don't import provider SDKs, webhook routes normalize events before they hit application code, and tests mock one interface instead of three. Switching providers becomes a config change and an adapter update — not a rewrite.
Codapult includes Stripe, LemonSqueezy, and Polar payment adapters; the payments docs show the provider switch and checkout paths.