Monetization · Advanced

Billing & Subscriptions

PayPal REST API integration for one-time orders, monthly and yearly subscriptions, refunds, webhook verification, usage tracking, and invoice generation — wired to Supabase.

Read the Getting Access guideif you haven't yet.

1. Get the file

Sign in at marrowstack.dev, open the Billing & Subscriptions block, and click Copy all files. Paste lib/billing.ts into your project.

Set PAYPAL_MODE=sandbox during development. Switch to live and update your webhook URL before going to production. Never use live credentials in development.

2. Prerequisites

  • Next.js 14 or 15, App Router
  • PayPal Developer account — create a REST API app at developer.paypal.com
  • Supabase project
  • A publicly accessible webhook URL (use ngrok for local dev)

3. Install

  1. Copy lib/billing.ts from the block detail page into your project.
  2. Install peer dependencies:
bash
npm install @supabase/supabase-js zod

4. Environment

VariableRequiredDefaultPurpose
PAYPAL_CLIENT_IDyesPayPal REST API client ID
PAYPAL_CLIENT_SECRETyesPayPal REST API client secret — server-side only
PAYPAL_WEBHOOK_IDyesWebhook ID from PayPal Developer Dashboard → Webhooks
PAYPAL_MODEnosandbox or live. Defaults to sandbox
NEXT_PUBLIC_SUPABASE_URLyesYour Supabase project URL
SUPABASE_SERVICE_ROLE_KEYyesService role key — server-side only

5. Database

Run the MIGRATION constant in Supabase SQL Editor. Creates:

  • orders — one-time PayPal orders with status tracking
  • subscriptions — PayPal subscription IDs, plan, billing cycle, status
  • invoices — generated invoice records linked to orders/subscriptions

6. Wire it in

app/api/billing/order/route.ts
import { createOrder, captureOrder } from '@/lib/billing'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'

// POST — create a PayPal order
export async function POST(req: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })

  const { amountUsd, description } = await req.json()
  const order = await createOrder({ userId: session.user.id, amountUsd, description })
  // Return the PayPal order ID to the client — use it to launch the PayPal button
  return NextResponse.json({ orderId: order.id })
}

// PATCH — capture after buyer approves
export async function PATCH(req: NextRequest) {
  const session = await getServerSession(authOptions)
  if (!session?.user?.id) return new NextResponse('Unauthorized', { status: 401 })

  const { orderId } = await req.json()
  const result = await captureOrder(orderId)
  // Deliver value only after capture succeeds
  return NextResponse.json(result)
}
app/api/billing/webhook/route.ts
import { verifyWebhook, handleWebhookEvent } from '@/lib/billing'
import { NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const body = await req.text()
  const headers = Object.fromEntries(req.headers.entries())

  const isValid = await verifyWebhook(body, headers)
  if (!isValid) return new NextResponse('Invalid signature', { status: 400 })

  const event = JSON.parse(body)
  await handleWebhookEvent(event)
  return NextResponse.json({ ok: true })
}

7. Verify it works

  1. Create a sandbox order and confirm a row appears in the orders table with status created.
  2. Complete the PayPal sandbox checkout flow and confirm the order status transitions to captured.
  3. Use the PayPal sandbox webhook simulator to send a PAYMENT.CAPTURE.COMPLETED event — confirm verifyWebhook() passes.
  4. Create a test subscription and confirm a row appears in subscriptions.

8. Failure modes & fixes

INVALID_CLIENT — client authentication failed

Cause: PAYPAL_CLIENT_ID or PAYPAL_CLIENT_SECRET is wrong, or you're using live credentials in sandbox mode.

Fix: Check credentials in PayPal Developer Dashboard. Ensure PAYPAL_MODE matches the app type (sandbox/live).

Webhook signature verification failed

Cause: PAYPAL_WEBHOOK_ID is wrong, or the webhook body was modified in transit.

Fix: Copy the exact Webhook ID from PayPal Developer Dashboard → Webhooks. Don't modify the raw body before passing to verifyWebhook().

ORDER_NOT_APPROVED

Cause: Attempting to capture an order before the buyer has approved it in the PayPal flow.

Fix: Only call captureOrder() after the PayPal JS SDK fires onApprove.

relation 'orders' does not exist

Cause: The migration hasn't been run.

Fix: Run the MIGRATION constant in Supabase SQL Editor.

9. Security responsibilities

  • Always verify the webhook signature before processing any event — never trust the payload alone.
  • Deliver value (access, credits, downloads) only after captureOrder() succeeds server-side.
  • Keep PAYPAL_CLIENT_SECRET server-side only. It allows full API access to your PayPal account.
  • Use idempotency: check the orders table before delivering to prevent double-credit on webhook retries.