Payments
Universal Payment Adapter — 5 providers, one interface. Switch providers with one env var. Zero code changes.
How It Works
Application Code
↓
getPaymentProvider() ← reads PAYMENT_PROVIDER env var
↓
Returns Stripe|Polar|LemonSqueezy|Dodo|Creem
↓
All implement PaymentProvider interfaceApp code never references a specific provider. Factory pattern abstracts the entire layer.
Supported Providers
| Provider | Env Value | SDK/API | MoR | Webhook Secret |
|---|---|---|---|---|
| Stripe | stripe | Stripe Node SDK | No | STRIPE_WEBHOOK_SECRET |
| Polar | polar | @polar-sh/sdk | Yes | POLAR_WEBHOOK_SECRET |
| Lemon Squeezy | lemon-squeezy | REST + HMAC | Yes | LEMONSQUEEZY_WEBHOOK_SECRET |
| Dodo Payments | dodo | REST + HMAC | Yes | DODO_WEBHOOK_SECRET |
| Creem | creem | REST + HMAC | Yes | CREEM_WEBHOOK_SECRET |
MoR = Merchant of Record. Polar, Lemon Squeezy, Dodo, Creem handle tax, invoicing, and compliance. Stripe requires you to handle these (or use Stripe Tax).
Stripe Invoice Formatting: The Stripe adapter automatically strips auto-generated quantity and pricing interval suffixes (e.g., "1 × Starter Tier (at $29.00 / month)") from line items via
formatInvoiceDescription()to ensure clean receipt rendering.
️ Configuration
PAYMENT_PROVIDER=stripe # One var to switch providers
# Stripe
STRIPE_SECRET_KEY=sk_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_FREE_MONTHLY_PRICE_ID=price_...
STRIPE_TIER_TWO_MONTHLY_PRICE_ID=price_...
STRIPE_TIER_THREE_MONTHLY_PRICE_ID=price_...
# + yearly and lifetime variants
# Polar
POLAR_API_KEY=...
POLAR_WEBHOOK_SECRET=...
POLAR_ORGANIZATION_ID=...
# Lemon Squeezy / Dodo / Creem — same patternPaymentProvider Interface
interface PaymentProvider {
createCheckoutSession(params: CheckoutParams): Promise<{ url: string }>;
createBillingPortalSession(params: PortalParams): Promise<{ url: string }>;
handleWebhook(
body: string,
headers: Record<string, string>,
): Promise<WebhookEvent>;
getSubscription(subscriptionId: string): Promise<Subscription | null>;
cancelSubscription(
subscriptionId: string,
options?: { immediate?: boolean },
): Promise<void>;
}Cancellation default: All 5 providers use graceful cancellation (cancel at period end). Users retain access until billing period ends. Immediate cancellation ({ immediate: true }) only triggers during account deletion.
Deletion safety: Account deletion is fail-closed for paid users. performAccountDeletion() selects every active-like subscription for the user (active, trialing, past_due, unpaid, paused), resolves the stored provider with getPaymentProviderByType(), and cancels each provider subscription immediately. If any active subscription cannot be matched or canceled, the function returns failure before storage, waitlist, profile, or Supabase Auth deletion.
Full types in lib/payments/types.ts.
Payment Readiness
lib/payments/readiness.ts is the single source of truth for deployment-time
payment configuration checks. It reads PAYMENT_PROVIDER, normalizes the
selected provider, and returns the missing environment variable names required
for that provider.
The readiness helper checks:
- provider API key/access token;
- provider webhook secret, including
DODO_WEBHOOK_SECRET; - active paid-tier monthly and yearly price/product IDs when subscriptions are enabled;
- active paid-tier lifetime price/product IDs when lifetime purchases are enabled.
It does not call provider APIs and never returns configured secret values.
Detailed dependency readiness belongs to authenticated super-admin/oRPC health
surfaces. The public /api/health route only returns minimal liveness metadata.
Billing Models
Configure in lib/config/billing.ts:
| Model | Config Key | How It Works |
|---|---|---|
| Subscriptions | billing.subscriptions | Monthly/yearly recurring |
| Lifetime | billing.lifetimePurchases | One-time, permanent access |
| AI credits | billing.credits | Per-message deduction (FOR UPDATE locking) |
| Seat-based | billing.seats | Per-member org pricing |
| Free trials | billing.trial | N-day trial |
| Digital products | billing.digitalProducts | One-time downloads |
Lifetime-First Entitlement: The billing engine prioritizes lifetime purchases over active recurring subscriptions. Once a user holds a lifetime tier, redundant checkout options for that tier (or downgrades) are locked in the UI.
Seat billing requires orgs enabled.
isSeatsEnabled()auto-returnsfalsewhenfeatures.organizationsisfalse.
️ Pricing Tiers
Single source of truth in lib/config/pricing.ts:
export const TIER_IDS = {
TIER_ONE: "free",
TIER_TWO: "starter",
TIER_THREE: "pro",
} as const;
export const PRICING_TIERS = {
[TIER_IDS.TIER_ONE]: {
id: TIER_IDS.TIER_ONE,
name: "Free Forever",
monthlyPriceInCents: 0,
limits: {
projects: 3,
teamMembers: 1,
apiRequestsPerDay: 100,
storageMB: 100,
},
credits: { monthly: 50, rollover: false },
rateLimit: { requests: 100, window: "1m" },
features: ["Basic AI chat", "3 projects", "Community support"],
},
[TIER_IDS.TIER_TWO]: {
/* ... */
},
[TIER_IDS.TIER_THREE]: {
/* ... */
},
};Customizing
| Operation | Steps |
|---|---|
| Add tier | Add key to PRICING_TIERS, set prices, limits, features, provider env vars |
| Change prices | Update cents in config + new price entries in provider dashboard + update env vars |
| Rename | Change name only. Never change the key — stored in profiles.subscription_tier |
| Remove | Set isAvailable: false to grandfather existing subs |
| Change limits | Edit limits/credits/rateLimit — server-side enforcement automatic |
Rules: One tier must have isDefault: true. id must match key. Prices in cents ($29/mo = 2900).
Paid Product Name Mapping
mapProductNameToTier(productName) in lib/payments/utils.ts is used by providers when metadata and provider price/product IDs are unavailable. It:
- Collects paid tiers from
PRICING_TIERSand sorts them by monthly price ascending. - Matches configured tier IDs and display names case-insensitively.
- Maps config-driven
highIntentAliases(e.g.,business,agency,enterprise) to the correct paid tier without hardcoded keyword lists. - Falls back to the lowest paid tier for unknown paid provider product names.
It intentionally does not fall back to getDefaultTierId() unless no paid tiers exist, because this path runs after a paid provider event or checkout context and returning the free tier would under-grant a completed purchase.
Every call emits a [mapProductNameToTier] warning through lib/system/logger.ts; production payment integrations should prefer metadata tierId or configured provider price/product IDs so this warning remains rare.
Checkout Flow
"Upgrade to Pro"
↓
createCheckoutSession({ tier: "pro", interval: "monthly" })
↓
getPaymentProvider().createCheckoutSession({
priceId: resolved from PRICING_TIERS + env vars,
customerId: profiles.stripe_customer_id,
successUrl: /dashboard/billing?success=true,
cancelUrl: /dashboard/billing?canceled=true,
metadata: { userId, tier }
})
↓
Redirect to provider-hosted checkout
↓
Payment complete → webhook → /api/webhooks/[provider]
↓
handleWebhook() → HMAC-SHA256 verified
↓
webhook_logs_provider_event_id_unique partial index → idempotency at DB level
↓
syncSubscriptionToDb():
- upsert by provider + provider_subscription_id
- profiles.subscription_tier = "pro"
- subscriptions table updated
- invoices table updated
↓
Active subscription on refreshRedirect URL Control
Checkout and billing-portal redirects are user-visible trust boundaries. All
provider return URLs are built server-side with lib/security/redirects.ts
using config.app.url as the canonical origin.
Allowed paths:
/checkout/success/pricing/dashboard/billing/dashboard/organization/settings
Server Actions, REST routes, and oRPC billing procedures reject malformed, external, or non-allowlisted redirect targets before calling a provider. Provider SDK errors stay in logs/Sentry; clients receive stable translated billing errors.
Authenticated REST billing routes (/api/billing/checkout, /api/billing/portal,
/api/billing/cancel, and /api/billing/subscription) use
getEffectiveUserId(user.id) so super-admin impersonation reads and mutations
target the same effective account as the Server Actions.
Billing Portal Sync
Customer portal changes are synchronized in two paths:
- Webhook path (canonical): provider webhook ->
handleWebhook()->syncSubscriptionToDb()-> profile/subscription tables -> cache tag invalidation. - Portal return path (read repair): customer returns to
/dashboard/billing?portal_return=1->syncBillingFromProvider()calls the active provider'sgetSubscription()->syncSubscriptionToDb()->/dashboard/billingrefetches.
The return path makes plan changes, scheduled cancellations, and payment-method
driven subscription status changes visible as soon as the user comes back to
/dashboard/billing, even if the webhook is still in flight. Webhooks still remain the
source of truth because providers retry them and they cover changes made outside
the current browser session.
Provider-Specific Portal Constraints: Some providers (e.g., Creem) do not support dynamic
return_urlvalues passed during portal session creation. For these providers, you must explicitly configurehttps://<YOUR_DOMAIN>/dashboard/billingas the allowed return URL in their dashboard, and conditional UI guidance is rendered on the/dashboard/billingpage.
Guest Checkout
Unauthenticated purchases use the guest checkout route. Public checkout routes do not create confirmed Supabase users before payment completion. Subscription webhooks resolve the buyer by metadata/customer/email after the provider event is verified, link an existing profile when one exists, or create the local user and send the account setup email. Digital product guest checkout is additionally gated by isDigitalProductsEnabled() and records guest purchases for email/token fulfillment without creating an account.
Scalable Guest Lookups
The system implements O(1) indexed database queries against the profiles table to resolve guest identities by email, explicitly bypassing O(N) unpaginated Supabase Admin API calls. This guarantees high-performance webhook fulfillment regardless of the total registered user count.
Digital Product Fulfillment
Vibe44 includes a robust Token-based Download System for digital product delivery, tracked via the purchases table.
Tier Preservation Guarantee: Purchasing a digital product explicitly bypasses tier upgrades. All 5 providers pass
tierGranted: nullwhen syncing digital product purchases to ensure a user's active subscription tier (e.g., "Pro") is never inadvertently overwritten by a one-time asset purchase.
Fulfillment Logic
- Purchase Completion: When a one-time purchase for a digital product (
productIdnot null) is synced via webhook, the system generates a uniquedownload_token. - Token Security: The token is stored alongside a
download_expires_attimestamp (configurable, defaults to 72 hours from purchase). - Secure Delivery: Files are served via
app/api/downloads/[token]. This route:- Verifies the token existence in the
purchasestable. - Checks that the current time is before
download_expires_at. - Verifies the authenticated
userIdmatches the purchase (unless it's a guest-checkout with a valid token). - Generates a presigned URL from Supabase Storage to serve the actual file.
- Verifies the token existence in the
This architecture ensures that digital assets are never exposed via public URLs and remain accessible only to verified buyers for a limited window.
Webhook Setup
| Provider | URL | Required Events |
|---|---|---|
| Stripe | /api/webhooks/stripe | checkout.session.completed, customer.subscription.created/updated/paused/resumed/deleted, invoice.paid/payment_failed |
| Polar | /api/webhooks/polar | checkout.created/updated/expired, order.created/paid, subscription.created/active/updated/canceled/past_due/revoked |
| Lemon Squeezy | /api/webhooks/lemon-squeezy | order_created, subscription_created/updated/cancelled/resumed/expired/paused/unpaused/payment_success/payment_failed/payment_recovered |
| Dodo | /api/webhooks/dodo | payment.succeeded/failed, subscription.active/renewed/plan_changed/updated/cancelled/failed/on_hold/expired |
| Creem | /api/webhooks/creem | checkout.completed, subscription.active/trialing/paid/canceled/scheduled_cancel/expired/paused/update/past_due |
Local Testing
# Stripe
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# Others
ngrok http 3000
# Set webhook URL: https://{ngrok-id}.ngrok.io/api/webhooks/{provider}Webhook Security
HMAC-SHA256 Verification
| Provider | Signature Header(s) |
|---|---|
| Stripe | stripe-signature |
| Polar | webhook-id + webhook-timestamp + webhook-signature |
| Lemon Squeezy | x-signature |
| Dodo | webhook-id + webhook-timestamp + webhook-signature |
| Creem | creem-signature |
crypto.timingSafeEqual() for constant-time comparison.
Idempotency
Webhook → claim webhook_logs(provider, event_id)
↓
Partial unique index applies when event_id IS NOT NULL
↓
success rows skip; active processing rows under 5 minutes skip
↓
failed or stale processing rows retry with attempts + 1Duplicate deliveries = zero duplicate mutations, guaranteed by PostgreSQL webhook_logs_provider_event_id_unique. Retryable delivery failures remain recoverable because the same row is reused instead of insert-only failing.
Provider resource mutations have a second database guard:
subscriptions(provider, provider_subscription_id)is unique when the provider subscription ID is present.syncSubscriptionToDb()upserts on this key so replayed subscription events update the existing entitlement row.purchases(provider, provider_payment_id)is unique when the provider payment ID is present.savePurchase()treats duplicate provider payment IDs as idempotent success and returns the existing purchase ID.
Creem webhook payloads must include a durable provider event.id. Events without it are rejected instead of using generated timestamp IDs, because generated IDs would bypass delivery-level deduplication.
Credit System
Deduction
SELECT deduct_credits(user_id, cost);
-- Returns remaining balance. Throws on insufficient.FOR UPDATE row locking prevents race conditions.
Replenishment
Monthly via webhook on renewal. Amount from PRICING_TIERS.credits.monthly. rollover: false = unused expire.
Subscription Lifecycle
| Event | Webhook | DB Update |
|---|---|---|
| Checkout complete | checkout.session.completed | Insert subscription, update tier |
| Renewal | invoice.paid | Update current_period_end, add invoice, replenish credits |
| Payment failed | invoice.payment_failed | Update status, log |
| Cancellation | subscription.deleted | Status →canceled, cancel_at_period_end; graceful by default (access until period end) |
| Plan change | subscription.updated | Update tier, recalculate limits |
️ Database Tables
| Table | Purpose |
|---|---|
subscriptions | Active subscription records |
purchases | Lifetime + digital purchases |
invoices | Payment receipts + URLs |
webhook_logs | Audit + idempotency |
Billing idempotency depends on both event logs and provider resource IDs:
webhook_logs(provider, event_id) deduplicates delivery attempts, while
subscriptions(provider, provider_subscription_id) and
purchases(provider, provider_payment_id) deduplicate terminal billing state.
Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.