# PayPal Checkout PayPal REST API v2 integration for Next.js 14 — one-time order creation, capture, refunds, webhook verification, and INR display helpers, with a module-level token cache and typed payloads. ## What's included **Auth** - `getPayPalToken()` — fetches and caches an OAuth2 access token at module level; reuses across warm Lambda invocations; refreshes 60 seconds before expiry **Orders** - `createOrder(opts)` — creates a `CAPTURE` intent order; accepts `amountUSD`, `description`, `referenceId`, `customId`, `returnUrl`, `cancelUrl`; `customId` is echoed back in webhook payloads - `getOrder(orderId)` — fetches live order state by ID - `getApprovalUrl(order)` — extracts the `approve` link from an order response; throws if missing - `captureOrder(orderId)` — captures an approved order; returns a typed `PayPalCapture` - `extractCaptureId(capture)` — pulls the capture ID from a `PayPalCapture`; needed to issue refunds - `extractCustomId(capture)` — pulls your `customId` echo from a capture; use this to identify the purchase in your DB **Refunds** - `refundCapture(captureId, opts?)` — full or partial refund; `opts.amountUSD` omitted = full refund; `opts.note` capped at 255 chars - `getCapture(captureId)` — fetches a capture by ID **Webhooks** - `verifyWebhook(headers, rawBody)` — verifies signature via PayPal's API; returns boolean; `rawBody` must be the raw string, not parsed JSON - `PayPalWebhookEvent` — union type of all subscribable event strings - `PayPalWebhookPayload` — typed webhook envelope with `event_type` and `resource` **Display helpers** - `usdToInr(usd)` — converts at hardcoded 84x rate - `formatInr(usd)` — `₹1,596` - `formatUsd(usd)` — `$19.00` - `formatBothCurrencies(usd)` — `$19.00 (≈ ₹1,596)` ## Setup ### 1. Install dependencies No extra packages — uses `fetch` only. ### 2. Environment variables ``` PAYPAL_CLIENT_ID=your PayPal app client ID PAYPAL_CLIENT_SECRET=your PayPal app client secret PAYPAL_WEBHOOK_ID=webhook ID from PayPal dashboard PAYPAL_MODE=sandbox # change to 'live' for production NEXT_PUBLIC_APP_URL=https://yourdomain.com ``` ### 3. Create a PayPal app 1. Go to [developer.paypal.com/dashboard/applications/sandbox](https://developer.paypal.com/dashboard/applications/sandbox) 2. Create an app → copy **Client ID** and **Secret** to `.env.local` 3. Under the app → **Webhooks** → Add webhook: - URL: `https://yourdomain.com/api/webhooks/paypal` - Events: `CHECKOUT.ORDER.APPROVED`, `PAYMENT.CAPTURE.COMPLETED`, `PAYMENT.CAPTURE.REFUNDED` 4. Copy the **Webhook ID** to `PAYPAL_WEBHOOK_ID` ## Usage examples ```ts // app/api/purchase/create-order/route.ts import { createOrder, getApprovalUrl } from '@/blocks/payments' export async function POST(req: Request) { const session = await getServerSession(authOptions) if (!session?.user?.id) return Response.json({ error: 'Sign in required' }, { status: 401 }) const { blockId, price, name } = await req.json() const order = await createOrder({ amountUSD: price.toFixed(2), description: `MarrowStack — ${name}`, customId: `${blockId}:${session.user.id}`, // echoed back in webhook referenceId: blockId, }) return Response.json({ orderId: order.id, approvalUrl: getApprovalUrl(order) }) } ``` ```ts // app/api/webhooks/paypal/route.ts import { verifyWebhook, PayPalWebhookPayload } from '@/blocks/payments' export async function POST(req: Request) { const rawBody = await req.text() // must be raw — do not call req.json() const valid = await verifyWebhook(req.headers as Headers, rawBody) if (!valid) return Response.json({ error: 'Invalid signature' }, { status: 400 }) const event: PayPalWebhookPayload = JSON.parse(rawBody) if (event.event_type === 'PAYMENT.CAPTURE.COMPLETED') { const captureId = event.resource.id const [blockId, userId] = (event.resource.custom_id ?? '').split(':') // grant GitHub repo access, record purchase in DB } if (event.event_type === 'PAYMENT.CAPTURE.REFUNDED') { // mark purchase as refunded in DB } return Response.json({ received: true }) } ``` ```ts // Issuing a refund from an admin route import { refundCapture } from '@/blocks/payments' // Full refund await refundCapture(captureId) // Partial refund await refundCapture(captureId, { amountUSD: '9.00', note: 'Partial refund for unused period' }) ``` ## Notes - `verifyWebhook` makes an outbound HTTP call to PayPal to verify the signature — it is not a local HMAC check; add a timeout wrapper (`fetchWithTimeout` from the Error Handling block) if you want to guard against PayPal API latency in your webhook handler - `USD_TO_INR` is hardcoded at `84` — update it periodically or replace `formatInr` with a live exchange rate call; stale rates mean buyers see incorrect INR estimates at checkout - `customId` is the cleanest way to correlate webhooks back to your database — encode `blockId:userId` or your internal order ID there rather than relying on `referenceId`, which PayPal sometimes strips - The module-level token cache (`_token`) is shared across all requests in the same Lambda instance; on Vercel Edge Runtime there is no module-level state between requests, so every invocation will re-fetch a token — move token caching to KV (Upstash) if you deploy to Edge