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

  1. Copy lib/payments.ts from the block detail page into your project.
  2. Install peer dependencies:
bash
npm install 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

5. Wire it in

app/api/checkout/route.ts
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 })
}
app/api/checkout/webhook/route.ts
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

  1. Call createOrder({ amountUsd: 10, description: 'test' }) and confirm you receive a PayPal order ID.
  2. Complete the PayPal sandbox checkout and call captureOrder(orderId) — status should be COMPLETED.
  3. Send a test webhook from the PayPal sandbox simulator and confirm verifyWebhookSignature() returns true.

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().