0%
Vibe44 Logo

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 interface

App code never references a specific provider. Factory pattern abstracts the entire layer.


Supported Providers

ProviderEnv ValueSDK/APIMoRWebhook Secret
StripestripeStripe Node SDKNoSTRIPE_WEBHOOK_SECRET
Polarpolar@polar-sh/sdkYesPOLAR_WEBHOOK_SECRET
Lemon Squeezylemon-squeezyREST + HMACYesLEMONSQUEEZY_WEBHOOK_SECRET
Dodo PaymentsdodoREST + HMACYesDODO_WEBHOOK_SECRET
CreemcreemREST + HMACYesCREEM_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 pattern

PaymentProvider 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:

ModelConfig KeyHow It Works
Subscriptionsbilling.subscriptionsMonthly/yearly recurring
Lifetimebilling.lifetimePurchasesOne-time, permanent access
AI creditsbilling.creditsPer-message deduction (FOR UPDATE locking)
Seat-basedbilling.seatsPer-member org pricing
Free trialsbilling.trialN-day trial
Digital productsbilling.digitalProductsOne-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-returns false when features.organizations is false.


️ 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

OperationSteps
Add tierAdd key to PRICING_TIERS, set prices, limits, features, provider env vars
Change pricesUpdate cents in config + new price entries in provider dashboard + update env vars
RenameChange name only. Never change the key — stored in profiles.subscription_tier
RemoveSet isAvailable: false to grandfather existing subs
Change limitsEdit limits/credits/rateLimit — server-side enforcement automatic

Rules: One tier must have isDefault: true. id must match key. Prices in cents ($29/mo = 2900).

mapProductNameToTier(productName) in lib/payments/utils.ts is used by providers when metadata and provider price/product IDs are unavailable. It:

  1. Collects paid tiers from PRICING_TIERS and sorts them by monthly price ascending.
  2. Matches configured tier IDs and display names case-insensitively.
  3. Maps config-driven highIntentAliases (e.g., business, agency, enterprise) to the correct paid tier without hardcoded keyword lists.
  4. 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 refresh

Redirect 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:

  1. Webhook path (canonical): provider webhook -> handleWebhook() -> syncSubscriptionToDb() -> profile/subscription tables -> cache tag invalidation.
  2. Portal return path (read repair): customer returns to /dashboard/billing?portal_return=1 -> syncBillingFromProvider() calls the active provider's getSubscription() -> syncSubscriptionToDb() -> /dashboard/billing refetches.

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_url values passed during portal session creation. For these providers, you must explicitly configure https://<YOUR_DOMAIN>/dashboard/billing as the allowed return URL in their dashboard, and conditional UI guidance is rendered on the /dashboard/billing page.

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: null when 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

  1. Purchase Completion: When a one-time purchase for a digital product (productId not null) is synced via webhook, the system generates a unique download_token.
  2. Token Security: The token is stored alongside a download_expires_at timestamp (configurable, defaults to 72 hours from purchase).
  3. Secure Delivery: Files are served via app/api/downloads/[token]. This route:
    • Verifies the token existence in the purchases table.
    • Checks that the current time is before download_expires_at.
    • Verifies the authenticated userId matches the purchase (unless it's a guest-checkout with a valid token).
    • Generates a presigned URL from Supabase Storage to serve the actual file.

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

ProviderURLRequired Events
Stripe/api/webhooks/stripecheckout.session.completed, customer.subscription.created/updated/paused/resumed/deleted, invoice.paid/payment_failed
Polar/api/webhooks/polarcheckout.created/updated/expired, order.created/paid, subscription.created/active/updated/canceled/past_due/revoked
Lemon Squeezy/api/webhooks/lemon-squeezyorder_created, subscription_created/updated/cancelled/resumed/expired/paused/unpaused/payment_success/payment_failed/payment_recovered
Dodo/api/webhooks/dodopayment.succeeded/failed, subscription.active/renewed/plan_changed/updated/cancelled/failed/on_hold/expired
Creem/api/webhooks/creemcheckout.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

ProviderSignature Header(s)
Stripestripe-signature
Polarwebhook-id + webhook-timestamp + webhook-signature
Lemon Squeezyx-signature
Dodowebhook-id + webhook-timestamp + webhook-signature
Creemcreem-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 + 1

Duplicate 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

EventWebhookDB Update
Checkout completecheckout.session.completedInsert subscription, update tier
Renewalinvoice.paidUpdate current_period_end, add invoice, replenish credits
Payment failedinvoice.payment_failedUpdate status, log
Cancellationsubscription.deletedStatus →canceled, cancel_at_period_end; graceful by default (access until period end)
Plan changesubscription.updatedUpdate tier, recalculate limits

️ Database Tables

TablePurpose
subscriptionsActive subscription records
purchasesLifetime + digital purchases
invoicesPayment receipts + URLs
webhook_logsAudit + 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.