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.
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
- Copy
lib/billing.tsfrom the block detail page into your project. - Install peer dependencies:
npm install @supabase/supabase-js zod4. Environment
| Variable | Required | Default | Purpose |
|---|---|---|---|
| PAYPAL_CLIENT_ID | yes | — | PayPal REST API client ID |
| PAYPAL_CLIENT_SECRET | yes | — | PayPal REST API client secret — server-side only |
| PAYPAL_WEBHOOK_ID | yes | — | Webhook ID from PayPal Developer Dashboard → Webhooks |
| PAYPAL_MODE | no | — | sandbox or live. Defaults to sandbox |
| NEXT_PUBLIC_SUPABASE_URL | yes | — | Your Supabase project URL |
| SUPABASE_SERVICE_ROLE_KEY | yes | — | Service role key — server-side only |
5. Database
Run the MIGRATION constant in Supabase SQL Editor. Creates:
orders— one-time PayPal orders with status trackingsubscriptions— PayPal subscription IDs, plan, billing cycle, statusinvoices— generated invoice records linked to orders/subscriptions
6. Wire it in
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)
}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
- Create a sandbox order and confirm a row appears in the
orderstable with statuscreated. - Complete the PayPal sandbox checkout flow and confirm the order status transitions to
captured. - Use the PayPal sandbox webhook simulator to send a
PAYMENT.CAPTURE.COMPLETEDevent — confirmverifyWebhook()passes. - 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_SECRETserver-side only. It allows full API access to your PayPal account. - Use idempotency: check the
orderstable before delivering to prevent double-credit on webhook retries.