Monetization · Intermediate
Payments
PayPal Checkout v2 for one-time payments — order creation, capture, refunds, webhook verification, and INR display helpers with a module-level token cache. Lighter than the full Billing block; use this when you only need one-time charges with no subscription management.
Read the Getting Access guideif you haven't yet.
1. Get the file
Sign in at marrowstack.dev, open the Payments block, and click Copy all files. Paste lib/payments.ts into your project. The block maintains a module-level OAuth2 token cache — each cold-start fetches a fresh token automatically.
2. Prerequisites
- Next.js 14 or 15, App Router
- PayPal Developer account with a REST API app (sandbox for dev, live for production)
- A publicly accessible webhook endpoint (use ngrok or a preview deployment for local dev)
3. Install
- Copy
lib/payments.tsfrom the block detail page into your project. - Install peer dependencies:
npm install 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 |
5. Wire it in
import { createOrder, captureOrder } from '@/lib/payments'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'
// POST — create order, return PayPal order ID to client
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({ amountUsd, description })
return NextResponse.json({ orderId: order.id })
}
// PATCH — capture after buyer approves in PayPal UI
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 capture = await captureOrder(orderId)
if (capture.status === 'COMPLETED') {
// Deliver value here — only runs after server-side capture confirmation
}
return NextResponse.json({ status: capture.status })
}import { verifyWebhookSignature } from '@/lib/payments'
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 valid = await verifyWebhookSignature(body, headers)
if (!valid) return new NextResponse('Bad signature', { status: 400 })
const event = JSON.parse(body)
// Handle event.event_type: PAYMENT.CAPTURE.COMPLETED, PAYMENT.CAPTURE.REFUNDED, etc.
return NextResponse.json({ ok: true })
}6. Verify it works
- Call
createOrder({ amountUsd: 10, description: 'test' })and confirm you receive a PayPal order ID. - Complete the PayPal sandbox checkout and call
captureOrder(orderId)— status should beCOMPLETED. - Send a test webhook from the PayPal sandbox simulator and confirm
verifyWebhookSignature()returnstrue.
7. Failure modes & fixes
INVALID_CLIENT
Cause: Wrong PAYPAL_CLIENT_ID or PAYPAL_CLIENT_SECRET, or sandbox credentials used with PAYPAL_MODE=live.
Fix: Copy credentials from PayPal Developer Dashboard. Ensure PAYPAL_MODE matches.
ORDER_ALREADY_CAPTURED
Cause: captureOrder() was called twice for the same order ID.
Fix: Check your order state before calling capture. Use a database column to track captured orders.
Webhook signature mismatch
Cause: The body was parsed as JSON before passing to verifyWebhookSignature(), or PAYPAL_WEBHOOK_ID is wrong.
Fix: Pass the raw string body (req.text()) directly to verifyWebhookSignature().