Production-grade payment integration for Stripe, Paddle, Adyen, and more. Use when implementing checkout, subscriptions, webhooks, or billing.
Use this skill to design, implement, and debug production payment integrations: checkout flows, subscription management, webhook handling, regional pricing, feature gating, one-time purchases, billing portals, and payment testing.
Defaults bias toward: Stripe as primary processor (most common), webhooks as source of truth, idempotent handlers, lazy-initialized clients, dynamic payment methods, Zod validation at boundaries, structured logging, and fire-and-forget for non-critical tracking. For complex billing, consider a billing orchestrator (Chargebee, Recurly, Lago) on top of Stripe/Adyen.
| Task | Default Picks | Notes |
|---|---|---|
| Subscription billing | Stripe Checkout (hosted) | Omit payment_method_types for dynamic methods |
| MoR / tax compliance | Stripe Managed Payments / Paddle / LemonSqueezy | MoR handles VAT/sales tax for you |
| Mobile subscriptions |
| RevenueCat |
| Wraps App Store + Google Play |
| Enterprise / high-volume | Adyen | 250+ payment methods, interchange++ pricing |
| Complex billing logic | Chargebee / Recurly on top of Stripe | Per-seat + usage, contract billing, revenue recognition |
| Usage-based billing | Stripe Billing Meters or Lago (open-source) | API calls, AI tokens, compute metering |
| UK Direct Debit | GoCardless | Bacs/SEPA/ACH DD, lowest involuntary churn |
| EU multi-method | Mollie | iDEAL, Bancontact, SEPA DD, Klarna — 25+ methods |
| Online + POS | Square | Unified commerce: online payments + in-person readers |
| Bank-to-bank (A2A) | Open Banking (TrueLayer / Yapily) | Zero card fees, instant settlement, no chargebacks |
| Webhook handling | Verify signature + idempotent handlers | Stripe retries for 3 days |
| Feature gating | Tier hierarchy + feature matrix | Check at API boundary |
| One-time purchases | Stripe Checkout mode: 'payment' | Alongside subscriptions |
| Billing portal | Stripe Customer Portal | Self-service management |
| Regional pricing | PPP-adjusted prices per country | Use x-vercel-ip-country or GeoIP |
| PayPal button | Stripe PayPal method or PayPal Commerce Platform | Avoid Braintree — deprecated 2026, EOL Jan 2027 |
| BNPL (e-commerce) | Klarna (via Stripe/Mollie/direct) | Split payments; UK regulation expected 2026-27 |
| Testing | Stripe CLI + test cards | 4242 4242 4242 4242 |
Use this skill to:
Use a different skill when:
Three platform layers (can be combined):
| Layer | Role | Examples |
|---|---|---|
| Payment Processor | Moves money, payment methods, fraud | Stripe, Adyen, Mollie, Square |
| Merchant of Record (MoR) | Handles tax, legal, disputes for you | Paddle, LemonSqueezy, Stripe Managed Payments |
| Billing Orchestrator | Subscription logic, dunning, revenue recognition | Chargebee, Recurly, Lago (open-source) |
| Direct Debit | Bank-account recurring pulls | GoCardless (Bacs, SEPA, ACH) |
| Open Banking (A2A) | Bank-to-bank instant payments | TrueLayer, Yapily |
Payment integration needs: [Business Model]
STEP 1: Choose your processor
- Default / most common -> Stripe
- Enterprise, >$1M/yr, 250+ payment methods -> Adyen
- EU-focused, need iDEAL/Bancontact/SEPA -> Mollie
- Need PayPal button -> Stripe (PayPal method) or PayPal Commerce Platform
- WARNING: Do NOT start new projects on Braintree (deprecated 2026, EOL Jan 2027)
STEP 2: Do you need a MoR?
- Handle own tax + compliance -> Skip MoR, use processor directly
- Want tax/VAT/disputes handled -> Stripe Managed Payments, Paddle, LemonSqueezy
- Indie / small SaaS -> LemonSqueezy (simplest MoR)
- EU-heavy customer base -> Paddle (strongest EU VAT handling)
STEP 3: Is billing logic complex?
- Simple tiers (free/pro/enterprise) -> Stripe Billing is sufficient
- Per-seat + usage, contract billing, rev-rec -> Chargebee or Recurly on top of Stripe
- Usage-based (API calls, AI tokens) -> Stripe Billing Meters or Lago (open-source)
- B2C subscriptions, churn focus -> Recurly (strong revenue recovery)
STEP 4: Platform-specific needs
- Mobile app (iOS/Android) -> RevenueCat (wraps both stores)
- Hybrid (web + app) -> RevenueCat + Stripe (share customer IDs)
- Marketplace / multi-party -> Stripe Connect
- UK Direct Debit recurring -> GoCardless (Bacs DD, lowest involuntary churn)
- Multi-method EU checkout -> Mollie (25+ methods, single integration)
- Online + in-person POS -> Square (unified commerce)
- High-value A2A / zero card fees -> Open Banking (TrueLayer)
- BNPL for e-commerce -> Klarna (via Stripe, Mollie, or direct)
- One-time digital goods -> Stripe Checkout (payment mode)
- Physical goods -> Stripe + shipping integration
- Emerging markets / PPP -> Multiple Stripe Price objects per region
- Multi-currency -> Stripe multi-currency or Paddle (auto-converts)
- B2B invoicing -> Stripe Invoicing
For detailed platform comparison tables, see references/platform-comparison.md. For UK/EU-specific platforms (GoCardless, Mollie, Square, Klarna, Open Banking), see references/uk-eu-payments-guide.md.
CRITICAL: Lazy-initialize the Stripe client. Import-time initialization fails during build/SSR when env vars aren't available.
// CORRECT: Lazy initialization with proxy for backwards compatibility
import Stripe from 'stripe';
let _stripe: Stripe | null = null;
export function getStripeServer(): Stripe {
if (!_stripe) {
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error('STRIPE_SECRET_KEY is not configured');
}
_stripe = new Stripe(secretKey, {
apiVersion: '2026-01-28.clover', // Pin to specific version
typescript: true,
});
}
return _stripe;
}
// Proxy for convenience (backwards-compatible named export)
export const stripe = {
get customers() { return getStripeServer().customers; },
get subscriptions() { return getStripeServer().subscriptions; },
get checkout() { return getStripeServer().checkout; },
get billingPortal() { return getStripeServer().billingPortal; },
get webhooks() { return getStripeServer().webhooks; },
};
// WRONG: Crashes during build when STRIPE_SECRET_KEY is undefined
import Stripe from 'stripe';
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // Build failure
CRITICAL: Do NOT set payment_method_types. Omitting it enables Stripe's dynamic payment method selection (Apple Pay, Google Pay, Link, bank transfers, local methods) based on customer region and device.
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
// DO NOT set payment_method_types — let Stripe auto-select
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 7,
metadata: {
user_id: userId,
billing_interval: interval,
},
},
success_url: `${appUrl}/dashboard?checkout=success&tier=${tier}`,
cancel_url: `${appUrl}/dashboard?checkout=canceled`,
allow_promotion_codes: true,
billing_address_collection: 'auto',
metadata: {
user_id: userId,
tier,
billing_interval: interval,
},
});
// WRONG: Limits to cards only, blocks Apple Pay, Google Pay, Link, etc.
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'], // REMOVE THIS
// ...
});
Webhooks are the source of truth for subscription state. Never trust client-side callbacks alone.
Pattern: read raw body as text → verify stripe.webhooks.constructEvent(body, signature, secret) → switch(event.type) → return { received: true } on success or 500 on handler error (so Stripe retries). Full route implementation in references/stripe-patterns.md.
| Event | When | Handler Pattern |
|---|---|---|
checkout.session.completed | Checkout finishes | Link Stripe customer to user, create subscription record |
checkout.session.expired | Abandoned checkout | Fire-and-forget analytics (never fail the response) |
customer.subscription.created | New subscription | Upsert subscription record with tier, status, period |
customer.subscription.updated | Plan change, renewal, cancel-at-period-end | Update tier, status, cancel flags |
customer.subscription.deleted | Subscription ends | Reset to free tier |
invoice.payment_succeeded | Successful charge | Update period dates, process referral rewards |
invoice.payment_failed | Failed charge | Set status to past_due |
customer.subscription.trial_will_end | 3 days before trial ends | Trigger retention email |
For checkout.session.expired (and similar analytics events): call tracking without await, never throw. Non-critical tracking must not cause a webhook 500.
// Type definitions
export type SubscriptionTier = 'free' | 'starter' | 'pro' | 'enterprise';
export type SubscriptionStatus = 'active' | 'trialing' | 'canceled' | 'past_due' | 'incomplete';
export type BillingInterval = 'month' | 'year';
// Tier hierarchy for comparison
export const TIER_HIERARCHY: Record<SubscriptionTier, number> = {
free: 0,
starter: 1,
pro: 2,
enterprise: 3,
};
// Feature access matrix
export type Feature = 'basic_dashboard' | 'advanced_reports' | 'api_access' | 'priority_support';
const TIER_FEATURES: Record<SubscriptionTier, Feature[]> = {
free: ['basic_dashboard'],
starter: ['basic_dashboard', 'advanced_reports'],
pro: ['basic_dashboard', 'advanced_reports', 'api_access'],
enterprise: ['basic_dashboard', 'advanced_reports', 'api_access', 'priority_support'],
};
export function hasFeatureAccess(tier: SubscriptionTier, feature: Feature): boolean {
return TIER_FEATURES[tier].includes(feature);
}
export function isTierUpgrade(current: SubscriptionTier, target: SubscriptionTier): boolean {
return (TIER_HIERARCHY[target] ?? 0) > (TIER_HIERARCHY[current] ?? 0);
}
Use stripe.subscriptions.update() with proration_behavior: 'create_prorations' and the new price on the existing item. Update local DB immediately; webhook will confirm. See full lifecycle in references/subscription-lifecycle.md.
Create separate Stripe Price objects per region (standard vs emerging). Use x-vercel-ip-country or GeoIP for detection. Full implementation and market list in references/regional-pricing-guide.md.
// Some products are one-time (e.g., PDF reports, credits)
// but subscribers get unlimited access
export function hasUnlimitedProductAccess(
tier: SubscriptionTier,
status: SubscriptionStatus,
product: OneTimeProduct
): boolean {
const isActive = status === 'active' || status === 'trialing';
if (!isActive) return false;
const productConfig = ONE_TIME_PRODUCTS[product];
if (!productConfig.unlimitedFeature) return false;
return hasFeatureAccess(tier, productConfig.unlimitedFeature);
}
Use stripe.billingPortal.sessions.create() to redirect customers to Stripe's self-service portal for plan changes, payment method updates, and cancellation. See references/stripe-patterns.md for portal configuration checklist.
Key constraint: allow_promotion_codes and discounts are mutually exclusive in Stripe Checkout. If a referral coupon applies, set discounts: [{ coupon: REFERRAL_COUPON_ID }] and omit allow_promotion_codes. On invoice.payment_succeeded with billing_reason === 'subscription_create', reward the referrer via stripe.customers.createBalanceTransaction().
Every paid feature requires enforcement at 3 layers: Feature Registry (maps features to tiers), API Enforcement (returns 403), UI Paywall (shows upgrade CTA). Missing any layer creates a security hole or broken UX.
Key anti-patterns:
transits: Transit[] | { __gated: true } crashes (data.transits || []).sort(). Use consistent shapes with an explicit transitsGated: boolean flag.allow_promotion_codes + discounts together = Stripe rejects.Always verify Stripe SDK TypeScript types (node_modules/stripe/types/), not documentation examples.
For detailed gating architecture (consumables, fraud prevention, discriminated unions), see references/feature-gating-patterns.md.
Current version: 2026-01-28.clover. Key breaking change: invoice.subscription replaced by invoice.parent.subscription_details since 2025-11-17.clover. Full version table and migration code in references/stripe-patterns.md.
| FAIL Avoid | PASS Instead | Why |
|---|---|---|
payment_method_types: ['card'] | Omit the field entirely | Blocks Apple Pay, Google Pay, Link, local methods |
| Trusting client-side checkout callback | Use webhooks as source of truth | Client can close browser before callback |
new Stripe(key) at module top level | Lazy-initialize in a function | Build fails when env var is undefined |
| Catching webhook errors silently | Log + return 500 so Stripe retries | Lost events = lost revenue |
| Storing subscription state only client-side | Sync from webhook to DB | Single source of truth |
| Hardcoding prices in code | Use Stripe Price objects via env vars | Prices change, regional variants |
| Skipping webhook signature verification | Always verify with constructEvent() | Prevents replay/spoofing attacks |
Using invoice.subscription (2025+) | Use invoice.parent.subscription_details | Breaking change since 2025-11-17.clover |
await on fire-and-forget analytics | Don't await, don't throw | Non-critical tracking must not fail webhooks |
Missing UUID validation on user_id from metadata | Validate with regex before DB operations | Prevents injection and corrupt data |
| Creating checkout without checking existing subscription | Check and use upgrade flow if active | Prevents duplicate subscriptions |
Using --no-verify for Stripe webhook testing | Use Stripe CLI: stripe listen --forward-to | Real signature verification in dev |
Quick reference — full patterns in references/testing-patterns.md.
# Stripe CLI: forward events to local webhook endpoint
stripe listen --forward-to localhost:3001/api/stripe/webhook
# Trigger specific events
stripe trigger checkout.session.completed
stripe trigger invoice.payment_failed
| Card | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Declined |
4000 0000 0000 3220 | 3D Secure required |
4000 0000 0000 9995 | Insufficient funds |
When checkout API response contracts change, treat it as a cross-surface migration. Enumerate all entrypoints, update every caller, route blocked flows to one shared recovery UX. Full checklist in references/in-app-browser-checkout-contract.md and assets/template-checkout-entrypoint-propagation-checklist.md.
10-point checklist covering webhook signature verification, secrets management, UUID validation, HTTPS, idempotency, and rate limiting. Full checklist in references/stripe-patterns.md.
References
Templates
Related Skills
When users ask version-sensitive questions about payment platforms, do a freshness check.
data/sources.json (official docs, changelogs, API versions).For checkout 500 errors with RLS/authorization denials: 5-step incident loop, required logging fields, and guardrails. See references/ops-runbook-checkout-errors.md.