
Software Payments
- 134 installs
- 73 repo stars
- Updated July 13, 2026
- vasilyu1983/ai-agents-public
Helps with ai & agent building tasks.
About
software-payments is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- software-payments
- AI & Agent Building
- AI-coding skill
Software Payments by the numbers
- 134 all-time installs (skills.sh)
- +2 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #3,580 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vasilyu1983/ai-agents-public --skill software-paymentsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 134 |
|---|---|
| repo stars | ★ 73 |
| Last updated | July 13, 2026 |
| Repository | vasilyu1983/ai-agents-public ↗ |
What it does
Helps with ai & agent building tasks.
Files
Payments & Billing Engineering
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.
---
Quick Reference
| 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 |
Scope
Use this skill to:
- Implement checkout flows (hosted, embedded, custom)
- Build subscription lifecycle management (create, upgrade, downgrade, cancel)
- Handle webhooks reliably (signature verification, idempotency, error handling)
- Set up regional/multi-currency pricing (PPP, emerging markets)
- Build feature gating and entitlement systems
- Implement one-time purchases alongside subscriptions
- Create billing portal integrations
- Test payment flows end-to-end
- Debug common payment integration issues
When NOT to Use This Skill
Use a different skill when:
- General backend patterns -> See software-backend
- API design only (no payments) -> See dev-api-design
- Conversion optimization -> See marketing-cro
- Business model / pricing strategy -> See startup-business-models
- Security audits -> See software-security-appsec
---
Decision Tree: Payment Platform Selection
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 InvoicingFor 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.
---
Stripe Integration Patterns (Feb 2026)
1. Client Initialization
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 failure2. Checkout Session Creation
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
// ...
});3. Webhook Handler Architecture
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.
Essential Webhook Events
| 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 |
Fire-and-Forget Pattern for Non-Critical Tracking
For checkout.session.expired (and similar analytics events): call tracking without await, never throw. Non-critical tracking must not cause a webhook 500.
4. Subscription Tier Model
// 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);
}5. Upgrade/Downgrade Flow
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.
6. Regional / PPP Pricing
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.
7. One-Time Purchases Alongside Subscriptions
// 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);
}8. Billing Portal
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.
9. Referral/Coupon Integration
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().
---
Feature Gating Patterns
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:
- Gate on Wrong Key: If the feature key is in the free tier, the gate is a permanent no-op.
- Polymorphic Field Shapes:
transits: Transit[] | { __gated: true }crashes(data.transits || []).sort(). Use consistent shapes with an explicittransitsGated: booleanflag. - Checkout Mutual Exclusivity:
allow_promotion_codes+discountstogether = 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.
---
Stripe API Version Notes
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.
---
Common Mistakes and Anti-Patterns
| 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 |
---
E2E Testing Patterns
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 |
---
Checkout Contract Propagation
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.
Security Checklist
10-point checklist covering webhook signature verification, secrets management, UUID validation, HTTPS, idempotency, and rate limiting. Full checklist in references/stripe-patterns.md.
---
Navigation
References
- references/stripe-patterns.md - Stripe patterns: webhook handlers, idempotency, status mapping, error handling, dunning, usage-based billing, security checklist, API version notes
- references/platform-comparison.md - Platform comparison: Stripe, Adyen, Paddle, LemonSqueezy, Chargebee, Recurly, Lago, Braintree (deprecated)
- references/uk-eu-payments-guide.md - UK/EU platforms: GoCardless, Mollie, Square, PayPal Commerce, Klarna, Open Banking (TrueLayer, Yapily)
- references/testing-patterns.md - E2E testing: Stripe CLI, Playwright checkout, test cards, state sync
- references/subscription-lifecycle.md - Full subscription state machine, trials, upgrades/downgrades, cancellation, dunning, pause/resume, database schema
- references/regional-pricing-guide.md - PPP implementation, multi-currency Stripe prices, tax by region, fraud prevention, A/B testing pricing
- references/webhook-reliability-patterns.md - Idempotency, retry handling, dead letter queues, monitoring, event ordering, queue-based processing
- references/feature-gating-patterns.md - Feature gating: consumable vs binary unlocks, spread-then-override filtering, fraud prevention, discriminated union typed responses
- references/in-app-browser-checkout-contract.md - Checkout response contract propagation for in-app browser recovery and cross-surface consistency
- references/ops-runbook-checkout-errors.md - Checkout 500 debugging: RLS denials, auth policy, incident loop
- data/sources.json - External documentation links (69 sources)
Templates
- assets/template-checkout-entrypoint-propagation-checklist.md - Migration checklist for contract changes across all checkout callers
Related Skills
- ../software-backend/SKILL.md - Backend API patterns, database, auth
- ../dev-api-design/SKILL.md - API design patterns
- ../marketing-cro/SKILL.md - Conversion optimization for checkout
- ../startup-business-models/SKILL.md - Pricing strategy
- ../software-security-appsec/SKILL.md - Payment security
- ../qa-testing-playwright/SKILL.md - E2E testing patterns
---
Freshness Protocol
When users ask version-sensitive questions about payment platforms, do a freshness check.
Trigger Conditions
- "What's the best payment platform for [use case]?"
- "Stripe vs Paddle vs LemonSqueezy?"
- "How do I handle [tax/VAT/sales tax]?"
- "What's new in Stripe [API/Billing/Checkout]?"
- "Is Stripe Managed Payments available?"
- "Best mobile subscription SDK?"
How to Freshness-Check
1. Start from data/sources.json (official docs, changelogs, API versions). 2. Run a targeted web search for the specific platform and feature. 3. Prefer official documentation and changelogs over blog posts.
What to Report
- Current landscape: what is stable and widely used now
- Emerging trends: Managed Payments, usage-based billing, entitlements API, Open Banking
- Deprecated/declining: hardcoded payment_method_types, top-level invoice.subscription, Braintree
- Recommendation: default choice + alternatives with trade-offs
Ops Runbook
For checkout 500 errors with RLS/authorization denials: 5-step incident loop, required logging fields, and guardrails. See references/ops-runbook-checkout-errors.md.
Fact-Checking
- Use web search/web fetch to verify current external facts, versions, pricing, deadlines, regulations, or platform behavior before final answers.
- Prefer primary sources; report source links and dates for volatile information.
- If web access is unavailable, state the limitation and mark guidance as unverified.
Template: Checkout Entrypoint Propagation Checklist
Use this when checkout API contract changes (status/error/payload).
Contract Change
- Change summary:
________________________________________ - Contract version/tag:
____________________________________ - Owner:
_______________________________________________
Entrypoint Audit
| Surface | File/Component | Calls Checkout API | Handles Contract Status | Uses Shared Recovery UI | i18n Ready | Analytics Owner | Targeted Test |
|---|---|---|---|---|---|---|---|
| Subscription modal | [ ] | [ ] | [ ] | [ ] | client / server | [ ] | |
| One-time purchase card | [ ] | [ ] | [ ] | [ ] | client / server | [ ] | |
| Embedded paywall CTA | [ ] | [ ] | [ ] | [ ] | client / server | [ ] | |
| Upsell CTA | [ ] | [ ] | [ ] | [ ] | client / server | [ ] | |
| Other | [ ] | [ ] | [ ] | [ ] | client / server | [ ] |
Telemetry Consistency
- [ ] Contract-failure KPI emitted by one layer only.
- [ ] If secondary diagnostic event exists, event name is distinct.
- [ ] Dedup key strategy documented.
Release Decision
- [ ] Go
- [ ] No-go (missing migrated entrypoints)
Notes: _____________________________________________________________
{
"metadata": {
"skill": "software-payments",
"updated": "2026-03-01",
"total_sources": 69,
"supported_platforms": [
"Stripe",
"Adyen",
"Paddle",
"LemonSqueezy",
"RevenueCat",
"Chargebee",
"Recurly",
"Lago",
"GoCardless",
"Mollie",
"Square",
"Klarna",
"Checkout.com",
"Open Banking (TrueLayer, Yapily)"
],
"extensible": true,
"latest_updates": "March 2026: Added GoCardless, Mollie, Square, Klarna, Checkout.com, Open Banking (TrueLayer/Yapily). Mollie acquired GoCardless (2025). UK Open Banking 15M+ users. BNPL regulation expected 2026-27. Stripe API 2026-01-28.clover. Braintree deprecated (EOL Jan 2027)."
},
"categories": {
"stripe_core": [
{
"name": "Stripe Documentation",
"url": "https://docs.stripe.com/",
"type": "documentation",
"description": "Official Stripe API documentation, guides, and reference.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe API Reference",
"url": "https://docs.stripe.com/api",
"type": "reference",
"description": "Complete API reference for all Stripe resources.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe API Changelog",
"url": "https://docs.stripe.com/changelog",
"type": "changelog",
"description": "Breaking changes, new features, deprecations per API version.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Sessions 2025 Updates",
"url": "https://stripe.com/blog/top-product-updates-sessions-2025",
"type": "announcement",
"description": "Major product announcements including Managed Payments MoR.",
"update_frequency": "annual",
"access": "free",
"add_as_web_search": true
}
],
"stripe_checkout": [
{
"name": "Stripe Checkout Documentation",
"url": "https://docs.stripe.com/payments/checkout",
"type": "guide",
"description": "Hosted checkout implementation, payment methods, customization.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Elements Documentation",
"url": "https://docs.stripe.com/payments/elements",
"type": "guide",
"description": "Embedded payment UI components for custom checkout.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Dynamic Payment Methods",
"url": "https://docs.stripe.com/payments/payment-methods/dynamic-payment-methods",
"type": "guide",
"description": "Let Stripe auto-select optimal payment methods by region/device.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"stripe_billing": [
{
"name": "Stripe Billing Documentation",
"url": "https://docs.stripe.com/billing",
"type": "guide",
"description": "Subscription billing, invoicing, meters, proration.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Billing Meters",
"url": "https://docs.stripe.com/billing/subscriptions/usage-based/recording-usage",
"type": "guide",
"description": "Usage-based billing with meters for API calls, tokens, etc.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Customer Portal",
"url": "https://docs.stripe.com/customer-management/integrate-customer-portal",
"type": "guide",
"description": "Self-service subscription management for customers.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
},
{
"name": "Stripe Pricing Strategies",
"url": "https://docs.stripe.com/products-prices/pricing-models",
"type": "guide",
"description": "Flat rate, per-seat, usage-based, tiered pricing models.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
},
{
"name": "Stripe Revenue Recovery",
"url": "https://docs.stripe.com/billing/revenue-recovery",
"type": "guide",
"description": "Dunning, smart retries, and failed payment recovery.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Entitlements",
"url": "https://docs.stripe.com/billing/entitlements",
"type": "guide",
"description": "Feature gating via Stripe Entitlements API (2025+).",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"stripe_webhooks": [
{
"name": "Stripe Webhooks Documentation",
"url": "https://docs.stripe.com/webhooks",
"type": "guide",
"description": "Webhook setup, signature verification, event types, retry logic.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe Webhook Best Practices",
"url": "https://www.stigg.io/blog-posts/best-practices-i-wish-we-knew-when-integrating-stripe-webhooks",
"type": "blog",
"description": "Real-world webhook integration lessons and patterns.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "Stripe Idempotency",
"url": "https://stripe.com/blog/idempotency",
"type": "blog",
"description": "Official Stripe blog on designing robust APIs with idempotency.",
"update_frequency": "static",
"access": "free",
"add_as_web_search": false
},
{
"name": "Stripe Advanced Error Handling",
"url": "https://docs.stripe.com/error-low-level",
"type": "guide",
"description": "Error types, retry strategies, idempotency key behavior.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
],
"stripe_security": [
{
"name": "Stripe Security Documentation",
"url": "https://docs.stripe.com/security",
"type": "guide",
"description": "PCI compliance, security best practices, data handling.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
},
{
"name": "Stripe Radar (Fraud)",
"url": "https://docs.stripe.com/radar",
"type": "guide",
"description": "ML-based fraud prevention, rules, risk scoring.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
],
"stripe_testing": [
{
"name": "Stripe Testing Documentation",
"url": "https://docs.stripe.com/testing",
"type": "guide",
"description": "Test mode, test cards, simulating events, Stripe CLI.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe CLI Documentation",
"url": "https://docs.stripe.com/stripe-cli",
"type": "tool",
"description": "Local webhook testing, event triggering, log tailing.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"stripe_connect": [
{
"name": "Stripe Connect Overview",
"url": "https://docs.stripe.com/connect",
"type": "guide",
"description": "Marketplace and multi-party payment patterns.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"stripe_managed_payments": [
{
"name": "Stripe Managed Payments",
"url": "https://docs.stripe.com/payments/managed-payments",
"type": "guide",
"description": "Stripe MoR offering - tax, fraud, disputes handled by Stripe.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"stripe_sdks": [
{
"name": "stripe-node (Server SDK)",
"url": "https://github.com/stripe/stripe-node",
"type": "library",
"description": "Official Node.js/TypeScript Stripe SDK.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "@stripe/stripe-js (Browser SDK)",
"url": "https://github.com/stripe/stripe-js",
"type": "library",
"description": "Stripe.js loader for browser-side checkout and Elements.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "stripe-python",
"url": "https://github.com/stripe/stripe-python",
"type": "library",
"description": "Official Python Stripe SDK.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "stripe-go",
"url": "https://github.com/stripe/stripe-go",
"type": "library",
"description": "Official Go Stripe SDK.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
}
],
"paddle": [
{
"name": "Paddle Documentation",
"url": "https://developer.paddle.com/",
"type": "documentation",
"description": "Paddle billing API, checkout, webhooks, subscriptions.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Paddle Billing API",
"url": "https://developer.paddle.com/api-reference/overview",
"type": "reference",
"description": "Complete Paddle API reference.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Paddle Retain",
"url": "https://www.paddle.com/retain",
"type": "product",
"description": "Dunning and churn reduction tool built into Paddle.",
"update_frequency": "active",
"access": "commercial",
"add_as_web_search": false
},
{
"name": "Paddle vs Stripe Comparison",
"url": "https://www.paddle.com/compare/stripe",
"type": "comparison",
"description": "Official Paddle comparison against Stripe.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
],
"lemonsqueezy": [
{
"name": "LemonSqueezy Documentation",
"url": "https://docs.lemonsqueezy.com/",
"type": "documentation",
"description": "LemonSqueezy API, checkout, webhooks, subscriptions.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "LemonSqueezy API Reference",
"url": "https://docs.lemonsqueezy.com/api",
"type": "reference",
"description": "Complete LemonSqueezy API reference.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Stripe + LemonSqueezy Update",
"url": "https://www.lemonsqueezy.com/blog/stripe-lemon-squeezy-update-2025",
"type": "announcement",
"description": "Integration progress after Stripe acquisition.",
"update_frequency": "occasional",
"access": "free",
"add_as_web_search": true
}
],
"revenuecat": [
{
"name": "RevenueCat Documentation",
"url": "https://docs.revenuecat.com/",
"type": "documentation",
"description": "RevenueCat SDK, API, webhooks, entitlements for mobile.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "RevenueCat API Reference",
"url": "https://docs.revenuecat.com/reference",
"type": "reference",
"description": "Server-side API for subscription management.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
},
{
"name": "RevenueCat React Native SDK",
"url": "https://docs.revenuecat.com/docs/reactnative",
"type": "guide",
"description": "React Native in-app purchase integration.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
],
"adyen": [
{
"name": "Adyen Documentation",
"url": "https://docs.adyen.com/",
"type": "documentation",
"description": "Official Adyen integration guides, payment methods, and platform docs.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Adyen API Explorer",
"url": "https://docs.adyen.com/api-explorer/",
"type": "reference",
"description": "Complete Adyen API reference with interactive examples.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Adyen for Platforms",
"url": "https://docs.adyen.com/platforms",
"type": "guide",
"description": "Marketplace and multi-party payment patterns (equivalent to Stripe Connect).",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
},
{
"name": "Adyen GitHub",
"url": "https://github.com/adyen",
"type": "library",
"description": "Official SDKs, plugins, and example integrations.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
}
],
"chargebee": [
{
"name": "Chargebee Documentation",
"url": "https://www.chargebee.com/docs/2.0/",
"type": "documentation",
"description": "Subscription billing, invoicing, revenue recognition, checkout.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Chargebee API Reference",
"url": "https://apidocs.chargebee.com/docs/api",
"type": "reference",
"description": "Complete Chargebee REST API reference.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Chargebee RevRec",
"url": "https://www.chargebee.com/revenue-recognition/",
"type": "product",
"description": "ASC 606 / IFRS 15 compliant revenue recognition.",
"update_frequency": "active",
"access": "commercial",
"add_as_web_search": false
}
],
"recurly": [
{
"name": "Recurly Documentation",
"url": "https://docs.recurly.com/",
"type": "documentation",
"description": "Subscription billing, dunning, revenue recovery.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Recurly API Reference",
"url": "https://developers.recurly.com/api/v2021-02-25/",
"type": "reference",
"description": "Complete Recurly REST API reference.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"lago": [
{
"name": "Lago Documentation",
"url": "https://docs.getlago.com/",
"type": "documentation",
"description": "Open-source usage-based billing: metering, aggregation, invoicing.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Lago GitHub",
"url": "https://github.com/getlago/lago",
"type": "library",
"description": "Open-source billing engine (MIT license). Self-hostable.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Lago API Reference",
"url": "https://docs.getlago.com/api-reference/intro",
"type": "reference",
"description": "Lago REST API for metering, subscriptions, invoicing.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": false
}
],
"braintree_deprecated": [
{
"name": "Braintree Documentation (DEPRECATED)",
"url": "https://developer.paypal.com/braintree/docs/",
"type": "documentation",
"description": "WARNING: Braintree deprecated 2026, processing ends Jan 2027. For reference only.",
"update_frequency": "declining",
"access": "free",
"add_as_web_search": false
},
{
"name": "PayPal Commerce Platform",
"url": "https://developer.paypal.com/docs/commerce-platform/",
"type": "documentation",
"description": "PayPal's replacement for Braintree. Direct PayPal/Venmo integration.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"gocardless": [
{
"name": "GoCardless Documentation",
"url": "https://developer.gocardless.com/",
"type": "documentation",
"description": "Direct Debit specialist: Bacs (UK), SEPA (EU), ACH (US). Recurring bank payments.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "GoCardless API Reference",
"url": "https://developer.gocardless.com/api-reference/",
"type": "reference",
"description": "Complete GoCardless REST API for mandates, payments, and billing requests.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"mollie": [
{
"name": "Mollie Documentation",
"url": "https://docs.mollie.com/",
"type": "documentation",
"description": "European multi-method payment processor: cards, iDEAL, Bancontact, SEPA DD, Klarna.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Mollie API Reference",
"url": "https://docs.mollie.com/reference/v2/payments-api/overview",
"type": "reference",
"description": "Complete Mollie REST API for payments, subscriptions, and methods.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"square": [
{
"name": "Square Developer Documentation",
"url": "https://developer.squareup.com/docs/",
"type": "documentation",
"description": "Online payments, POS, invoicing, banking. Strong UK presence.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "Square UK Pricing",
"url": "https://squareup.com/gb/pricing",
"type": "reference",
"description": "UK pricing: 1.75% in-person, 1.4%+25p EU cards online.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
],
"klarna": [
{
"name": "Klarna Developer Documentation",
"url": "https://docs.klarna.com/",
"type": "documentation",
"description": "BNPL integration: Pay in 3/4, Pay Later, Pay Now. UK/EU e-commerce.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"open_banking": [
{
"name": "TrueLayer Documentation",
"url": "https://truelayer.com/docs/",
"type": "documentation",
"description": "Open Banking payments (A2A) and data API. UK/EU bank-to-bank.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
},
{
"name": "UK Open Banking",
"url": "https://www.openbanking.org.uk/",
"type": "standard",
"description": "UK Open Banking standards, statistics, and regulated providers.",
"update_frequency": "active",
"access": "free",
"add_as_web_search": true
}
],
"checkout_com": [
{
"name": "Checkout.com Documentation",
"url": "https://www.checkout.com/docs",
"type": "documentation",
"description": "Enterprise online payments: cards, Apple Pay, Google Pay. Interchange++ pricing.",
"update_frequency": "continuous",
"access": "free",
"add_as_web_search": true
}
],
"payment_standards": [
{
"name": "PCI DSS Compliance Guide",
"url": "https://www.pcisecuritystandards.org/",
"type": "standard",
"description": "Payment Card Industry Data Security Standard.",
"update_frequency": "annual",
"access": "free",
"add_as_web_search": false
},
{
"name": "Strong Customer Authentication (SCA)",
"url": "https://docs.stripe.com/strong-customer-authentication",
"type": "guide",
"description": "EU SCA requirements for payment authentication (3D Secure).",
"update_frequency": "active",
"access": "free",
"add_as_web_search": false
}
]
},
"research_tools": [
{
"name": "Stripe Dashboard",
"url": "https://dashboard.stripe.com/",
"purpose": "Manage products, prices, subscriptions, view logs.",
"use_case": "Configuration and debugging."
},
{
"name": "Stripe CLI",
"url": "https://docs.stripe.com/stripe-cli",
"purpose": "Local webhook testing, event simulation.",
"use_case": "Development and testing."
},
{
"name": "Stripe Tax Calculator",
"url": "https://dashboard.stripe.com/tax",
"purpose": "Tax rate lookup and calculation.",
"use_case": "Regional pricing validation."
}
],
"communities": [
{
"name": "Stripe Discord",
"url": "https://discord.gg/stripe",
"platform": "discord",
"focus": "Stripe integration help, community patterns."
},
{
"name": "r/stripe",
"url": "https://reddit.com/r/stripe",
"platform": "reddit",
"focus": "Stripe integration discussions, troubleshooting."
},
{
"name": "Indie Hackers",
"url": "https://www.indiehackers.com/",
"platform": "forum",
"focus": "Payment integration discussions for indie products."
}
]
}
Feature Gating Patterns
Detailed patterns for feature gating, entitlement architectures, fraud prevention, and typed gated responses in payment-integrated applications.
---
Consumable vs. Binary Unlock Architecture
Two fundamental access models exist for paid features. Most apps need both.
Binary Unlocks
Simple "has completed purchase or has active subscription at correct tier" check. Examples: Chart PDF export, Year Ahead Report, advanced house systems.
// Binary unlock — simple tier check
export function hasBinaryAccess(
tier: SubscriptionTier,
feature: Feature,
purchases: Purchase[]
): boolean {
// Subscription grants access
if (hasFeatureAccess(tier, feature)) return true;
// One-time purchase grants access
return purchases.some(p => p.feature === feature && p.status === 'completed');
}Consumables
Consumable features (e.g., Ask Pack credits, AI interpretation tokens) require an atomic decrement on a counter column with optimistic locking. Without atomicity, concurrent requests can overdraw the pool.
-- Atomic decrement with optimistic lock
UPDATE ask_cosmos_bonus_pool
SET remaining = remaining - 1
WHERE user_id = $1 AND remaining > 0
RETURNING remaining;
-- If 0 rows returned → pool exhausted, do NOT grant access// Application-level consumable check
async function consumeCredit(userId: string, pool: string): Promise<boolean> {
const { data, error } = await supabase
.rpc('decrement_pool', { p_user_id: userId, p_pool: pool });
if (error || !data || data.length === 0) {
// Pool exhausted or DB error — deny access
return false;
}
return true; // Credit consumed, proceed with feature
}PASS/FAIL:
FAIL: SELECT remaining, then UPDATE remaining - 1 in two queries → race condition, overdraw
PASS: Single UPDATE ... WHERE remaining > 0 RETURNING → atomic, no overdraw
FAIL: Caching consumable count client-side and decrementing locally → stale count
PASS: Server-side atomic decrement, return fresh count to client---
Spread-Then-Override Tier Filtering
When building gated API responses, the filtering strategy determines how safely new fields are handled over time.
Spread-Then-Override (PASS: Forward-Compatible)
Start with the full response object, then override only the gated fields. New fields added to the response automatically pass through to paid users without code changes.
// PASS: Forward-compatible — new fields automatically pass through
function buildGatedResponse(fullResponse: FullResponse, userTier: Tier): GatedResponse {
return {
...fullResponse,
// Override only gated fields
transits: hasAccess(userTier, 'transits')
? fullResponse.transits
: [],
transitsGated: !hasAccess(userTier, 'transits'),
transitTeaser: !hasAccess(userTier, 'transits')
? { count: fullResponse.transits.length, topTransit: fullResponse.transits[0]?.name }
: undefined,
};
}Allowlist (FAIL: Brittle)
Explicitly listing allowed fields means every new field must be added to the allowlist. Forgetting silently drops data for all users.
// FAIL: Allowlist — silently drops newly added fields
function buildGatedResponse(data: FullResponse): GatedResponse {
return {
sunSign: data.sunSign,
moonSign: data.moonSign,
// Forgot to add risingSign when it was added last sprint → invisible bug
};
}---
Fraud Prevention Patterns
1. Cross-Account Intro Pricing Detection
Prevent users from creating new accounts to repeatedly claim introductory pricing.
// Before granting intro discount, check Stripe for prior customers with same email
async function isEligibleForIntroPricing(email: string): Promise<boolean> {
const existingCustomers = await stripe.customers.list({
email: email.toLowerCase(),
limit: 1,
});
// If any prior customer record exists, they've had an account before
if (existingCustomers.data.length > 0) {
return false; // Not eligible for intro pricing
}
return true;
}2. Refund-and-Revoke
When a charge is refunded (either voluntarily or via dispute), revoke access and zero out consumable pools.
// charge.refunded webhook handler
async function handleChargeRefunded(charge: Stripe.Charge) {
const customerId = charge.customer as string;
const user = await getUserByStripeCustomerId(customerId);
if (!user) return;
// Revoke subscription access
await db.subscriptions.update({
tier: 'free',
status: 'canceled',
updated_at: new Date(),
}).where({ user_id: user.id });
// Zero out consumable pools
await db.askCosmosBonusPool.update({
remaining: 0,
updated_at: new Date(),
}).where({ user_id: user.id });
// Clear feature cache
await invalidateFeatureCache(user.id);
}3. Stale Feature Cache Invalidation
When subscription state changes (cancellation, downgrade, refund), immediately clear any cached feature entitlements. Stale caches let users retain access after they should have lost it.
// Call on: subscription.deleted, subscription.updated (downgrade), charge.refunded
async function invalidateFeatureCache(userId: string): Promise<void> {
await redis.del(`user_features:${userId}`);
// If using in-memory cache, also publish invalidation event
await redis.publish('feature_cache_invalidate', userId);
}4. Rate Limiting LLM-Costly Features
Features that invoke LLMs or expensive compute need per-user rate limits independent of subscription tier to prevent abuse.
| Feature | Limit | Window | Rationale |
|---|---|---|---|
write:ask-cosmos | 20 | per hour | LLM inference cost |
write:dreams | 20 | per day | LLM interpretation cost |
write:chart-pdf | 10 | per hour | PDF rendering + compute |
read:compatibility | 50 | per hour | Prevent scraping |
// Rate limit check before expensive operation
const key = `ratelimit:${feature}:${userId}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSeconds);
}
if (current > limit) {
return Response.json(
{ error: 'rate_limited', retryAfter: await redis.ttl(key) },
{ status: 429 }
);
}---
Discriminated Union for Gated Fields
A type-safe pattern for API responses where fields can be either fully available or gated with a teaser.
Type Definition
type GatedField<T> =
| { gated: false; data: T }
| { gated: true; teaser: { count: number; preview: string } };API Response Usage
type ChartResponse = {
sunSign: string; // Always available (free tier)
moonSign: string; // Always available (free tier)
transits: GatedField<Transit[]>;
houseSystems: GatedField<HouseSystem[]>;
yearAhead: GatedField<YearAheadReport>;
};
function buildChartResponse(data: FullChartData, tier: Tier): ChartResponse {
return {
sunSign: data.sunSign,
moonSign: data.moonSign,
transits: hasAccess(tier, 'transits')
? { gated: false, data: data.transits }
: { gated: true, teaser: { count: data.transits.length, preview: data.transits[0]?.summary ?? '' } },
houseSystems: hasAccess(tier, 'house_systems')
? { gated: false, data: data.houseSystems }
: { gated: true, teaser: { count: data.houseSystems.length, preview: 'Placidus, Whole Sign, ...' } },
yearAhead: hasAccess(tier, 'year_ahead')
? { gated: false, data: data.yearAhead }
: { gated: true, teaser: { count: 12, preview: 'Your year begins with a powerful...' } },
};
}React Component with Render Props
type GatedFieldProps<T> = {
field: GatedField<T>;
feature: Feature;
children: (data: T) => React.ReactNode;
fallback?: (teaser: { count: number; preview: string }) => React.ReactNode;
};
function GatedFieldView<T>({ field, feature, children, fallback }: GatedFieldProps<T>) {
if (!field.gated) {
return <>{children(field.data)}</>;
}
if (fallback) {
return <>{fallback(field.teaser)}</>;
}
return <UpgradePrompt feature={feature} teaser={field.teaser} />;
}
// Usage
<GatedFieldView field={response.transits} feature="transits">
{(transits) => <TransitList transits={transits} />}
</GatedFieldView>Why Discriminated Union over Optional Fields
| Approach | Pros | Cons |
|---|---|---|
data?: T (optional) | Simple | No teaser, no way to distinguish "empty" from "gated" |
| `data: T \ | GatedTeaser` (union, no discriminant) | Compact |
GatedField<T> (discriminated union) | Type-safe, exhaustive switch, teaser included | Slightly more verbose response |
The discriminated union forces the client to handle both branches. TypeScript narrows the type after checking field.gated, so accessing field.data on a gated field is a compile error.
// TypeScript enforces exhaustive handling
function renderTransits(field: GatedField<Transit[]>) {
if (field.gated) {
// field.teaser is available, field.data is NOT
return showUpgrade(field.teaser);
}
// field.data is available, field.teaser is NOT
return renderList(field.data);
}In-App Browser Checkout Contract Propagation
Use this guide when checkout API contract changes must be rolled out safely across multiple purchase entrypoints.
Canonical Contract Example
{
"status": 409,
"error": "IN_APP_BROWSER_BLOCKED",
"message": "Open in browser to continue checkout",
"recovery": {
"open_url": "https://...",
"copy_url": "https://..."
}
}Mandatory Migration Steps
1. Enumerate all checkout callsites:
rg -n "api/stripe/checkout|api/purchases/checkout|createCheckout|checkout" app src2. Build an entrypoint map (file + component + user surface). 3. Confirm every entrypoint handles the new contract path (for example 409 IN_APP_BROWSER_BLOCKED). 4. Route blocked state to one shared recovery component. 5. Ensure analytics event ownership is singular (server or client, not both).
Verification Matrix
For each entrypoint, verify:
- status-specific handling exists
- user sees deterministic recovery UI
- copy is localized
- telemetry emitted exactly once per action
- regression test covers blocked path
Release Gate
Block release if any active entrypoint fails one of the checks above.
Common Failure Modes
- Only one component migrated; others still show generic errors.
- Server and client emit duplicate KPI event.
- Recovery copy hardcoded in one locale only.
- New contract handled in subscription flow but not one-time purchases.
Ops Runbook: Checkout 500 + Authorization or RLS Denials
Use this when checkout endpoints return 500 and DB writes fail due to auth policy (for example PostgreSQL RLS, tenant predicates, or missing role grants).
---
5-Step Incident Loop
# 1) Trace checkout call path fast
rg -n "checkout|purchase|subscription|webhook" src app lib
# 2) Reproduce with minimal request (capture full response)
curl -i -X POST http://localhost:3000/api/purchases/checkout \
-H 'content-type: application/json' \
-d '{"productKey":"example"}'
# 3) Inspect auth and policy checks in code
rg -n "auth\.|user_id|tenant_id|policy|row level|RLS|canPurchase" src app lib
# 4) Verify DB policies (PostgreSQL)
psql "$DATABASE_URL" -c "select schemaname, tablename, policyname, permissive, cmd, qual, with_check from pg_policies where tablename in ('purchases','subscriptions','orders') order by tablename, policyname;"
# 5) Verify constrained insert path under app role
psql "$DATABASE_URL" -c "begin; set local role app_user; -- run minimal insert/select test here; rollback;"---
Required Logging for Fast Triage
request_id,user_id,tenant_id,product_key,price_id,policy_branch- database error code + message + table name
- payment provider request id (
stripe_request_idor equivalent)
---
Guardrails
- Keep checkout create calls idempotent (idempotency key per user + product + window).
- Validate authorization before payment intent/session creation.
- In webhook handlers, never trust client state; reconcile from provider event + DB.
- Fail closed on entitlement write errors; do not grant access on partial checkout success.
Payment Platform Comparison (Feb 2026)
Detailed comparison across three platform layers: processors (Stripe, Adyen), merchants of record (Paddle, LemonSqueezy), and billing orchestrators (Chargebee, Recurly, Lago). Plus mobile (RevenueCat) and deprecation warnings (Braintree).
---
Stripe
Best for: Maximum control, complex billing, marketplaces, established businesses.
Strengths
- Most complete API and SDK ecosystem
- Support for 135+ currencies and 100+ payment methods
- Stripe Connect for marketplace/platform payments
- Stripe Tax for automated tax calculation
- Stripe Radar for fraud detection (ML-based)
- Stripe Invoicing for B2B
- Stripe Billing Meters for usage-based pricing
- Managed Payments (MoR) launching 2025
- Stripe Link for one-click checkout (50M+ stored cards)
Weaknesses
- You handle tax compliance by default (unless using Tax or Managed Payments)
- Higher complexity for simple SaaS
- Dispute/chargeback management is your responsibility
Pricing
- Standard: 2.9% + 30c (US domestic)
- International: +1.5% (cross-border)
- Stripe Tax: 0.5% per transaction
- No monthly fees (pay-per-use)
Key Integration Points
Checkout Session -> Webhook -> DB Sync -> Feature Gating
| |
+--> Success URL (client redirect) |
+--> Cancel URL (client redirect) |
v
Subscription Context
(React Context / API middleware)---
Paddle
Best for: SaaS businesses selling globally, especially to EU customers needing VAT compliance.
Strengths
- Full merchant of record: handles VAT, sales tax, GST in 200+ countries
- Automatic tax calculation and filing
- Handles refunds, chargebacks, and customer invoicing
- Paddle Retain for dunning and churn reduction
- ProfitWell Metrics (acquired) for revenue analytics
- Relatively simple integration for the value provided
Weaknesses
- Higher fee (5% + 50c) compared to Stripe
- Less flexible API than Stripe
- Limited customization of checkout experience
- No marketplace/Connect equivalent
- Smaller ecosystem of third-party integrations
Pricing
- 5% + 50c per transaction
- No monthly fees
- Includes all tax compliance, fraud, chargebacks
When to Choose Paddle Over Stripe
- Selling to EU/UK customers (VAT compliance is complex)
- Small team without tax/legal resources
- B2C SaaS with global customer base
- Want to avoid dealing with payment disputes
---
LemonSqueezy (Stripe-Powered)
Best for: Indie developers, small SaaS, digital products, creators.
Strengths
- MoR: handles tax compliance globally
- Simple integration (embeddable checkout, overlay)
- Built-in affiliate system
- Email marketing tools included
- Nice dashboard for non-technical founders
- Acquired by Stripe in 2024 — long-term backing
Weaknesses
- 5% + 50c fees (same as Paddle)
- Less mature API compared to Stripe/Paddle
- Limited webhook events compared to Stripe
- No usage-based billing
- Limited marketplace support
Pricing
- 5% + 50c per transaction
- Free tier available
- Includes all tax compliance
When to Choose LemonSqueezy
- Solo developer or very small team
- Digital products (ebooks, courses, templates)
- Want simplest possible integration
- Don't need advanced billing features
---
RevenueCat
Best for: Mobile apps with in-app subscriptions (iOS + Android).
Strengths
- Wraps both App Store and Google Play billing
- Unified API for cross-platform subscriptions
- Experiments/A/B testing for pricing
- Detailed subscription analytics (MRR, churn, LTV)
- Handles receipt validation
- Webhook support for server-side logic
- Free tier for small apps
Weaknesses
- Mobile-only (no web checkout)
- Doesn't replace Stripe for web billing
- Limited to subscription billing models
- Can't handle one-time purchases via web
Pricing
- Free: up to $2.5K MTR
- Starter: $99/mo (up to $10K MTR)
- Pro: $499/mo (custom MTR limits)
- Enterprise: custom
Hybrid Pattern (RevenueCat + Stripe)
For apps with both mobile and web users:
// Mobile: RevenueCat handles App Store / Google Play
// Web: Stripe handles checkout and billing
// Backend: Unified user subscription state
// When RevenueCat webhook fires:
if (event.type === 'INITIAL_PURCHASE') {
await db.subscriptions.upsert({
user_id: event.app_user_id,
platform: 'mobile',
tier: mapRevenueCatToPlan(event.product_id),
status: 'active',
});
}
// When Stripe webhook fires:
if (event.type === 'customer.subscription.created') {
await db.subscriptions.upsert({
user_id: event.data.object.metadata.user_id,
platform: 'web',
tier: getTierFromPriceId(event.data.object.items.data[0].price.id),
status: 'active',
});
}---
Adyen
Best for: Enterprise businesses, high-volume processors (>$1M/yr), companies needing 250+ local payment methods.
Strengths
- Largest local payment method coverage (250+ methods globally)
- Interchange++ pricing (transparent, lower at high volume)
- Unified platform: online, in-app, and point-of-sale
- Adyen for Platforms (marketplace/multi-party equivalent to Stripe Connect)
- Strong in APAC, LATAM, and EMEA local methods
- Used by Uber, Spotify, eBay, Microsoft
Weaknesses
- Complex setup — not suitable for startups or low-volume businesses
- No built-in subscription billing (pair with Chargebee/Recurly)
- Less developer-friendly documentation compared to Stripe
- Interchange++ pricing model can be confusing for small teams
- Limited self-serve — requires sales engagement for onboarding
Pricing
- Interchange++ (transaction processing fee + scheme fee + Adyen markup)
- No monthly minimums for online payments
- Volume-dependent — gets cheaper at scale
- Typical effective rate: 1.5-2.5% for high-volume EU transactions
When to Choose Adyen Over Stripe
- Processing >$1M/yr (interchange++ becomes cheaper than Stripe flat rate)
- Need local payment methods in APAC/LATAM that Stripe doesn't support
- Unified online + point-of-sale on one platform
- Enterprise compliance requirements (SOC 2 Type II, PCI Level 1)
---
Chargebee (Billing Orchestrator)
Best for: B2B SaaS with complex billing logic — per-seat, usage-based, contract billing, multi-currency invoicing.
Strengths
- Sits on top of Stripe/Adyen/Braintree (you keep your processor)
- Advanced subscription management (trials, prorations, contract terms)
- Revenue recognition (ASC 606 / IFRS 15 compliance)
- Quote-to-cash workflow for B2B sales-led deals
- 100+ integrations (Salesforce, HubSpot, Xero, QuickBooks)
- Hosted checkout pages and customer portal included
Weaknesses
- Usage-based billing limited to 5,000 records per subscription lifetime
- Adds a billing layer = additional vendor and cost
- Can be overkill for simple tier-based SaaS
- Chargebee-managed dunning may conflict with Stripe's built-in dunning
Pricing
- Startup: Free (up to $250K revenue)
- Performance: 0.75% of revenue
- Enterprise: custom
- Plus processor fees (Stripe/Adyen) on top
When to Choose Chargebee
- B2B SaaS with per-seat + usage hybrid pricing
- Need revenue recognition / ASC 606 compliance
- Sales-led with custom contracts and quotes
- Outgrowing hand-rolled subscription logic on top of Stripe
---
Recurly (Billing Orchestrator)
Best for: B2C subscription businesses, media/streaming, companies focused on churn reduction and revenue recovery.
Strengths
- Best-in-class dunning and revenue recovery (claims 8-12% revenue uplift)
- Strong B2C subscription analytics (MRR, churn, LTV, cohort analysis)
- Multi-gateway support (Stripe, Adyen, Braintree, Worldpay)
- Flexible pricing models (flat, tiered, usage, ramp)
- Hosted payment pages with PCI compliance
- US-based support team (never outsourced)
Weaknesses
- Reporting features often criticized as limited/inaccurate
- Less flexible API compared to Chargebee for custom logic
- Weaker B2B/enterprise billing features
- No built-in revenue recognition (via partner integrations)
Pricing
- Core: Free (limited features)
- Professional: custom pricing
- Elite: custom pricing
- Plus processor fees on top
When to Choose Recurly Over Chargebee
- B2C subscriptions (media, streaming, consumer SaaS)
- Churn reduction is your top priority
- Need best-in-class dunning automation
- Prefer US-based support
---
Lago (Open-Source Billing)
Best for: AI/ML SaaS with usage-based pricing, developer-tools companies, teams wanting billing logic in their own infrastructure.
Strengths
- Open-source (MIT license), self-hostable
- Purpose-built for usage-based billing (API calls, AI tokens, compute, storage)
- Real-time metering with aggregation engine
- Composable pricing: flat + usage + per-seat in one plan
- Event-driven architecture (scales to billions of events)
- Growing fast in AI/developer-tools space
Weaknesses
- Younger platform (less battle-tested than Chargebee/Recurly)
- Smaller ecosystem of integrations
- Self-hosting requires infrastructure investment
- Cloud version still maturing
- No built-in dunning comparable to Recurly
Pricing
- Self-hosted: Free (MIT license)
- Cloud: usage-based pricing
- Premium: custom
When to Choose Lago
- AI/ML product with token-based or compute-based pricing
- Need metering flexibility beyond Stripe Billing Meters
- Want billing logic in your own infrastructure
- Open-source alignment / vendor independence
---
Braintree (DEPRECATED — Avoid for New Projects)
WARNING (Feb 2026): Braintree Drop-in SDK moves to unsupported status July 14, 2026. PayPal Powered by Braintree processing ends January 2027. SSL certificates for mobile SDKs expire March 30, 2026.
Migration paths:
- For PayPal payments → Use Stripe's PayPal payment method or PayPal Commerce Platform directly
- For card processing → Migrate to Stripe or Adyen
- For Venmo → PayPal Commerce Platform
Do NOT start new projects on Braintree.
---
Decision Matrix
| Scenario | Recommendation |
|---|---|
| Maximum API flexibility | Stripe |
| Enterprise, high-volume (>$1M/yr) | Adyen |
| SaaS with global tax needs | Paddle or Stripe Managed Payments |
| Indie developer, digital products | LemonSqueezy |
| Mobile app subscriptions | RevenueCat |
| Marketplace / multi-party payments | Stripe Connect or Adyen for Platforms |
| B2B invoicing | Stripe Invoicing |
| Complex subscription logic (per-seat + usage) | Chargebee on top of Stripe |
| B2C subscriptions, churn focus | Recurly on top of Stripe |
| Usage-based pricing (simple) | Stripe Billing Meters |
| Usage-based pricing (complex, AI tokens) | Lago (open-source) or Chargebee |
| Already on Stripe, need MoR | Wait for Stripe Managed Payments GA |
| Need it working today with MoR | Paddle |
| Hybrid mobile + web | RevenueCat (mobile) + Stripe (web) |
| Need PayPal button | Stripe PayPal method or PayPal Commerce Platform |
| Currently on Braintree | Migrate to Stripe or Adyen (Braintree EOL Jan 2027) |
| Revenue recognition / ASC 606 | Chargebee RevRec |
| Desktop software / license keys | FastSpring (MoR) |
---
Migration Paths
Stripe -> Paddle
- Export customer data from Stripe
- Create Paddle products/prices to match
- Migrate active subscriptions gradually (honor current billing periods)
- Update webhook endpoints
LemonSqueezy -> Stripe
- Natural since LemonSqueezy is Stripe-powered
- May get migration tools as Stripe integrates the acquisition
Stripe -> Stripe Managed Payments
- Expected to be a configuration change, not a full migration
- Opt-in on existing Stripe account
- Stripe handles tax + fraud + disputes going forward
---
Stripe Managed Payments (MoR) — 2025+
Stripe announced Managed Payments in 2025 as a merchant of record offering, expanding through 2026:
- Stripe handles global tax compliance (VAT in 100+ countries)
- AI-driven fraud prevention with Smart Disputes (+13% chargeback win rate)
- Transaction-level customer support
- Expanding from private preview for subscription SaaS and digital goods (NA + EU)
- Acquired LemonSqueezy in 2024 to build this capability
- Expected to be a configuration toggle on existing Stripe accounts (not a full migration)
Decision: Stripe MoR vs Paddle vs LemonSqueezy
| Factor | Stripe Managed Payments | Paddle | LemonSqueezy |
|---|---|---|---|
| Tax handling | Included | Included | Included |
| Ecosystem | Full Stripe ecosystem | Standalone | Stripe-powered |
| Pricing | TBD (likely % + fee) | 5% + 50c | 5% + 50c |
| API flexibility | Full Stripe API | Paddle API | Simpler API |
| Maturity | Private preview | Production | Production |
| Best for | Already on Stripe | Established SaaS | Indie/small SaaS |
---
RevenueCat Integration (Mobile)
For mobile apps with in-app subscriptions:
// RevenueCat SDK initialization (React Native)
import Purchases from 'react-native-purchases';
Purchases.configure({
apiKey: REVENUECAT_API_KEY,
appUserID: userId, // Match your backend user ID
});
// Check entitlements
const customerInfo = await Purchases.getCustomerInfo();
const isPro = customerInfo.entitlements.active['pro'] !== undefined;
// Purchase
const offerings = await Purchases.getOfferings();
const package = offerings.current?.availablePackages[0];
if (package) {
const result = await Purchases.purchasePackage(package);
}Regional Pricing and Multi-Currency Guide
Strategies for implementing Purchasing Power Parity (PPP) pricing, multi-currency support, tax compliance, fraud prevention, and revenue analytics for global SaaS products.
---
Overview
Regional pricing adjusts what customers pay based on their location and local purchasing power. A customer in India should not pay the same absolute price as a customer in the US for the same digital product. Implementing this correctly increases total revenue by expanding addressable markets while maintaining willingness-to-pay in high-income markets.
---
Purchasing Power Parity (PPP)
What PPP Is
PPP measures how much a basket of goods costs in different countries relative to a base currency. A product priced at $10/month in the US might have a PPP-equivalent of $3/month in India.
PPP Data Sources
| Source | Data Type | Update Frequency | Access |
|---|---|---|---|
| World Bank ICP | Official PPP conversion factors | Annual | Free API |
| OECD PPP | PPP for GDP and consumption | Annual | Free |
| Big Mac Index | Informal PPP indicator | Semi-annual | Free (The Economist) |
| Numbeo | Cost of living index | Continuous | API (paid) |
| Purchasing Power Parity API | Developer-focused PPP data | Continuous | Free tier available |
PPP Discount Tiers
// Tier-based PPP pricing (recommended over continuous PPP)
export const PPP_TIERS = {
TIER_0: {
name: 'Full Price',
discount: 0,
countries: ['US', 'CA', 'GB', 'DE', 'FR', 'AU', 'JP', 'CH', 'NO', 'SE', 'DK', 'NL', 'AT', 'BE', 'FI', 'IE', 'SG', 'NZ'],
},
TIER_1: {
name: 'Moderate Discount',
discount: 0.30, // 30% off
countries: ['ES', 'IT', 'PT', 'KR', 'CZ', 'PL', 'CL', 'CR', 'UY'],
},
TIER_2: {
name: 'Significant Discount',
discount: 0.50, // 50% off
countries: ['BR', 'MX', 'CO', 'AR', 'TH', 'MY', 'ZA', 'RO', 'BG', 'HU'],
},
TIER_3: {
name: 'Emerging Market',
discount: 0.65, // 65% off
countries: ['IN', 'ID', 'PH', 'VN', 'EG', 'NG', 'PK', 'BD', 'KE', 'ET', 'GH', 'TZ'],
},
} as const;
export function getPPPTier(countryCode: string): typeof PPP_TIERS[keyof typeof PPP_TIERS] {
for (const tier of Object.values(PPP_TIERS)) {
if (tier.countries.includes(countryCode)) return tier;
}
return PPP_TIERS.TIER_0; // Default: full price
}Implementation Strategy
Two approaches:
1. Multiple Stripe Price Objects (recommended)
- Create separate Price objects for each tier
- pros: Clean reporting, no coupon complexity
- cons: More Price objects to manage
2. Single Price + Automatic Discount
- One base price, apply PPP coupon at checkout
- pros: Fewer objects to manage
- cons: Revenue reporting requires coupon filtering---
Multi-Currency with Stripe
Price Objects Per Currency
// Create Stripe prices for each currency/tier combination
const PRICE_CONFIG: Record<string, Record<string, { amount: number; currency: string }>> = {
starter_monthly: {
default: { amount: 999, currency: 'usd' },
eur: { amount: 899, currency: 'eur' },
gbp: { amount: 799, currency: 'gbp' },
inr: { amount: 29900, currency: 'inr' }, // ~$3.59
brl: { amount: 2990, currency: 'brl' }, // ~$4.99
},
pro_monthly: {
default: { amount: 2999, currency: 'usd' },
eur: { amount: 2699, currency: 'eur' },
gbp: { amount: 2399, currency: 'gbp' },
inr: { amount: 89900, currency: 'inr' }, // ~$10.79
brl: { amount: 8990, currency: 'brl' }, // ~$14.99
},
};
// Resolve price ID based on country
export function resolvePriceId(
tier: PaidTier,
interval: BillingInterval,
countryCode: string,
): string {
const pppTier = getPPPTier(countryCode);
const currencyOverride = COUNTRY_CURRENCY_MAP[countryCode];
const key = `${tier}_${interval}`;
// Look up the appropriate pre-created Stripe Price ID
return STRIPE_PRICE_IDS[key][pppTier.name] || STRIPE_PRICE_IDS[key]['default'];
}Stripe Auto-Conversion vs Explicit Prices
| Approach | How | Pros | Cons |
|---|---|---|---|
| Explicit prices | Create Price per currency | Precise control, round numbers | More objects to manage |
| Stripe auto-conversion | Single USD price, Stripe converts | Simple setup | Odd amounts ($9.99 → $13.27 AUD), exchange rate fluctuation |
| Hybrid | Explicit for top markets, auto for rest | Balance control vs effort | Two systems to maintain |
Recommendation: Use explicit prices for your top 5-10 markets, auto-conversion for the rest.
---
Country Detection
Detection Methods
| Method | Accuracy | Latency | VPN-Resistant | Implementation |
|---|---|---|---|---|
| Vercel headers | High | 0ms | Moderate | x-vercel-ip-country |
| Cloudflare headers | High | 0ms | Moderate | CF-IPCountry |
| GeoIP service | High | 10-50ms | Moderate | MaxMind, IP2Location |
| User preference | Perfect | 0ms | Yes | User selects country |
| Billing address | Perfect | N/A | Yes | From payment method |
| Accept-Language | Low | 0ms | Yes | Browser header (infers, not detects) |
Implementation
// Server-side country detection with fallback chain
export function detectCountry(request: NextRequest): string {
// 1. User override (stored preference)
const userCountry = request.cookies.get('user_country')?.value;
if (userCountry && isValidCountryCode(userCountry)) return userCountry;
// 2. CDN header (Vercel)
const vercelCountry = request.headers.get('x-vercel-ip-country');
if (vercelCountry && isValidCountryCode(vercelCountry)) return vercelCountry;
// 3. Cloudflare header
const cfCountry = request.headers.get('cf-ipcountry');
if (cfCountry && isValidCountryCode(cfCountry)) return cfCountry;
// 4. Default
return 'US';
}
// Let users override detected country
export function PricingPage() {
const [country, setCountry] = useState(detectedCountry);
return (
<div>
<CountrySelector
value={country}
onChange={(code) => {
setCountry(code);
setCookie('user_country', code, { maxAge: 365 * 24 * 60 * 60 });
}}
/>
<PricingTable country={country} />
</div>
);
}---
Tax Implications
Tax by Region
| Region | Tax Type | Rate Range | Who Collects |
|---|---|---|---|
| US | Sales tax | 0-10.25% | Varies by state (nexus rules) |
| EU | VAT | 17-27% | Seller (or MoR) |
| UK | VAT | 20% | Seller (or MoR) |
| Canada | GST/HST/PST | 5-15% | Seller |
| Australia | GST | 10% | Seller |
| India | GST | 18% | Seller |
| Japan | Consumption tax | 10% | Seller |
Tax Handling Options
| Option | Complexity | Cost | Best For |
|---|---|---|---|
| Stripe Tax | Low | 0.5% per transaction | Self-managed Stripe |
| Merchant of Record (Paddle/LemonSqueezy) | Lowest | 5% + 50c (includes tax) | Indie/small SaaS |
| Tax calculation service (TaxJar, Avalara) | Medium | Subscription-based | Complex tax needs |
| Manual | Highest | Accounting costs | Not recommended |
// Stripe Tax integration
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
line_items: [{ price: priceId, quantity: 1 }],
automatic_tax: { enabled: true }, // Stripe calculates and collects tax
customer_update: {
address: 'auto', // Update customer address from checkout
},
// ...
});---
Currency Display and Formatting
Locale-Aware Currency Formatting
// Always use Intl.NumberFormat for currency display
export function formatPrice(
amount: number, // In smallest currency unit (cents)
currency: string, // ISO 4217 code
locale?: string, // BCP 47 locale
): string {
const displayAmount = getDisplayAmount(amount, currency);
return new Intl.NumberFormat(locale || 'en-US', {
style: 'currency',
currency: currency.toUpperCase(),
minimumFractionDigits: isZeroDecimalCurrency(currency) ? 0 : 2,
maximumFractionDigits: isZeroDecimalCurrency(currency) ? 0 : 2,
}).format(displayAmount);
}
function getDisplayAmount(amount: number, currency: string): number {
return isZeroDecimalCurrency(currency) ? amount : amount / 100;
}
// Stripe zero-decimal currencies
const ZERO_DECIMAL_CURRENCIES = new Set([
'bif', 'clp', 'djf', 'gnf', 'jpy', 'kmf', 'krw', 'mga',
'pyg', 'rwf', 'ugx', 'vnd', 'vuv', 'xaf', 'xof', 'xpf',
]);
function isZeroDecimalCurrency(currency: string): boolean {
return ZERO_DECIMAL_CURRENCIES.has(currency.toLowerCase());
}Display Examples
| Locale | Currency | Amount (cents) | Display |
|---|---|---|---|
| en-US | USD | 999 | $9.99 |
| de-DE | EUR | 899 | 8,99 EUR |
| ja-JP | JPY | 1500 | 1,500 JPY |
| pt-BR | BRL | 2990 | R$ 29,90 |
| hi-IN | INR | 29900 | 29,900.00 INR |
| ar-SA | SAR | 3750 | 37.50 SAR |
---
Fraud Prevention for Regional Pricing
Common Abuse Vectors
| Vector | Description | Detection |
|---|---|---|
| VPN abuse | User in US uses Indian VPN for lower price | IP vs billing address mismatch |
| Address spoofing | Fake billing address in low-tier country | Billing address vs card BIN country |
| Account sharing | One PPP subscription shared in high-income country | Usage patterns, concurrent sessions |
| Coupon stacking | PPP discount + promotional coupon | Enforce mutual exclusivity |
Mitigation Strategies
// Validate billing country matches pricing country
async function validatePricingEligibility(
session: Stripe.Checkout.Session,
): Promise<boolean> {
const pricingCountry = session.metadata?.pricing_country;
const billingCountry = session.customer_details?.address?.country;
const cardCountry = session.payment_intent
? await getCardCountry(session.payment_intent as string)
: null;
// Flag if card country doesn't match pricing country
if (cardCountry && pricingCountry && cardCountry !== pricingCountry) {
await flagForReview(session.id, {
reason: 'country_mismatch',
pricingCountry,
billingCountry,
cardCountry,
});
return false; // Block or flag for review
}
return true;
}
// Get card issuing country from payment method
async function getCardCountry(paymentIntentId: string): Promise<string | null> {
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
expand: ['payment_method'],
});
const pm = pi.payment_method as Stripe.PaymentMethod;
return pm?.card?.country || null;
}Prevention Checklist
Required:
✓ Compare card BIN country with pricing tier country
✓ Store pricing country in subscription metadata
✓ Block coupon stacking with PPP pricing
Recommended:
✓ Log IP country at checkout for audit trail
✓ Require billing address that matches pricing country
✓ Rate limit pricing lookups per IP
Optional:
✓ Use IP reputation service (MaxMind, IPQualityScore)
✓ Require phone verification for high-discount tiers
✓ Manual review for cards from different country than pricing---
A/B Testing Pricing Across Regions
Testing Framework
// Assign users to pricing experiments by country
interface PricingExperiment {
id: string;
name: string;
countries: string[];
variants: {
control: { priceId: string; amount: number };
treatment: { priceId: string; amount: number };
};
allocation: number; // 0-1, percentage in treatment
}
function getPricingVariant(
experiment: PricingExperiment,
userId: string,
): 'control' | 'treatment' {
// Deterministic assignment based on user ID
const hash = hashString(`${experiment.id}:${userId}`);
const bucket = (hash % 100) / 100;
return bucket < experiment.allocation ? 'treatment' : 'control';
}Metrics to Track
| Metric | Formula | What It Tells You |
|---|---|---|
| Conversion rate | Signups / Visitors | Does the price point convert? |
| Revenue per visitor | Total revenue / Visitors | Net revenue impact |
| ARPU | Revenue / Paying users | Average yield per user |
| LTV | ARPU x Average lifespan | Long-term value at this price |
| Churn rate | Churned / Total | Does price affect retention? |
Statistical Significance
Pricing A/B tests require larger sample sizes than feature tests
because conversion rate differences are small.
Recommended:
- Minimum 1,000 visitors per variant per country
- Run for at least 2 full billing cycles (2 months)
- Track LTV, not just initial conversion
- Segment by new vs returning visitors---
Revenue Analytics with Multi-Currency
Normalisation Strategy
// Always normalise to a base currency for reporting
export async function normaliseRevenue(
amount: number,
currency: string,
targetCurrency: string = 'USD',
): Promise<number> {
if (currency.toUpperCase() === targetCurrency.toUpperCase()) return amount;
const rate = await getExchangeRate(currency, targetCurrency);
return Math.round(amount * rate);
}
// Monthly revenue report
interface RevenueReport {
period: string;
totalRevenueUSD: number;
byCountry: Record<string, {
revenue: number;
currency: string;
revenueUSD: number;
subscribers: number;
arpu: number;
}>;
byTier: Record<string, {
subscribers: number;
mrrUSD: number;
}>;
}Dashboard Considerations
| Metric | Display | Notes |
|---|---|---|
| MRR | Always in base currency (USD) | Normalise at time of transaction |
| ARPU by country | Local currency + USD equivalent | Show both for context |
| Conversion by tier | Separate by PPP tier | Don't mix high/low price cohorts |
| Revenue growth | USD-normalised | Exchange rate fluctuation can mask real growth |
| Churn rate | Segment by PPP tier | Different retention patterns by market |
---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| Continuous PPP (per-country price) | 200+ prices to manage | Use 3-4 PPP tiers |
| No fraud prevention | Users VPN to get low prices | Validate card BIN vs pricing country |
| Auto-conversion only | Odd price amounts ($13.27) | Use explicit prices for top markets |
| Ignoring zero-decimal currencies | JPY 999 displayed as $9.99 | Check Stripe zero-decimal currency list |
| Same tax treatment everywhere | Non-compliant in many jurisdictions | Use Stripe Tax or MoR |
| Not tracking PPP tier in analytics | Cannot segment revenue by market | Store pricing_country in metadata |
| A/B testing prices for too short | Unreliable results | Run for 2+ billing cycles |
---
Cross-References
- subscription-lifecycle.md — Subscription states, upgrades, dunning
- stripe-patterns.md — Checkout session creation, price ID resolution
- webhook-reliability-patterns.md — Webhook handling for payment events
- testing-patterns.md — Testing regional pricing flows
- platform-comparison.md — MoR platforms that handle tax automatically
Stripe Implementation Patterns
Detailed patterns, error handling, and lessons learned from production Stripe integrations.
---
Webhook Handler Architecture
Handler Module Organization
Organize webhook handlers by domain in separate files:
lib/stripe/
index.ts # Client init, types, tier model, feature matrix
client.ts # Browser-side Stripe.js loader
handlers/
index.ts # Re-export all handlers
types.ts # Shared types (WebhookContext, status mapping, UUID validation)
checkout.ts # checkout.session.completed, checkout.session.expired
subscription.ts # subscription.created, subscription.updated, subscription.deleted
invoice.ts # invoice.payment_succeeded, invoice.payment_failed
referral.ts # Referral reward processingHandler Contract
Each handler: 1. Receives the Stripe event object + a DB client 2. Extracts relevant data (customer ID, user ID from metadata) 3. Validates user_id format (UUID regex) 4. Upserts data to the database (idempotent) 5. Tracks analytics events 6. Throws on DB errors (so webhook returns 500 and Stripe retries)
UUID Validation
Always validate metadata.user_id before using it in DB queries:
const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isValidUUID(userId: string): boolean {
return UUID_REGEX.test(userId);
}
// In handler:
if (!userId || !isValidUUID(userId)) {
log.error('Invalid user_id in metadata', { sessionId, userId });
throw new Error('Invalid user_id format');
}---
Error Handling Patterns
Structured Logging for Webhooks
const log = stripeLogger.child({ handler: 'checkout' });
// Always include event context in logs
log.error('DB upsert failed', {
event: 'checkout.session.completed',
sessionId: session.id,
userId,
customerId,
}, error);Error Categories
| Error Type | Action | Retry? |
|---|---|---|
| Signature verification failed | Return 400 | No (client error) |
| Missing metadata (user_id) | Log + throw | Yes (Stripe retries) |
| DB upsert failed | Log + throw | Yes |
| Analytics capture failed | Log + continue | No (non-critical) |
| Stripe API rate limit | Exponential backoff | Yes |
| Card declined | Return error to user | No |
Webhook Response Strategy
200: Event processed successfully
400: Bad request (missing signature, malformed payload)
500: Handler failed (Stripe will retry for 3 days)Never return 200 for an event you failed to process — Stripe needs to know to retry.
---
Trial Management
Trial Qualification Logic
// Only first-time subscribers get a trial
const hasHadPaidSubscription = subscription?.tier && subscription.tier !== 'free';
const trialDays = hasHadPaidSubscription ? undefined : 7;Trial Events Timeline
Day 0: trial_started (subscription.created with status='trialing')
Day 4: trial_will_end (3 days before expiry — send retention email)
Day 7: subscription becomes 'active' (first charge) or 'canceled' (no card)---
Proration Strategies
| Strategy | Use When | Stripe Setting |
|---|---|---|
create_prorations | Upgrades (charge difference) | Default, fairest |
none | Downgrades (wait until next period) | Prevents credits |
always_invoice | Immediate billing change | Instant charge |
---
Subscription Database Schema
CREATE TABLE subscriptions (
user_id UUID PRIMARY KEY REFERENCES auth.users(id),
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
stripe_price_id TEXT,
tier TEXT NOT NULL DEFAULT 'free',
status TEXT NOT NULL DEFAULT 'active',
billing_interval TEXT DEFAULT 'month',
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
cancel_at_period_end BOOLEAN DEFAULT FALSE,
canceled_at TIMESTAMPTZ,
trial_start TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for webhook lookups (customer ID -> user)
CREATE INDEX idx_subscriptions_stripe_customer
ON subscriptions(stripe_customer_id);---
Handling Duplicate Subscriptions
Prevent users from having multiple active subscriptions:
// Before creating checkout, check for existing active subscription
if (hasActivePaidSubscription && existingSubscriptionId) {
// Use upgrade flow instead of new checkout
await stripe.subscriptions.update(existingSubscriptionId, {
items: [{ id: existingItemId, price: newPriceId }],
proration_behavior: 'create_prorations',
});
return; // Skip checkout creation
}---
HTTPS Enforcement
let appUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
if (process.env.NODE_ENV === 'production' && appUrl.startsWith('http://')) {
console.warn('[checkout] NEXT_PUBLIC_APP_URL should use HTTPS in production');
appUrl = appUrl.replace('http://', 'https://');
}---
Stripe API Version Migration Checklist
When upgrading Stripe API version:
1. [ ] Read the changelog for breaking changes 2. [ ] Check invoice.subscription -> invoice.parent.subscription_details migration 3. [ ] Check subscription.current_period_start/end -> subscription.items.data[0].current_period_start/end 4. [ ] Update all type casts in webhook handlers 5. [ ] Test with Stripe CLI using the new version 6. [ ] Pin the version in new Stripe(key, { apiVersion: 'YYYY-MM-DD.codename' }) 7. [ ] Verify webhook event shapes match your handlers
---
Customer Portal Configuration
Configure the Stripe Customer Portal in Dashboard > Settings > Customer Portal:
- [ ] Enable subscription cancellation
- [ ] Enable plan switching (if multiple tiers)
- [ ] Enable payment method updates
- [ ] Set cancellation proration to "None" (prevents credits)
- [ ] Configure custom return URL
- [ ] Disable invoice history if not needed
- [ ] Configure cancellation survey questions
---
Retention Workflows Triggered by Payment Events
| Event | Retention Action |
|---|---|
checkout.session.expired | 24h nudge email ("You left something behind") |
customer.subscription.trial_will_end | 3-day trial ending email with value highlights |
invoice.payment_failed | Dunning email sequence (day 0, 3, 7) |
customer.subscription.deleted | Win-back email at 7 and 30 days |
| Checkout started but no completion (24h) | Post-onboarding checkout nudge |
---
Stripe Billing Meters (Usage-Based Billing)
For usage-based pricing (API calls, AI tokens, storage):
// Report usage to Stripe Meter
await stripe.billing.meterEvents.create({
event_name: 'api_calls',
payload: {
stripe_customer_id: customerId,
value: '150', // Number of API calls
},
});This is an emerging pattern (2025+) for hybrid subscription + usage billing.
---
Webhook Idempotency
Stripe retries webhooks for up to 3 days. Your handlers must be idempotent.
// Pattern: Upsert instead of insert
await supabase
.from('subscriptions')
.upsert(
{
user_id: userId,
stripe_customer_id: customerId,
tier,
status,
},
{ onConflict: 'user_id' } // Idempotent — same user_id = update
);// Pattern: Check-before-act for one-time operations
if (invoice.billing_reason !== 'subscription_create') {
return; // Only process first payment, not renewals
}---
Status Mapping
// Map Stripe statuses to your internal statuses
export function mapStripeStatus(status: Stripe.Subscription.Status): SubscriptionStatus {
const map: Record<Stripe.Subscription.Status, SubscriptionStatus> = {
active: 'active',
trialing: 'trialing',
canceled: 'canceled',
past_due: 'past_due',
incomplete: 'incomplete',
incomplete_expired: 'canceled',
unpaid: 'past_due',
paused: 'canceled',
};
return map[status] || 'incomplete';
}---
Dunning / Failed Payment Recovery
Smart Retry Configuration (Stripe Dashboard)
Configure in Dashboard > Settings > Billing > Subscriptions > Manage failed payments:
| Retry | Timing | Notes |
|---|---|---|
| 1st attempt | 1 day after failure | Stripe sends invoice.payment_failed |
| 2nd attempt | 3 days after first retry | Customer notified automatically |
| 3rd attempt | 5 days after second retry | Final automated attempt |
| After final | Cancel or mark unpaid | Configure in dashboard |
Dunning Email Sequence
| Day | Event | CTA | |
|---|---|---|---|
| 0 | invoice.payment_failed | "Your payment failed" | Update payment method (billing portal link) |
| 3 | 2nd retry fails | "Action required: subscription at risk" | Update payment method |
| 7 | 3rd retry fails | "Last chance to keep your subscription" | Update payment method |
| 8 | customer.subscription.deleted | "We're sorry to see you go" | Reactivation link |
| 30 | (scheduled) | "We'd love to have you back" | Win-back offer with discount |
Webhook Handler for Failed Payments
async function handlePaymentFailed(invoice: Stripe.Invoice) {
const subscriptionDetails = invoice.parent?.subscription_details;
const subscriptionId =
typeof subscriptionDetails?.subscription === 'string'
? subscriptionDetails.subscription
: subscriptionDetails?.subscription?.id;
if (!subscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const userId = subscription.metadata?.user_id;
if (!userId || !isValidUUID(userId)) return;
// Update local status to past_due
await db.subscriptions.update({
status: 'past_due',
updated_at: new Date(),
}).where({ user_id: userId });
// Determine retry count from invoice attempt_count
const attemptCount = invoice.attempt_count || 1;
// Send appropriate dunning email
await sendDunningEmail(userId, {
attempt: attemptCount,
nextRetry: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000)
: null,
billingPortalUrl: await createBillingPortalUrl(subscription.customer as string),
});
}Billing Portal Link for Self-Service Recovery
async function createBillingPortalUrl(customerId: string): Promise<string> {
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/subscription`,
});
return session.url;
}Grace Period Pattern
// Allow access during grace period (past_due but not yet canceled)
export function hasActiveAccess(
status: SubscriptionStatus,
tier: SubscriptionTier
): boolean {
if (tier === 'free') return true;
// Active, trialing, or within dunning grace period
return status === 'active' || status === 'trialing' || status === 'past_due';
}---
Security Checklist
- [ ] Webhook signature verified with
constructEvent()on every request - [ ]
STRIPE_WEBHOOK_SECRETstored in environment, never in code - [ ] Webhook endpoint returns 200 quickly (offload heavy work)
- [ ] UUID validation on all
metadata.user_idvalues before DB operations - [ ] HTTPS enforced in production for checkout URLs
- [ ] Service role client used for webhook DB operations (bypasses RLS)
- [ ] No PII logged (mask customer IDs in non-error logs)
- [ ] Idempotency keys used for critical mutations
- [ ] Rate limiting on checkout endpoint
- [ ] Existing subscription check before creating new checkout
---
Stripe API Version Notes (2025-2026)
| Version | Key Changes |
|---|---|
2026-01-28.clover | Subscription pause support, Reserve resources, per-payment-method capture config |
2025-11-17.clover | Invoice parent.subscription_details replaces top-level subscription field |
2025+ | Managed Payments (MoR) — expanding from private preview |
2024+ | Dynamic payment methods by default when payment_method_types omitted |
API v2 (/v2) | Improved idempotency — re-executes failed requests instead of returning cached error |
Invoice API Breaking Change
// OLD (pre-2025): invoice.subscription was a string
const subscriptionId = invoice.subscription;
// NEW (2025+): access via parent.subscription_details
const subscriptionDetails = invoice.parent?.subscription_details;
const subscriptionId =
typeof subscriptionDetails?.subscription === 'string'
? subscriptionDetails.subscription
: subscriptionDetails?.subscription?.id;Subscription Lifecycle Management
Complete guide to managing subscription states, transitions, trials, upgrades/downgrades, cancellation, reactivation, dunning, grace periods, pausing, and database schema design for Stripe-based subscription systems.
---
Subscription State Machine
States
| State | Description | Access? | Billing? |
|---|---|---|---|
| trialing | Free trial period, no charge yet | Full access | No charge until trial ends |
| active | Paid and current | Full access | Recurring charges |
| past_due | Payment failed, retrying | Configurable (grace) | Retry schedule active |
| canceled | Cancellation requested or completed | Until period end (if cancel-at-period-end) | No further charges |
| incomplete | Initial payment failed | No access | Awaiting first payment |
| incomplete_expired | Initial payment never completed | No access | Dead |
| unpaid | All retry attempts exhausted | No access | No further charges |
| paused | Subscription paused by customer or system | No access (configurable) | No charges during pause |
State Transition Diagram
┌─────────────┐
Checkout ──────>│ incomplete │──── payment succeeds ────┐
└──────┬──────┘ │
│ expires (23h) │
┌──────▼──────────┐ │
│incomplete_expired│ │
└─────────────────┘ │
│
Checkout ──────────────────────────>┌──────────┐<─────────┘
(with trial)──────────────────────>│ trialing │
└─────┬────┘
│ trial ends + payment succeeds
▼
┌─────────> active <──────────┐
│ │ │ │
│ payment │ │ payment │ payment
│ succeeds│ │ fails │ succeeds
│ │ ▼ │
│ │ past_due ───────┘
│ │ │
│ │ │ all retries fail
│ │ ▼
│ │ unpaid
│ │
│ cancel │
│ ▼
│ canceled ───> (reactivate) ──> active
│ │
│ pause │
│ ▼
└────── pausedWebhook Events Per Transition
| Transition | Webhook Event | Key Data |
|---|---|---|
| Checkout → trialing | customer.subscription.created | status=trialing, trial_end |
| Checkout → active | customer.subscription.created | status=active |
| Checkout → incomplete | customer.subscription.created | status=incomplete |
| trialing → active | customer.subscription.updated | status=active (trial ended) |
| trialing → active | invoice.payment_succeeded | billing_reason=subscription_create |
| active → past_due | invoice.payment_failed | attempt_count, next_payment_attempt |
| past_due → active | invoice.payment_succeeded | Recovery payment |
| past_due → unpaid | customer.subscription.updated | status=unpaid |
| active → canceled | customer.subscription.updated | cancel_at_period_end=true |
| canceled → ended | customer.subscription.deleted | Subscription removed |
| active → paused | customer.subscription.paused | pause_collection |
| paused → active | customer.subscription.resumed | Active again |
---
Trial Management
Trial Configuration
// Create subscription with trial
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
subscription_data: {
trial_period_days: 14,
trial_settings: {
end_behavior: {
missing_payment_method: 'cancel', // or 'pause' or 'create_invoice'
},
},
metadata: { user_id: userId },
},
// Require payment method during trial signup for better conversion
payment_method_collection: 'always',
success_url: `${appUrl}/welcome?trial=true`,
cancel_url: `${appUrl}/pricing`,
});trial_will_end Handling
Stripe sends customer.subscription.trial_will_end 3 days before trial expires.
async function handleTrialWillEnd(subscription: Stripe.Subscription): Promise<void> {
const userId = subscription.metadata.user_id;
if (!userId || !isValidUUID(userId)) return;
const trialEnd = subscription.trial_end
? new Date(subscription.trial_end * 1000)
: null;
// Send retention email with value highlights
await sendEmail(userId, 'trial-ending', {
trialEndDate: trialEnd?.toLocaleDateString(),
daysRemaining: 3,
planName: getTierFromPriceId(subscription.items.data[0].price.id),
billingPortalUrl: await createBillingPortalUrl(subscription.customer as string),
});
// Track for analytics
captureEvent(userId, 'trial_ending_notification_sent', {
subscription_id: subscription.id,
trial_end: trialEnd?.toISOString(),
});
}Trial Conversion Optimisation
| Strategy | Implementation | Impact |
|---|---|---|
| Require payment method | payment_method_collection: 'always' | 2-3x higher conversion |
| Send value email on day 3 | Highlight features used during trial | +10-20% conversion |
| Send trial-ending email (day 11/14) | Urgency + billing portal link | +5-10% conversion |
| Offer discounted first month | Apply coupon on trial-end | +15-25% conversion (lower ARPU) |
| Show usage stats in-app | "You've used X feature 15 times" | +10-15% conversion |
---
Upgrade / Downgrade Flows
Proration Strategies
| Strategy | Stripe Value | Use Case | Customer Experience |
|---|---|---|---|
| Create prorations | create_prorations | Upgrades | Charged difference immediately |
| None | none | Downgrades | Change at next billing cycle |
| Always invoice | always_invoice | Immediate billing | Instant charge/credit |
Upgrade Flow
async function upgradeSubscription(
userId: string,
newTier: PaidTier,
interval: BillingInterval,
): Promise<{ success: boolean; prorationAmount?: number }> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
const stripeSubscription = await stripe.subscriptions.retrieve(
sub.stripe_subscription_id,
);
const newPriceId = getPriceId(newTier, interval, sub.country_code);
// Preview proration before applying
const preview = await stripe.invoices.createPreview({
customer: sub.stripe_customer_id,
subscription: sub.stripe_subscription_id,
subscription_items: [{
id: stripeSubscription.items.data[0].id,
price: newPriceId,
}],
subscription_proration_behavior: 'create_prorations',
});
const prorationAmount = preview.total; // Amount in cents
// Apply upgrade
await stripe.subscriptions.update(sub.stripe_subscription_id, {
items: [{
id: stripeSubscription.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'create_prorations',
metadata: {
user_id: userId,
previous_tier: sub.tier,
new_tier: newTier,
upgrade_date: new Date().toISOString(),
},
});
// Optimistic local update (webhook will confirm)
await db.subscriptions.update(userId, { tier: newTier, billing_interval: interval });
return { success: true, prorationAmount };
}Downgrade Flow
async function downgradeSubscription(
userId: string,
newTier: PaidTier,
interval: BillingInterval,
): Promise<{ success: boolean; effectiveDate: Date }> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
const stripeSubscription = await stripe.subscriptions.retrieve(
sub.stripe_subscription_id,
);
const newPriceId = getPriceId(newTier, interval, sub.country_code);
// Schedule downgrade at end of current period (no proration)
const updatedSub = await stripe.subscriptions.update(sub.stripe_subscription_id, {
items: [{
id: stripeSubscription.items.data[0].id,
price: newPriceId,
}],
proration_behavior: 'none', // No credit — change at renewal
metadata: {
user_id: userId,
previous_tier: sub.tier,
new_tier: newTier,
downgrade_scheduled: new Date().toISOString(),
},
});
const effectiveDate = new Date(updatedSub.current_period_end * 1000);
return { success: true, effectiveDate };
}---
Cancellation Patterns
Cancel-at-Period-End (Recommended Default)
async function cancelSubscription(userId: string): Promise<{ endsAt: Date }> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
const updatedSub = await stripe.subscriptions.update(
sub.stripe_subscription_id,
{ cancel_at_period_end: true },
);
const endsAt = new Date(updatedSub.current_period_end * 1000);
// Update local DB
await db.subscriptions.update(userId, {
cancel_at_period_end: true,
canceled_at: new Date(),
});
// Send cancellation confirmation + retention offer
await sendEmail(userId, 'cancellation-confirmation', {
endsAt: endsAt.toLocaleDateString(),
reactivateUrl: `${appUrl}/settings/subscription`,
});
return { endsAt };
}Immediate Cancellation
// Use only when customer demands immediate cancellation
async function cancelImmediately(userId: string): Promise<void> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
// This triggers customer.subscription.deleted webhook
await stripe.subscriptions.cancel(sub.stripe_subscription_id, {
prorate: true, // Issue credit for unused time
});
// Webhook handler will reset to free tier
}Cancellation with Survey
// Collect cancellation reason before processing
interface CancellationReason {
reason: 'too_expensive' | 'missing_features' | 'found_alternative' | 'not_using' | 'other';
feedback?: string;
}
async function cancelWithReason(
userId: string,
cancellation: CancellationReason,
): Promise<{ endsAt: Date; offerApplied?: string }> {
// Store reason for analytics
await db.cancellationReasons.insert({
user_id: userId,
reason: cancellation.reason,
feedback: cancellation.feedback,
created_at: new Date(),
});
// Offer retention discount for price-sensitive users
if (cancellation.reason === 'too_expensive') {
const offer = await applyRetentionOffer(userId, '25_percent_3_months');
if (offer.accepted) {
return { endsAt: offer.newPeriodEnd, offerApplied: '25% off for 3 months' };
}
}
return cancelSubscription(userId);
}---
Reactivation and Win-Back
Reactivate Before Period End
// User changed their mind before subscription ends
async function reactivateSubscription(userId: string): Promise<void> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id || !sub.cancel_at_period_end) {
throw new Error('No cancellation to reverse');
}
await stripe.subscriptions.update(sub.stripe_subscription_id, {
cancel_at_period_end: false,
});
await db.subscriptions.update(userId, {
cancel_at_period_end: false,
canceled_at: null,
});
}Win-Back Expired Subscriptions
// Create new subscription for previously-churned user
async function winBackSubscription(
userId: string,
tier: PaidTier,
interval: BillingInterval,
discountCoupon?: string,
): Promise<{ checkoutUrl: string }> {
const sub = await db.subscriptions.findByUserId(userId);
const session = await stripe.checkout.sessions.create({
customer: sub?.stripe_customer_id || undefined,
mode: 'subscription',
line_items: [{ price: getPriceId(tier, interval), quantity: 1 }],
discounts: discountCoupon ? [{ coupon: discountCoupon }] : undefined,
metadata: {
user_id: userId,
win_back: 'true',
previous_tier: sub?.tier || 'unknown',
},
success_url: `${appUrl}/welcome-back`,
cancel_url: `${appUrl}/pricing`,
});
return { checkoutUrl: session.url! };
}Win-Back Email Sequence
| Day | Offer | |
|---|---|---|
| 7 | "We miss you" | None — highlight new features |
| 14 | "Here's what you're missing" | Show usage stats from when they were active |
| 30 | "Come back with 25% off" | 25% discount for 3 months |
| 60 | "Last chance: 40% off" | 40% discount for 3 months |
| 90 | Final attempt | 50% off first month |
---
Dunning Management
Retry Schedule (Stripe Default)
| Attempt | Timing | Webhook |
|---|---|---|
| 1st | Immediately | invoice.payment_failed (attempt_count=1) |
| 2nd | 3 days later | invoice.payment_failed (attempt_count=2) |
| 3rd | 5 days after 2nd | invoice.payment_failed (attempt_count=3) |
| 4th | 7 days after 3rd | invoice.payment_failed (attempt_count=4) |
| Final | Configured action | customer.subscription.deleted or mark unpaid |
Smart Retries
Stripe Smart Retries use ML to retry at the optimal time (when the card is most likely to succeed). Enable in Dashboard > Settings > Billing > Subscriptions.
Grace Period Pattern
// During dunning, maintain access to reduce churn
export function hasActiveAccess(
status: SubscriptionStatus,
tier: SubscriptionTier,
): boolean {
if (tier === 'free') return true;
switch (status) {
case 'active':
case 'trialing':
return true;
case 'past_due':
return true; // Grace period — maintain access during retries
case 'canceled':
case 'unpaid':
case 'incomplete':
return false;
default:
return false;
}
}
// Show non-blocking banner during grace period
export function shouldShowDunningBanner(status: SubscriptionStatus): boolean {
return status === 'past_due';
}Customer Notification During Dunning
async function handlePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
const subscriptionId = getSubscriptionIdFromInvoice(invoice);
if (!subscriptionId) return;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const userId = subscription.metadata.user_id;
if (!userId || !isValidUUID(userId)) return;
await db.subscriptions.update(userId, { status: 'past_due' });
const attemptCount = invoice.attempt_count || 1;
const billingPortalUrl = await createBillingPortalUrl(
subscription.customer as string,
);
// Escalating urgency in emails
const emailTemplates: Record<number, string> = {
1: 'payment-failed-gentle', // "Your payment didn't go through"
2: 'payment-failed-reminder', // "Action required: update payment"
3: 'payment-failed-urgent', // "Last chance to keep your subscription"
4: 'payment-failed-final', // "Your subscription will be canceled"
};
await sendEmail(userId, emailTemplates[attemptCount] || 'payment-failed-gentle', {
attemptCount,
billingPortalUrl,
nextRetryDate: invoice.next_payment_attempt
? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString()
: null,
});
}---
Pause and Resume
Stripe Subscription Pause (2026+)
// Pause subscription (no charges, configurable access)
async function pauseSubscription(
userId: string,
resumeDate?: Date,
): Promise<void> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
await stripe.subscriptions.update(sub.stripe_subscription_id, {
pause_collection: {
behavior: 'void', // or 'keep_as_draft' or 'mark_uncollectible'
resumes_at: resumeDate ? Math.floor(resumeDate.getTime() / 1000) : undefined,
},
});
await db.subscriptions.update(userId, {
status: 'paused',
paused_at: new Date(),
resume_at: resumeDate || null,
});
}
// Resume subscription
async function resumeSubscription(userId: string): Promise<void> {
const sub = await db.subscriptions.findByUserId(userId);
if (!sub?.stripe_subscription_id) throw new Error('No active subscription');
await stripe.subscriptions.update(sub.stripe_subscription_id, {
pause_collection: '', // Clear pause
});
await db.subscriptions.update(userId, {
status: 'active',
paused_at: null,
resume_at: null,
});
}---
Database Schema
CREATE TABLE subscriptions (
user_id UUID PRIMARY KEY REFERENCES auth.users(id),
-- Stripe identifiers
stripe_customer_id TEXT UNIQUE,
stripe_subscription_id TEXT UNIQUE,
stripe_price_id TEXT,
-- Subscription state
tier TEXT NOT NULL DEFAULT 'free'
CHECK (tier IN ('free', 'starter', 'pro', 'enterprise')),
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'trialing', 'canceled', 'past_due',
'incomplete', 'unpaid', 'paused')),
billing_interval TEXT DEFAULT 'month'
CHECK (billing_interval IN ('month', 'year')),
-- Period tracking
current_period_start TIMESTAMPTZ,
current_period_end TIMESTAMPTZ,
-- Cancellation
cancel_at_period_end BOOLEAN DEFAULT FALSE,
canceled_at TIMESTAMPTZ,
-- Trial
trial_start TIMESTAMPTZ,
trial_end TIMESTAMPTZ,
-- Pause
paused_at TIMESTAMPTZ,
resume_at TIMESTAMPTZ,
-- Regional pricing
country_code TEXT,
-- Timestamps
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for webhook lookups
CREATE INDEX idx_sub_stripe_customer ON subscriptions(stripe_customer_id);
CREATE INDEX idx_sub_stripe_subscription ON subscriptions(stripe_subscription_id);
CREATE INDEX idx_sub_status ON subscriptions(status) WHERE status != 'active';
-- Cancellation analytics
CREATE TABLE cancellation_reasons (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
reason TEXT NOT NULL,
feedback TEXT,
tier_at_cancel TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Subscription events log (audit trail)
CREATE TABLE subscription_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id),
event_type TEXT NOT NULL,
stripe_event_id TEXT UNIQUE, -- Idempotency key
previous_state JSONB,
new_state JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_sub_events_user ON subscription_events(user_id, created_at DESC);---
Anti-Patterns
| Anti-Pattern | Problem | Fix |
|---|---|---|
| No grace period during dunning | Users lose access immediately on failed payment | Keep access during past_due state |
| Immediate cancellation as default | No time for win-back | Use cancel_at_period_end |
| No trial qualification check | Users abuse trials on re-signup | Track previous subscriptions |
| Proration on downgrades | Unexpected credits, accounting complexity | Use proration_behavior: 'none' for downgrades |
| No cancellation reason collection | Cannot improve retention | Survey before cancellation |
| Trusting client-side status | Stale or manipulated data | Always check server/webhook as source of truth |
| Missing audit trail | Cannot debug subscription issues | Log all state transitions |
| No dunning emails | Users do not know payment failed | Send escalating notifications |
---
Cross-References
- stripe-patterns.md — Webhook handler architecture, idempotency, status mapping
- webhook-reliability-patterns.md — Idempotency, retry handling, dead letter queues
- regional-pricing-guide.md — Multi-currency pricing for subscription tiers
- testing-patterns.md — Testing subscription lifecycle with Stripe CLI
- platform-comparison.md — Dunning and subscription features per platform
Payment Testing Patterns
E2E testing patterns for Stripe checkout flows, webhook testing, and subscription state management.
---
Stripe CLI for Local Webhook Testing
# Forward events to local webhook endpoint
stripe listen --forward-to localhost:3001/api/stripe/webhook
# Trigger specific events for testing
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.payment_failed---
Test Card Numbers
| Card | Scenario |
|---|---|
4242 4242 4242 4242 | Successful payment |
4000 0000 0000 0002 | Declined |
4000 0000 0000 3220 | 3D Secure required |
4000 0025 0000 3155 | Requires authentication |
4000 0000 0000 9995 | Insufficient funds |
4000 0000 0000 0077 | Charge succeeds, dispute created |
4000 0000 0000 0341 | Attaching card to customer fails |
4000 0000 0000 3063 | 3D Secure 2 authentication required |
---
Playwright E2E for Checkout Flows
When testing Stripe Checkout with dynamic payment methods enabled, Card may not be the default view. Use multiple strategies:
async function fillStripeCheckout(page: Page) {
await page.waitForURL(/checkout\.stripe\.com/, { timeout: 30000 });
await page.waitForTimeout(3000);
// With dynamic payment methods, Card might not be pre-selected
// Use evaluate() to click the Card radio — Stripe's custom components
// may not respond to standard Playwright clicks
await page.evaluate(() => {
const radios = document.querySelectorAll('[role="radio"]');
for (const radio of radios) {
if (radio.textContent?.includes('Card')) {
(radio as HTMLElement).click();
return;
}
}
});
await page.waitForTimeout(2000);
await page.locator('input[name="cardNumber"]').fill('4242424242424242');
await page.locator('input[name="cardExpiry"]').fill('1234');
await page.locator('input[name="cardCvc"]').fill('123');
const nameInput = page.locator('input[name="billingName"]');
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await nameInput.fill('Test User');
}
await page.locator('button:has-text("Start trial"), button:has-text("Subscribe")').first().click();
await page.waitForURL(/localhost/, { timeout: 60000 });
}---
Subscription State Sync for Tests
// When Stripe CLI isn't forwarding webhooks, sync state manually
async function syncSubscriptionState(email: string) {
const customers = await stripe.customers.list({ email, limit: 1 });
const customerId = customers.data[0]?.id;
if (!customerId) return;
const subscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 10,
});
const active = subscriptions.data.find(
s => s.status === 'active' || s.status === 'trialing'
);
// Upsert to local DB with current Stripe state
await db.subscriptions.upsert({
stripe_customer_id: customerId,
stripe_subscription_id: active?.id,
tier: active ? getTierFromPriceId(active.items.data[0].price.id) : 'free',
status: active ? mapStripeStatus(active.status) : 'active',
});
}---
Test Environment Setup Checklist
- [ ] Stripe test mode API keys in
.env.local - [ ] Stripe CLI installed and authenticated (
stripe login) - [ ] Webhook endpoint configured for local forwarding
- [ ] Test customer created in Stripe Dashboard (test mode)
- [ ] Test price IDs configured in environment variables
- [ ] Playwright configured with extended timeouts for Stripe redirects (30s+)
- [ ] Database seeded with test user matching Stripe test customer