# Recipe: Next.js (App Router)

For Next.js 14 or later with the App Router, on Vercel or any Node.js host. This file has the code; [the playbook](https://checkouts.md/playbook.md) has the reasons, every error code and what to tell the person. Follow both.

| | |
|---|---|
| Server code runs in | Route handlers (`app/api/**/route.ts`) on the Node.js runtime |
| Database | PostgreSQL through `pg`. If the project has an ORM (Prisma, Drizzle), use it instead: [Prisma variant](#with-prisma) below |
| Webhook path | `/api/webhooks/railbed` |
| Connect | `npx checkouts.md login --env-path .env.local --live-to env:.env.railbed` |

## What this adds

| File | What it does |
|---|---|
| `db/railbed.sql` | The `orders` and `railbed_events` tables |
| `lib/products.ts` | What's for sale, priced on the server |
| `lib/db.ts` | The database pool |
| `lib/railbed.ts` | The API client and signature verification |
| `lib/orders.ts` | Orders, and applying a payment to its order exactly once |
| `app/api/checkout/route.ts` | Saves an order, creates the checkout session, redirects to it |
| `app/api/webhooks/railbed/route.ts` | Verifies, re-reads the payment, fulfils once |
| `app/checkout/success/page.tsx`, `refresh.tsx` | Shows the order's status; never marks it paid |
| `components/PayButton.tsx` | The Pay button |
| `CHECKOUTS.md` | How this project takes payments |

```bash
npm install pg
npm install --save-dev @types/pg
```

## Environment

| Name | Development (`.env.local`, gitignored) | Production (Vercel → Production) |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` (written by `npx checkouts.md login`) | `rb_live_…` (pushed from `.env.railbed` at go-live) |
| `RAILBED_WEBHOOK_SECRET` | `whsec_…` of the Test endpoint | `whsec_…` of the Live endpoint |
| `APP_URL` | `http://localhost:3000` | `https://yourapp.com` |
| `DATABASE_URL` | The project's database | The project's database |

Never prefix these with `NEXT_PUBLIC_`: that compiles them into the browser bundle.

## 1. Connect

```bash
npx checkouts.md login --env-path .env.local --live-to env:.env.railbed
```

The Test key goes to `.env.local`, which Next.js reads in development, and the Live key to `.env.railbed`; both are gitignored. The Live key waits there until go-live, where it's pushed to Vercel with the Live webhook secret and the file is deleted ([step 6](#6-go-live)). Relay the link and code to the person, and wait for the command to finish.

For Vercel Preview deployments, give them the Test key without printing it (the Vercel CLI signed in and linked with `vercel link`):

```bash
grep '^RAILBED_SECRET_KEY=' .env.local | cut -d= -f2- | tr -d '\n' | vercel env add RAILBED_SECRET_KEY preview
```

## 2. Database

`db/railbed.sql`:

```sql
CREATE TABLE IF NOT EXISTS orders (
  id          text PRIMARY KEY,
  product_id  text NOT NULL,
  amount      text NOT NULL,
  currency    text NOT NULL,
  livemode    boolean NOT NULL,
  status      text NOT NULL DEFAULT 'awaiting_payment',
  payment_id  text UNIQUE,
  user_id     text,
  created_at  timestamptz NOT NULL DEFAULT now(),
  paid_at     timestamptz
);

CREATE TABLE IF NOT EXISTS railbed_events (
  id          text PRIMARY KEY,
  type        text NOT NULL,
  payment_id  text,
  received_at timestamptz NOT NULL DEFAULT now()
);
```

Apply it the way the project applies migrations, or `psql "$DATABASE_URL" -f db/railbed.sql`.

## 3. The code

`lib/products.ts`:

```ts
export type Product = { id: string; name: string; amount: string; currency: string };

// Prices live here, on the server, as decimal strings (R3). The browser only ever sends an id.
export const PRODUCTS: Record<string, Product> = {
  pro: { id: 'pro', name: 'Pro plan', amount: '49.00', currency: 'USD' },
};

export function findProduct(id: unknown): Product | null {
  return typeof id === 'string' && Object.prototype.hasOwnProperty.call(PRODUCTS, id) ? PRODUCTS[id] : null;
}

export function formatPrice(product: Product): string {
  return new Intl.NumberFormat('en-US', { style: 'currency', currency: product.currency }).format(Number(product.amount));
}
```

`lib/db.ts`:

```ts
import { Pool } from 'pg';

// One pool per server process. Next.js reloads modules in development, so keep it on globalThis there.
const globalForDb = globalThis as unknown as { railbedPool?: Pool };
export const pool = globalForDb.railbedPool ?? new Pool({ connectionString: process.env.DATABASE_URL });
if (process.env.NODE_ENV !== 'production') globalForDb.railbedPool = pool;
```

`lib/railbed.ts`:

```ts
import crypto from 'node:crypto';

const API_BASE = process.env.RAILBED_API_BASE ?? 'https://pay.railbed.io/v1';

export type RailbedSession = {
  id: string;
  url: string;
  status: string;
  livemode: boolean;
  amount: string;
  currency: string;
  reference: string | null;
};

export type RailbedPayment = RailbedSession & {
  object: 'payment';
  metadata: Record<string, string> | null;
  paid_at: number | null;
  hold_reason: string | null;
};

export class RailbedError extends Error {
  status: number;
  code: string;
  retryAfter: number | null;
  constructor(status: number, code: string, message: string, retryAfter: number | null) {
    super(message);
    this.name = 'RailbedError';
    this.status = status;
    this.code = code;
    this.retryAfter = retryAfter;
  }
}

/** Calls the Railbed API with the secret key. Server code only: the key never reaches a browser. */
export async function railbed<T>(path: string, init: { body?: unknown; idempotencyKey?: string } = {}): Promise<T> {
  const key = process.env.RAILBED_SECRET_KEY;
  if (!key) throw new Error('RAILBED_SECRET_KEY is not set');
  const headers: Record<string, string> = { Authorization: `Bearer ${key}`, Accept: 'application/json' };
  if (init.body !== undefined) headers['Content-Type'] = 'application/json';
  if (init.idempotencyKey) headers['Idempotency-Key'] = init.idempotencyKey;
  const res = await fetch(`${API_BASE}${path}`, {
    method: init.body === undefined ? 'GET' : 'POST',
    headers,
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    cache: 'no-store',
    signal: AbortSignal.timeout(15_000),
  });
  const data = await res.json().catch(() => null);
  if (res.ok) return data as T;
  throw new RailbedError(
    res.status,
    data?.error?.code ?? 'unknown',
    data?.error?.message ?? `Railbed answered ${res.status}`,
    Number(res.headers.get('Retry-After')) || null,
  );
}

/** True when this environment holds a Live key. */
export const isLiveKey = () => (process.env.RAILBED_SECRET_KEY ?? '').startsWith('rb_live_');

/** Railbed-Signature: t=<seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">, one or more v1 (two during a secret roll). */
export function verifyRailbedSignature(rawBody: string, header: string | null, secret: string, toleranceSeconds = 300): boolean {
  const match = /^t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signatures] = match;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  return signatures
    .slice(1)
    .split(',')
    .some((part) => crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(part.slice(3))));
}
```

`lib/orders.ts`:

```ts
import { randomUUID } from 'node:crypto';
import type { PoolClient } from 'pg';
import { pool } from './db';
import type { Product } from './products';
import type { RailbedPayment } from './railbed';

export type Order = {
  id: string;
  product_id: string;
  amount: string;
  currency: string;
  livemode: boolean;
  status: 'awaiting_payment' | 'paid' | 'held' | 'failed' | 'expired';
  payment_id: string | null;
  user_id: string | null;
};

export async function createOrder(product: Product, livemode: boolean, userId: string | null = null): Promise<Order> {
  const { rows } = await pool.query<Order>(
    `INSERT INTO orders (id, product_id, amount, currency, livemode, user_id)
     VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
    [`order_${randomUUID()}`, product.id, product.amount, product.currency, livemode, userId],
  );
  return rows[0];
}

export async function savePaymentId(orderId: string, paymentId: string): Promise<void> {
  await pool.query('UPDATE orders SET payment_id = $2 WHERE id = $1', [orderId, paymentId]);
}

export async function findOrder(id: string): Promise<Order | null> {
  const { rows } = await pool.query<Order>('SELECT * FROM orders WHERE id = $1', [id]);
  return rows[0] ?? null;
}

export async function findOrderByPayment(paymentId: string): Promise<Order | null> {
  const { rows } = await pool.query<Order>('SELECT * FROM orders WHERE payment_id = $1', [paymentId]);
  return rows[0] ?? null;
}

/**
 * Grants what the order bought. Runs once per order, inside the transaction that marks it paid, so use `client`.
 * This is the one project-specific piece: replace the body. For example:
 *   await client.query('UPDATE users SET plan = $2 WHERE id = $1', [order.user_id, order.product_id]);
 * Slow work (emails, shipping labels) goes on a queue after the commit, not here.
 */
async function fulfil(client: PoolClient, order: Order): Promise<void> {
  void client;
  console.info(`fulfil ${order.id} (${order.product_id})`);
}

export type Applied = 'duplicate' | 'fulfilled' | 'recorded';

/** Records a verified event and applies the re-read payment to its order, exactly once (playbook 6.4, step 8). */
export async function applyPayment(eventId: string, eventType: string, order: Order, payment: RailbedPayment): Promise<Applied> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const seen = await client.query(
      'INSERT INTO railbed_events (id, type, payment_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING',
      [eventId, eventType, payment.id],
    );
    if (seen.rowCount === 0) {
      await client.query('ROLLBACK');
      return 'duplicate';
    }
    let result: Applied = 'recorded';
    if (payment.status === 'paid') {
      const paid = await client.query<Order>(
        `UPDATE orders SET status = 'paid', paid_at = to_timestamp($2)
         WHERE id = $1 AND status <> 'paid' RETURNING *`,
        [order.id, payment.paid_at ?? Math.floor(Date.now() / 1000)],
      );
      if (paid.rowCount === 1) {
        await fulfil(client, paid.rows[0]);
        result = 'fulfilled';
      }
    } else if (payment.status === 'held' || payment.status === 'failed' || payment.status === 'expired') {
      await client.query(`UPDATE orders SET status = $2 WHERE id = $1 AND status <> 'paid'`, [order.id, payment.status]);
    }
    await client.query('COMMIT');
    return result;
  } catch (error) {
    await client.query('ROLLBACK').catch(() => {});
    throw error;
  } finally {
    client.release();
  }
}
```

`app/api/checkout/route.ts`:

```ts
import { NextResponse } from 'next/server';
import { createOrder, savePaymentId } from '@/lib/orders';
import { findProduct } from '@/lib/products';
import { isLiveKey, railbed, RailbedError, type RailbedSession } from '@/lib/railbed';

export const runtime = 'nodejs';

export async function POST(request: Request) {
  const wantsJson = (request.headers.get('content-type') ?? '').includes('application/json');
  const input: Record<string, unknown> = wantsJson
    ? await request.json().catch(() => ({}))
    : Object.fromEntries(await request.formData());
  const product = findProduct(input.product);
  if (!product) return NextResponse.json({ error: 'Unknown product.' }, { status: 400 });

  // If the app has accounts, pass the signed-in user's id as the third argument.
  const order = await createOrder(product, isLiveKey());
  const appUrl = (process.env.APP_URL ?? new URL(request.url).origin).replace(/\/+$/, '');
  try {
    const session = await railbed<RailbedSession>('/checkout_sessions', {
      idempotencyKey: order.id,
      body: {
        amount: product.amount,
        currency: product.currency,
        description: product.name,
        reference: order.id,
        metadata: { order_id: order.id, product_id: product.id },
        success_url: `${appUrl}/checkout/success?order={REFERENCE}`,
        cancel_url: `${appUrl}/`,
      },
    });
    await savePaymentId(order.id, session.id);
    return wantsJson ? NextResponse.json({ url: session.url }) : NextResponse.redirect(session.url, 303);
  } catch (error) {
    console.error('Railbed: checkout session not created', order.id, error instanceof RailbedError ? error.code : error);
    return NextResponse.json({ error: "We couldn't start the payment. Please try again." }, { status: 502 });
  }
}
```

`app/api/webhooks/railbed/route.ts`:

```ts
import { applyPayment, findOrderByPayment } from '@/lib/orders';
import { isLiveKey, railbed, RailbedError, verifyRailbedSignature, type RailbedPayment } from '@/lib/railbed';

export const runtime = 'nodejs';

type RailbedEvent = {
  id: string;
  type: string;
  livemode: boolean;
  data: { payment: { id: string; metadata?: Record<string, string> | null } | null };
};

export async function POST(request: Request) {
  const raw = await request.text(); // the exact bytes Railbed signed: verify before parsing
  const secret = process.env.RAILBED_WEBHOOK_SECRET;
  if (!secret || !verifyRailbedSignature(raw, request.headers.get('railbed-signature'), secret)) {
    return new Response('Invalid signature', { status: 400 });
  }
  const event = JSON.parse(raw) as RailbedEvent;
  if (!event.type.startsWith('payment.') || !event.data.payment) return new Response(null, { status: 200 });
  if (event.livemode !== isLiveKey()) {
    console.error('Railbed webhook: mode mismatch. RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET must be the same mode.');
    return new Response('Mode mismatch', { status: 500 });
  }

  let payment: RailbedPayment;
  try {
    payment = await railbed<RailbedPayment>(`/payments/${encodeURIComponent(event.data.payment.id)}`);
  } catch (error) {
    // "Send test event" samples carry a made-up payment the API doesn't know: acknowledge them.
    const sample = !event.livemode && event.data.payment.metadata?.sample === 'true';
    if (error instanceof RailbedError && error.status === 404 && sample) return new Response(null, { status: 200 });
    console.error('Railbed webhook: payment not read', event.id, error instanceof RailbedError ? error.code : error);
    return new Response('Try again later', { status: 500 });
  }

  const order = await findOrderByPayment(payment.id);
  if (!order) return new Response(null, { status: 200 }); // not a payment this app created
  if (
    payment.livemode !== order.livemode ||
    payment.reference !== order.id ||
    payment.amount !== order.amount ||
    payment.currency !== order.currency
  ) {
    console.error('Railbed webhook: payment does not match its order', payment.id, order.id);
    return new Response(null, { status: 200 });
  }

  const result = await applyPayment(event.id, event.type, order, payment);
  console.info(`Railbed webhook ${event.type} ${event.id}: ${result}`);
  return new Response(null, { status: 200 });
}
```

`app/checkout/success/page.tsx`:

```tsx
import { findOrder } from '@/lib/orders';
import { RefreshWhileWaiting } from './refresh';

export const dynamic = 'force-dynamic';

const MESSAGES: Record<string, string> = {
  paid: 'Payment received. Your order is confirmed.',
  awaiting_payment: 'Confirming your payment…',
  held: 'Your payment is being reviewed. We’ll email you once it’s done.',
  failed: 'This payment didn’t complete.',
  expired: 'This payment didn’t complete.',
};

// Reads the order from the database. It never marks anything paid: only the webhook does (R2).
export default async function CheckoutSuccessPage({ searchParams }: { searchParams: Promise<{ order?: string }> }) {
  const { order: orderId } = await searchParams;
  const order = orderId ? await findOrder(orderId) : null;
  if (!order) {
    return (
      <main>
        <h1>We couldn’t find that order.</h1>
      </main>
    );
  }
  return (
    <main>
      <h1>{MESSAGES[order.status] ?? 'Checking your order…'}</h1>
      {order.status === 'awaiting_payment' && <RefreshWhileWaiting />}
      {(order.status === 'failed' || order.status === 'expired') && <a href="/">Try again</a>}
    </main>
  );
}
```

`app/checkout/success/refresh.tsx`:

```tsx
'use client';

import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

/** Re-renders the page every 3 seconds for up to 2 minutes while the webhook is on its way. */
export function RefreshWhileWaiting() {
  const router = useRouter();
  useEffect(() => {
    let runs = 0;
    const timer = setInterval(() => {
      runs += 1;
      if (runs > 40) clearInterval(timer);
      else router.refresh();
    }, 3000);
    return () => clearInterval(timer);
  }, [router]);
  return <p>This usually takes a few seconds.</p>;
}
```

`components/PayButton.tsx` (a server component; a plain form needs no JavaScript):

```tsx
import { formatPrice, PRODUCTS } from '@/lib/products';

export function PayButton({ productId }: { productId: string }) {
  const product = PRODUCTS[productId];
  if (!product) return null;
  return (
    <form method="post" action="/api/checkout">
      <input type="hidden" name="product" value={product.id} />
      <button type="submit">Pay {formatPrice(product)}</button>
    </form>
  );
}
```

Place it where the project sells, for example `<PayButton productId="pro" />` on the pricing page. From a client component, `fetch('/api/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ product: 'pro' }) })` answers `{ url }`; send the browser there with `window.location.assign(url)`, or open it over the page with `Railbed.open(url)` (playbook [5.3](https://checkouts.md/playbook.md#53-the-pay-button)).

If the project sets a `middleware.ts` (`proxy.ts` from Next.js 16) that requires sign-in, exclude `/api/webhooks/railbed` from its `matcher`: Railbed's request carries no session. If `next.config` sets `trailingSlash: true`, Next.js redirects the path to `/api/webhooks/railbed/`, and a redirect fails every delivery: register the address with the trailing slash.

## 4. The webhook endpoint

```bash
npm run dev
cloudflared tunnel --url http://localhost:3000     # in a second terminal; prints https://….trycloudflare.com
npx checkouts.md webhook https://<tunnel>.trycloudflare.com/api/webhooks/railbed --env-path .env.local
```

The CLI writes `RAILBED_WEBHOOK_SECRET` into `.env.local`. Restart `npm run dev` so Next.js reads it.

## 5. Test it

1. Create an order through the app's own route; the redirect is the session's `url`:

   ```bash
   curl -s -o /dev/null -w '%{redirect_url}\n' -X POST http://localhost:3000/api/checkout -d product=pro
   ```

2. Pay it: open that `url` and choose **Simulate successful payment** on the test provider page, or run the playbook's `simulate.mjs` with the `pay_…` id ([7.1](https://checkouts.md/playbook.md#71-the-end-to-end-run)): `node simulate.mjs pay_… paid --env-path .env.local`.
3. Check: the server log shows `Railbed webhook payment.paid evt_…: fulfilled`; `SELECT status, paid_at FROM orders WHERE payment_id = 'pay_…'` shows `paid`; the success page at `/checkout/success?order=order_…` says the order is confirmed.
4. Run the other scenarios in the playbook's [7.2](https://checkouts.md/playbook.md#72-the-scenarios), passing `--env-path .env.local` to `simulate.mjs` and `replay.mjs`: underpaid (stays `held`, nothing granted), declined, failed, a repeated event (`replay.mjs`, granted once) and a forged one (`400`).
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Set `APP_URL` in Vercel Production to the `https://` address and `DATABASE_URL` to the production database, and apply `db/railbed.sql` to it.
2. Add the Live endpoint, which stores its secret in `.env.railbed` beside the Live key:

   ```bash
   npx checkouts.md webhook https://yourapp.com/api/webhooks/railbed --live --live-to env:.env.railbed
   ```

3. Push both Live values to Vercel Production without printing them, then delete the file:

   ```bash
   for name in RAILBED_SECRET_KEY RAILBED_WEBHOOK_SECRET; do
     grep "^$name=" .env.railbed | cut -d= -f2- | tr -d '\n' | vercel env add "$name" production
   done
   rm .env.railbed
   ```

   (`vercel env add` refuses a name that already exists; remove it first with `vercel env rm NAME production -y`.)
4. Deploy (`vercel --prod`, or the project's usual way). Vercel reads environment variables at build and start, so redeploy after changing them.
5. Check: `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://yourapp.com/api/webhooks/railbed -d '{}'` answers `400` (reachable and verifying), and the Pay button on the production site redirects to `https://pay.railbed.io/p/pay_…`.
6. Set `mode: live` in `CHECKOUTS.md`, add the Live endpoint's id under Wiring and the checks under Evidence. The first real payment is the final evidence.

## 7. CHECKOUTS.md

Fill in [the template](https://checkouts.md/templates/CHECKOUTS.md). The Wiring section for this recipe:

```markdown
## Wiring

- `lib/products.ts`: the products and their prices.
- `app/api/checkout/route.ts`: saves the order, creates the checkout session (Idempotency-Key = order id), redirects to it.
- `components/PayButton.tsx`: the Pay button, on `/pricing`.
- `app/checkout/success/page.tsx`: shows the order's status from the database; never marks it paid.
- `app/api/webhooks/railbed/route.ts`: verifies Railbed-Signature, re-reads the payment, applies it once (`lib/orders.ts` `applyPayment`).
- `db/railbed.sql`: the `orders` and `railbed_events` tables.
- Webhook endpoints: Test `we_…` (tunnel, removed after testing), Live `we_…`, at `/api/webhooks/railbed`.
- Secrets: `RAILBED_SECRET_KEY`, `RAILBED_WEBHOOK_SECRET`. Test in `.env.local` (gitignored) and Vercel Preview; Live in Vercel Production.
```

## Notes

### With Prisma

Models, then `npx prisma migrate dev --name railbed`:

```prisma
model Order {
  id        String    @id
  productId String    @map("product_id")
  amount    String
  currency  String
  livemode  Boolean
  status    String    @default("awaiting_payment")
  paymentId String?   @unique @map("payment_id")
  userId    String?   @map("user_id")
  createdAt DateTime  @default(now()) @map("created_at")
  paidAt    DateTime? @map("paid_at")

  @@map("orders")
}

model RailbedEvent {
  id         String   @id
  type       String
  paymentId  String?  @map("payment_id")
  receivedAt DateTime @default(now()) @map("received_at")

  @@map("railbed_events")
}
```

`applyPayment` with Prisma, taking the same arguments as the `pg` version. `createMany` with `skipDuplicates` and `updateMany` with a status condition keep the transaction alive on a duplicate (a failed insert would abort it in PostgreSQL). `skipDuplicates` works on PostgreSQL, MySQL and CockroachDB, but not SQLite, SQL Server or MongoDB:

```ts
import { prisma } from '@/lib/prisma';
import type { RailbedPayment } from '@/lib/railbed';

export async function applyPayment(eventId: string, eventType: string, order: { id: string }, payment: RailbedPayment) {
  const orderId = order.id;
  return prisma.$transaction(async (tx) => {
    const seen = await tx.railbedEvent.createMany({
      data: [{ id: eventId, type: eventType, paymentId: payment.id }],
      skipDuplicates: true,
    });
    if (seen.count === 0) return 'duplicate';
    if (payment.status === 'paid') {
      const paid = await tx.order.updateMany({
        where: { id: orderId, status: { not: 'paid' } },
        data: { status: 'paid', paidAt: new Date((payment.paid_at ?? Date.now() / 1000) * 1000) },
      });
      if (paid.count === 1) {
        // Grant what the order bought here, with `tx`.
        return 'fulfilled';
      }
    } else if (['held', 'failed', 'expired'].includes(payment.status)) {
      await tx.order.updateMany({ where: { id: orderId, status: { not: 'paid' } }, data: { status: payment.status } });
    }
    return 'recorded';
  });
}
```

### Pages Router

The same code in `pages/api/checkout.ts` and `pages/api/webhooks/railbed.ts`. The webhook route turns off Next's body parser and reads the raw body itself:

```ts
import type { NextApiRequest, NextApiResponse } from 'next';

export const config = { api: { bodyParser: false } };

async function rawBody(req: NextApiRequest): Promise<string> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
  return Buffer.concat(chunks).toString('utf8');
}

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') return res.setHeader('Allow', 'POST').status(405).end();
  const raw = await rawBody(req);
  const signature = req.headers['railbed-signature'];
  const header = typeof signature === 'string' ? signature : null;
  // …then exactly the App Router handler's steps, with res.status(…).end() for each answer.
}
```

### Edge runtime

The routes above use the Node.js runtime (`pg`, `node:crypto`). For the Edge runtime, use the Web Crypto `verifyRailbedSignature` from the playbook's [6.3](https://checkouts.md/playbook.md#63-verify-the-signature) and an HTTP-based database driver.
