Payments

indiegoon supports 4 payment providers through a unified interface. Switch providers by changing one environment variable — your code stays the same.

Supported Providers

ProviderBest ForPricing
StripeFull control, global reach2.9% + $0.30
PolarOpen-source monetization5%
LemonSqueezySimplicity, tax handling5% + $0.50
DodoPaymentsEmerging markets, low feesVaries

Setup

goon setup payments

Pick your provider. The CLI will:

  1. Validate your API key (makes a real API call)
  2. Configure all required env vars
  3. Update your .goon/manifest.json

Manual Setup

Set PAYMENT_PROVIDER and the provider-specific keys in .env:

# Choose: stripe | polar | lemon | dodo
PAYMENT_PROVIDER="stripe"

# Stripe
STRIPE_SECRET_KEY="sk_test_..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
PRICE_ID_PRO_MONTHLY="price_..."

How It Works

The Provider Interface

Every provider implements the same interface:

interface PaymentProvider {
  name: string
  createCheckout(opts: CheckoutOptions): Promise<{ url: string }>
  createPortal(opts: PortalOptions): Promise<{ url: string }>
  listInvoices(customerId: string): Promise<Invoice[]>
  parseWebhook(body: string, headers: Headers): Promise<WebhookSyncResult | null>
}

The Factory

src/lib/payments/index.ts reads PAYMENT_PROVIDER and loads the right module:

import { getPaymentProvider } from "@/lib/payments"

const provider = getPaymentProvider()
const { url } = await provider.createCheckout({
  userId: session.user.id,
  email: session.user.email,
  priceId: "price_...",
  successUrl: "/dashboard?upgraded=true",
  cancelUrl: "/pricing",
})

API Routes

Three billing endpoints are pre-built:

Create Checkout Session

POST /api/billing/checkout
Body: { priceId: string }
Returns: { url: string }

Redirects the user to the payment page.

Open Customer Portal

POST /api/billing/portal
Returns: { url: string }

Lets users manage their subscription (cancel, update card, view invoices).

List Invoices

GET /api/billing/invoices
Returns: { invoices: Invoice[] }

Webhooks

Payment events (subscription created, renewed, cancelled) are handled automatically:

POST /api/webhooks/payments

The webhook route:

  1. Verifies the signature (provider-specific HMAC)
  2. Parses the event into a standard format
  3. Syncs the subscription to your database

Webhook Verification

Each provider uses different headers for HMAC verification:

ProviderHeaderAlgorithm
Stripestripe-signatureHMAC-SHA256 (multiple)
Polarwebhook-id + webhook-timestamp + webhook-signatureStandard Webhooks
LemonSqueezyx-signatureHMAC-SHA256
DodoPaymentsx-dodo-signatureHMAC-SHA256

Subscription Database

Subscriptions are stored in the subscriptions table:

{
  id: string
  userId: string           // FK to users
  customerId: string       // Provider's customer ID
  subscriptionId: string   // Provider's subscription ID
  priceId: string          // Which plan they're on
  status: string           // "active" | "inactive" | "cancelled" | "past_due"
  currentPeriodEnd: Date   // When the current period expires
  cancelAtPeriodEnd: boolean
}

Using Subscription Status

Check if user has active subscription

import { db } from "@/lib/db"
import { subscriptions } from "@/lib/db/schema"
import { eq } from "drizzle-orm"

const sub = await db.query.subscriptions.findFirst({
  where: eq(subscriptions.userId, userId),
})

const isPro = sub?.status === "active"

In the Billing Page

The pre-built billing page at /billing shows:

  • Current plan status
  • Upgrade buttons (links to checkout)
  • Manage subscription button (links to portal)
  • Invoice history

Switching Providers

Want to switch from Stripe to Polar? Just change the env var:

PAYMENT_PROVIDER="polar"
POLAR_ACCESS_TOKEN="your-token"
POLAR_WEBHOOK_SECRET="your-secret"
POLAR_PRODUCT_ID="your-product-id"

No code changes. The factory loads the new provider automatically.

Testing Webhooks Locally

Stripe

stripe listen --forward-to localhost:3000/api/webhooks/payments

Other Providers

Use a tunnel like ngrok:

ngrok http 3000

Then set the webhook URL in your provider's dashboard to https://your-ngrok-url.ngrok.io/api/webhooks/payments.

Next Steps

  • Database — Understand the schema and run migrations
  • Email — Send transactional emails to your users