Skip to main content
Think of the payment system as two simple parts: a checkout link that sends your user to pay, and a webhook that tells your app “hey, the payment went through!” That’s really it. Let’s break it down.

What payments do in your app

You’ll use payments for two things:
  • Selling plan tiers — like Small, Medium, or Large one-time purchases
  • Selling credit top-ups — like credits-small and credits-large
Here’s the full flow, start to finish:
1

User clicks checkout

They hit a payment button in your app.
2

They pay on the provider's page

Stripe, LemonSqueezy, or Polar handles the actual payment.
3

Provider sends a webhook

Your app gets a secure HTTP request saying “payment succeeded.”
4

Your app verifies & stores

The webhook is verified, the purchase is saved, and credits or plan state are updated.

Pick your payment provider

The most popular choice for developers. You get full control over products, prices, and metadata. If you’ve used Stripe before, you’ll feel right at home.Best for: Teams who want the most familiar developer payment flow.Set up Stripe ->
Don’t try to set up all three providers at once. Pick one, get it fully working, then expand later if you need to.

Key concepts

Checkout URL

This is the payment link your user clicks. You store these as env vars so your UI can use them without hardcoding.
https://buy.stripe.com/...
https://yourstore.lemonsqueezy.com/buy/...
https://polar.sh/checkout/...

Webhook

A webhook is a secure HTTP request your payment provider sends to your app after a successful payment. Each provider has its own endpoint:
  • Stripe: https://yourdomain.com/api/payments/stripe
  • LemonSqueezy: https://yourdomain.com/api/payments/lemonsqueezy
  • Polar: https://yourdomain.com/api/payments/polar

Purchase type

This is the internal label your app uses to know what was bought. The repo supports: plan-small | plan-medium | plan-large | credits-small | credits-large Your payment provider might call it a “product”, “variant”, or “price” — but inside this app, it always maps to one of those purchase types.

Before you start

Make sure you have these ready:
  • Your app runs locally
  • Better Auth and your database are set up
  • You know your production domain
  • You’ve picked which provider to use first

Shared env vars

These env vars are used regardless of which provider you choose:
NEXT_PUBLIC_PAYMENT_PROVIDER=stripe
NEXT_PUBLIC_DEFAULT_MARKETING_PURCHASE_TYPE=plan-medium
NEXT_PUBLIC_CHECKOUT_URL_TEMPLATE=https://...
NEXT_PUBLIC_CHECKOUT_URL_CREDITS_SMALL=https://...
NEXT_PUBLIC_CHECKOUT_URL_CREDITS_LARGE=https://...
VariableWhat it does
NEXT_PUBLIC_PAYMENT_PROVIDERWhich provider the UI should use
NEXT_PUBLIC_DEFAULT_MARKETING_PURCHASE_TYPEWhich plan the main marketing CTA represents
NEXT_PUBLIC_CHECKOUT_URL_TEMPLATEFallback main checkout URL
NEXT_PUBLIC_CHECKOUT_URL_CREDITS_SMALLCheckout link for the small credit pack
NEXT_PUBLIC_CHECKOUT_URL_CREDITS_LARGECheckout link for the large credit pack
Setting the specific Small/Medium/Large plan URLs gives you cleaner pricing buttons on your marketing pages.

How the repo handles payments

Here are the key files you should know about:
FilePurpose
lib/payments/public-config.tsShared checkout URLs, labels, prices, credit-pack metadata
components/(ui-components)/payments/checkout-link.tsxHosted checkout buttons in the UI
app/api/payments/[provider]/route.tsShared webhook entrypoint
lib/payments/providers/*Provider-specific verification logic
lib/payments/processor.tsShared “payment succeeded” business logic
The UI and business logic are mostly shared. Only the webhook verification differs per provider.
Real checkout URLs should live in env vars. Shared purchase display metadata lives in lib/payments/public-config.ts. UI components should never hardcode raw checkout links.

Metadata vs product maps

There are two ways to tell your app what a payment means.

Credits

If the purchase type is credits-small or credits-large, the repo adds credits automatically after the webhook succeeds. If it’s a plan like plan-medium, the repo stores the purchase and updates the user’s profile state instead.

Optional Meta Ads attribution

If you run Meta Ads, the repo can track InitiateCheckout and Purchase events. Only enable this if you actually need it:
NEXT_PUBLIC_ENABLE_META_ATTRIBUTION=true
NEXT_PUBLIC_META_PIXEL_ID=...
META_ACCESS_TOKEN=...

Suggested setup order

1

Pick one provider

Start with Stripe, LemonSqueezy, or Polar — just one.
2

Create a Medium plan product

Keep it simple. One product to start.
3

Create a Small credits product

This lets you test the credits flow too.
4

Add checkout URLs to .env.local

Paste your payment links into the right env vars.
5

Add the webhook endpoint

Configure it in your provider’s dashboard.
6

Complete a test payment

Go through the full flow yourself.
7

Verify the purchase in your database

Check that a row appeared in purchases.
8

Confirm the user gets the right result

Credits added? Plan state updated? You’re golden.
Double-check that the URL in your provider dashboard matches your actual domain, including the correct path (/api/payments/stripe, /api/payments/lemonsqueezy, or /api/payments/polar).
Each provider needs its own webhook secret env var. Without it, webhook verification fails silently.
Make sure your product is mapped — either via metadata (type=plan-medium) or via the product/variant map in env.
You need to restart your dev server after changing env vars. Next.js doesn’t hot-reload env changes.

Provider guides