Skip to main content
vibestrap ships with a single payment facade that wraps five providers — Stripe, Paddle, Lemon Squeezy, Creem, and NOWPayments (crypto). Pick one in siteConfig.payment.provider, fill in its env vars, and the rest of the codebase keeps using the same paymentManager API. Webhooks from every provider get normalized to a single NormalizedEvent shape, so business code (credit grants, license issuance, affiliate commissions) is written once and runs the same regardless of who took the money.

How PaymentManager works

The facade lives at src/payment/index.ts. It picks the active provider at boot time based on siteConfig.payment.provider:
function pickProvider(): PaymentProvider {
  switch (siteConfig.payment.provider) {
    case 'stripe':       return stripeProvider;
    case 'paddle':       return paddleProvider;
    case 'lemonsqueezy': return lemonSqueezyProvider;
    case 'creem':        return creemProvider;
    case 'nowpayments':  return nowpaymentsProvider;
  }
}
export const paymentManager: PaymentProvider = pickProvider();
Every provider implements the same two-method PaymentProvider contract from src/payment/types.ts: createCheckout(opts) and createPortalLink(opts). Consumers (server actions, settings pages) only ever import paymentManager — they never know which provider is live.

Picking a provider

ProviderBest forFeesMoR?Notes
StripeNA / EU / global cards2.9% + 30¢NoYou handle VAT/sales tax. Best DX.
PaddleGlobal, regulatory-heavy~5% + 50¢YesHandles EU VAT + US sales tax for you.
Lemon SqueezyIndie hackers wanting MoR5% + 50¢YesFriendliest onboarding. Now Stripe-owned.
CreemMainland China usersvariesYes (cn)Alipay + WeChat Pay. Requires Chinese business entity.
NOWPaymentsCrypto-native buyers0.5%NoBTC/ETH/USDC/300+ coins. No KYC. One-time only — no subscriptions.
A common pattern: ship with Lemon Squeezy on day one (no entity required), move to Stripe once you have a US/EU LLC, add Creem if/when you target China.

Webhook URLs

Each provider has its own webhook route under app/api/webhooks/:
ProviderURL
Stripe/api/webhooks/stripe
Paddle/api/webhooks/paddle
Lemon Squeezy/api/webhooks/lemonsqueezy
Creem/api/webhooks/creem
NOWPayments/api/webhooks/nowpayments
You only need to register the URL for the provider you’re actually using. The other routes return 404 if their secret is unset.

Normalized event shape

Every provider’s parseWebhook produces a NormalizedEvent from src/payment/types.ts:
{
  type: 'checkout.completed' | 'invoice.paid' | 'subscription.activated' | ...,
  provider: 'stripe' | 'paddle' | 'lemonsqueezy' | 'creem' | 'nowpayments',
  eventId: string,
  payload: NormalizedPayload, // userId, email, sessionId, invoiceId, type, scene, ...
  raw: unknown,               // original provider event for fallbacks
}
The shared handler processNormalizedEvent in src/payment/handlers/core.ts reads only the normalized fields. To add a new provider, you implement createCheckout, createPortalLink, and a normalize*Event function — no business logic is duplicated.

Idempotency

Stripe (and others) retry webhook deliveries on any non-2xx response, so the core handler is idempotent on payment.invoiceId (unique index in src/db/app.schema.ts) and falls back to payment.sessionId when no invoice is involved:
if (p.invoiceId) {
  const existing = await db.query.payment.findFirst({
    where: eq(payment.invoiceId, p.invoiceId),
  });
  if (existing) return; // already processed — no-op
}
That means it’s safe to receive the same event 5 times — only the first delivery actually inserts a payment row and grants credits.

Verify it works

  1. Set siteConfig.payment.provider to your provider of choice.
  2. Set the matching env vars (see the per-provider doc).
  3. pnpm dev, open /pricing, click checkout — you should land on the provider’s hosted checkout page.
  4. Complete a test payment.
  5. The provider’s webhook should hit your route; check Postgres:
    select id, provider, scene, amount from payment order by created_at desc limit 5;
    
  6. Replay the webhook — you should see the same payment row, no duplicate.

Common pitfalls

  • Switching providers mid-flight without clearing checkout sessions — in-flight checkouts will 404 against the new provider. Run a manual cancel pass before swapping.
  • Forgetting to update priceIdEnv per provider — the active provider’s promo + standard env vars must both resolve, even if one is empty.
  • Local dev without a tunnel — webhooks can’t reach localhost. Use Stripe CLI / ngrok / cloudflared.
  • Mixing test and live mode keys — every provider has separate test/live keys AND test/live webhook secrets. Mismatches fail signature verification.
  • Returning early from the route on parsing errors — providers retry, and you’ll grant credits twice once you fix the bug. Always insert idempotently.

Official docs