# 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