# Recipe: Cloudflare Workers

For a Worker with D1 and, optionally, static assets, using Wrangler 4. The same handler code fits Hono, itty-router or any framework on Web `Request` and `Response`. 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 | The Worker's `fetch` handler |
| Database | D1 (SQLite). Statements in one `DB.batch()` commit together |
| Webhook path | `/api/webhooks/railbed` |
| Connect | `npx checkouts.md login --env-path .dev.vars --live-to env:.env.railbed` |

## What this adds

| File | What it does |
|---|---|
| `migrations/0001_railbed.sql` | The `orders` and `railbed_events` tables |
| `src/railbed.ts` | Products, the API client, Web Crypto signature verification, and applying a payment once |
| `src/index.ts` | Routes: `POST /api/checkout`, `GET /api/orders/:id`, `POST /api/webhooks/railbed`; everything else from static assets |
| `public/index.html` (or the project's page) | The Pay button |
| `public/checkout/success.html` | Shows the order's status; never marks it paid |
| `wrangler.jsonc` | The D1 binding, assets and `APP_URL` |
| `CHECKOUTS.md` | How this project takes payments |

If the project already has a router (Hono and others), add the three routes to it with the handler bodies below.

## Environment

| Name | Kind | Local (`.dev.vars`, gitignored) | Production |
|---|---|---|---|
| `RAILBED_SECRET_KEY` | Secret | `rb_test_…` | `wrangler secret put`, from `.env.railbed` at go-live |
| `RAILBED_WEBHOOK_SECRET` | Secret | `whsec_…` of the Test endpoint | `wrangler secret put`, from `.env.railbed` at go-live |
| `APP_URL` | Variable | `http://localhost:8787` (in `.dev.vars`) | `vars` in `wrangler.jsonc` |
| `DB` | D1 binding | Local D1 | The production D1 database |

## 1. Connect

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

The Test key goes to `.dev.vars` (Wrangler's local secrets file) and the Live key to `.env.railbed`; the CLI adds both to `.gitignore`. The Live key waits there until go-live, where it becomes a Worker secret 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.

Add the local address to `.dev.vars` too (it isn't secret). `wrangler dev` lets `.dev.vars` override `vars`, so local sessions return to `wrangler dev` instead of the production address in `wrangler.jsonc`: `echo 'APP_URL=http://localhost:8787' >> .dev.vars`.

## 2. Database and configuration

`migrations/0001_railbed.sql` (use the next free number if the project has migrations):

```sql
CREATE TABLE IF NOT EXISTS orders (
  id             TEXT PRIMARY KEY,          -- order_…: sent as reference and in the Idempotency-Key
  product_id     TEXT NOT NULL,
  amount         TEXT NOT NULL,             -- "49.00"
  currency       TEXT NOT NULL,
  livemode       INTEGER NOT NULL,          -- 1 for Live
  status         TEXT NOT NULL DEFAULT 'awaiting_payment',
  payment_id     TEXT UNIQUE,               -- pay_…
  user_id        TEXT,
  created_at     INTEGER NOT NULL,          -- Unix ms
  paid_at        INTEGER,                   -- Unix seconds, from the payment
  grant_nonce    TEXT                       -- set by the one call that marked it paid: guards the grant
);

CREATE TABLE IF NOT EXISTS railbed_events (
  id          TEXT PRIMARY KEY,             -- evt_…
  type        TEXT NOT NULL,
  payment_id  TEXT,
  received_at INTEGER NOT NULL
);
```

`wrangler.jsonc` (merge into the project's; create the database with `npx wrangler d1 create <name>` if it has none):

```jsonc
{
  "name": "my-app",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-20",
  "assets": { "binding": "ASSETS", "directory": "./public", "run_worker_first": ["/api/*"] },
  "d1_databases": [{ "binding": "DB", "database_name": "my-app", "database_id": "<from wrangler d1 create>" }],
  "vars": { "APP_URL": "https://my-app.example.com" }
}
```

```bash
npx wrangler d1 migrations apply my-app --local
```

## 3. The code

`src/railbed.ts`:

```ts
export interface Env {
  DB: D1Database;
  ASSETS: Fetcher;
  RAILBED_SECRET_KEY: string;
  RAILBED_WEBHOOK_SECRET: string;
  APP_URL: string;
  RAILBED_API_BASE?: string;
}

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 type Order = {
  id: string;
  product_id: string;
  amount: string;
  currency: string;
  livemode: number;
  status: string;
  payment_id: string | null;
  user_id: string | null;
};

export type RailbedPayment = {
  id: string;
  status: string;
  livemode: boolean;
  amount: string;
  currency: string;
  reference: string | null;
  metadata: Record<string, string> | null;
  paid_at: number | null;
};

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

/** Calls the Railbed API with the secret key. */
export async function railbed<T>(env: Env, path: string, init: { body?: unknown; idempotencyKey?: string } = {}): Promise<T> {
  const headers: Record<string, string> = { Authorization: `Bearer ${env.RAILBED_SECRET_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(`${env.RAILBED_API_BASE ?? 'https://pay.railbed.io/v1'}${path}`, {
    method: init.body === undefined ? 'GET' : 'POST',
    headers,
    body: init.body === undefined ? undefined : JSON.stringify(init.body),
    signal: AbortSignal.timeout(15_000),
  });
  const data = (await res.json().catch(() => null)) as { error?: { code?: string; message?: string } } | null;
  if (res.ok) return data as T;
  throw new RailbedError(res.status, data?.error?.code ?? 'unknown', data?.error?.message ?? `Railbed answered ${res.status}`);
}

export const isLiveKey = (env: Env) => 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 async function verifyRailbedSignature(rawBody: string, header: string | null, secret: string, toleranceSeconds = 300): Promise<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 enc = new TextEncoder();
  const key = await crypto.subtle.importKey('raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
  const signed = enc.encode(`${timestamp}.${rawBody}`);
  for (const part of signatures.slice(1).split(',')) {
    const bytes = new Uint8Array(part.slice(3).match(/../g)!.map((h) => parseInt(h, 16)));
    if (await crypto.subtle.verify('HMAC', key, bytes, signed)) return true; // constant time
  }
  return false;
}

/**
 * What the order grants, as statements that commit in the same batch as the payment. Each one is guarded on the
 * order having been marked paid by this very call (its fresh nonce), so it runs once however many events and retries
 * arrive. This is the one project-specific piece: replace the example. For instance, to give the buyer a plan:
 *   env.DB.prepare(`UPDATE users SET plan = 'pro'
 *     WHERE id = (SELECT user_id FROM orders WHERE id = ?1)
 *       AND EXISTS (SELECT 1 FROM orders WHERE id = ?1 AND grant_nonce = ?2)`).bind(orderId, nonce)
 */
function grantStatements(env: Env, orderId: string, nonce: string): D1PreparedStatement[] {
  void env;
  void orderId;
  void nonce;
  return [];
}

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(env: Env, eventId: string, eventType: string, order: Order, payment: RailbedPayment): Promise<Applied> {
  const statements = [
    env.DB.prepare('INSERT INTO railbed_events (id, type, payment_id, received_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT (id) DO NOTHING')
      .bind(eventId, eventType, payment.id, Date.now()),
  ];
  if (payment.status === 'paid') {
    // changes() is the event insert's: 0 for a duplicate, so the update doesn't run twice. The grants follow the
    // update only through this call's nonce, so a redelivery (same event id) or a later event grants nothing.
    const nonce = crypto.randomUUID();
    statements.push(
      env.DB.prepare(`UPDATE orders SET status = 'paid', paid_at = ?2, grant_nonce = ?3
        WHERE id = ?1 AND status <> 'paid' AND changes() > 0`).bind(order.id, payment.paid_at ?? Math.floor(Date.now() / 1000), nonce),
      ...grantStatements(env, order.id, nonce),
    );
  } else if (payment.status === 'held' || payment.status === 'failed' || payment.status === 'expired') {
    statements.push(
      env.DB.prepare(`UPDATE orders SET status = ?2 WHERE id = ?1 AND status <> 'paid' AND changes() > 0`).bind(order.id, payment.status),
    );
  }
  const results = await env.DB.batch(statements); // one transaction
  if (results[0].meta.changes === 0) return 'duplicate';
  return payment.status === 'paid' && results[1].meta.changes === 1 ? 'fulfilled' : 'recorded';
}
```

`src/index.ts`:

```ts
import { applyPayment, findProduct, isLiveKey, railbed, RailbedError, verifyRailbedSignature } from './railbed';
import type { Env, Order, RailbedPayment } from './railbed';

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

const json = (body: unknown, status = 200) =>
  Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } });

async function checkout(request: Request, env: Env): Promise<Response> {
  const wantsJson = (request.headers.get('Content-Type') ?? '').includes('application/json');
  const input: Record<string, unknown> = wantsJson
    ? await request.json<Record<string, unknown>>().catch(() => ({}))
    : Object.fromEntries(await request.formData());
  const product = findProduct(input.product);
  if (!product) return json({ error: 'Unknown product.' }, 400);

  const orderId = `order_${crypto.randomUUID()}`;
  await env.DB.prepare(
    'INSERT INTO orders (id, product_id, amount, currency, livemode, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)',
  ).bind(orderId, product.id, product.amount, product.currency, isLiveKey(env) ? 1 : 0, Date.now()).run();

  const appUrl = (env.APP_URL || new URL(request.url).origin).replace(/\/+$/, '');
  try {
    const session = await railbed<{ id: string; url: string }>(env, '/checkout_sessions', {
      idempotencyKey: orderId,
      body: {
        amount: product.amount,
        currency: product.currency,
        description: product.name,
        reference: orderId,
        metadata: { order_id: orderId, product_id: product.id },
        success_url: `${appUrl}/checkout/success?order={REFERENCE}`,
        cancel_url: `${appUrl}/`,
      },
    });
    await env.DB.prepare('UPDATE orders SET payment_id = ?2 WHERE id = ?1').bind(orderId, session.id).run();
    return wantsJson ? json({ url: session.url }) : Response.redirect(session.url, 303);
  } catch (error) {
    console.error('Railbed: checkout session not created', orderId, error instanceof RailbedError ? error.code : error);
    return json({ error: "We couldn't start the payment. Please try again." }, 502);
  }
}

async function orderStatus(id: string, env: Env): Promise<Response> {
  const order = await env.DB.prepare('SELECT status FROM orders WHERE id = ?1').bind(id).first<{ status: string }>();
  return order ? json({ status: order.status }) : json({ error: 'Not found.' }, 404);
}

async function webhook(request: Request, env: Env): Promise<Response> {
  const raw = await request.text(); // the exact bytes Railbed signed: verify before parsing
  const secret = env.RAILBED_WEBHOOK_SECRET;
  if (!secret || !(await 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(env)) {
    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>(env, `/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 env.DB.prepare('SELECT * FROM orders WHERE payment_id = ?1').bind(payment.id).first<Order>();
  if (!order) return new Response(null, { status: 200 }); // not a payment this app created
  if (
    payment.livemode !== (order.livemode === 1) ||
    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(env, event.id, event.type, order, payment);
  console.log(`Railbed webhook ${event.type} ${event.id}: ${result}`);
  return new Response(null, { status: 200 });
}

export default {
  async fetch(request, env): Promise<Response> {
    const { pathname } = new URL(request.url);
    if (pathname === '/api/checkout') {
      return request.method === 'POST' ? checkout(request, env) : new Response(null, { status: 405, headers: { Allow: 'POST' } });
    }
    if (pathname === '/api/webhooks/railbed') {
      return request.method === 'POST' ? webhook(request, env) : new Response(null, { status: 405, headers: { Allow: 'POST' } });
    }
    const status = /^\/api\/orders\/(order_[A-Za-z0-9-]+)$/.exec(pathname);
    if (status && request.method === 'GET') return orderStatus(status[1], env);
    return env.ASSETS.fetch(request);
  },
} satisfies ExportedHandler<Env>;
```

`public/index.html`, the Pay button (or put the form in the project's own page):

```html
<form method="post" action="/api/checkout">
  <input type="hidden" name="product" value="pro">
  <button type="submit">Pay $49.00</button>
</form>
```

`public/checkout/success.html`, which reads the order's status and never marks it paid (R2):

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Your order</title>
</head>
<body>
  <main>
    <h1 id="message">Confirming your payment…</h1>
    <p id="detail">This usually takes a few seconds.</p>
  </main>
  <script>
    const MESSAGES = {
      paid: 'Payment received. Your order is confirmed.',
      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.',
    };
    const order = new URLSearchParams(location.search).get('order');
    let tries = 0;
    async function check() {
      const res = order ? await fetch(`/api/orders/${encodeURIComponent(order)}`, { cache: 'no-store' }) : null;
      if (!res || !res.ok) {
        document.getElementById('message').textContent = 'We couldn’t find that order.';
        document.getElementById('detail').textContent = '';
        return;
      }
      const { status } = await res.json();
      if (MESSAGES[status]) {
        document.getElementById('message').textContent = MESSAGES[status];
        document.getElementById('detail').textContent = '';
        return;
      }
      if (++tries < 40) setTimeout(check, 3000);
    }
    check();
  </script>
</body>
</html>
```

## 4. The webhook endpoint

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

The CLI writes `RAILBED_WEBHOOK_SECRET` into `.dev.vars`. Restart `wrangler dev` so it reads it.

## 5. Test it

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

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

2. Pay it: open that `url` and choose **Simulate successful payment**, or run the playbook's `simulate.mjs` with the `pay_…` id: `node simulate.mjs pay_… paid --env-path .dev.vars`.
3. Check: `wrangler dev` logs `Railbed webhook payment.paid evt_…: fulfilled`; `npx wrangler d1 execute my-app --local --command "SELECT status, paid_at FROM orders WHERE payment_id = 'pay_…'"` shows `paid`; `/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), including `replay.mjs` with `--env-path .dev.vars`.
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Apply the migration to the production database: `npx wrangler d1 migrations apply my-app --remote`.
2. Set `APP_URL` in `wrangler.jsonc`'s `vars` to the `https://` address.
3. Add the Live endpoint, which stores its secret in `.env.railbed` beside the Live key:

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

4. Make both Live values Worker secrets without printing them, delete the file, and deploy:

   ```bash
   for name in RAILBED_SECRET_KEY RAILBED_WEBHOOK_SECRET; do
     grep "^$name=" .env.railbed | cut -d= -f2- | tr -d '\n' | npx wrangler secret put "$name"
   done
   rm .env.railbed
   npx wrangler deploy
   ```

5. Check: `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://my-app.example.com/api/webhooks/railbed -d '{}'` answers `400`.
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

- `src/railbed.ts`: the products and their prices, the API client, signature verification and `applyPayment` (one D1 batch: the event, the order marked paid only if it isn't, and `grantStatements`).
- `src/index.ts`: `POST /api/checkout` (saves the order, creates the checkout session with Idempotency-Key = order id, redirects), `GET /api/orders/:id`, `POST /api/webhooks/railbed`.
- `public/index.html`: the Pay button. `public/checkout/success.html`: status only, never marks paid.
- `migrations/0001_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 `.dev.vars` (gitignored); Live as Worker secrets.
```

## Notes

- **Hono:** `app.post('/api/webhooks/railbed', (c) => webhook(c.req.raw, c.env))` keeps the raw request; don't read `c.req.json()` before verifying.
- **Pages Functions:** the same handlers as `functions/api/checkout.ts` (`export const onRequestPost: PagesFunction<Env> = ({ request, env }) => checkout(request, env)`), with secrets set in the Pages project.
- **Without D1:** use the project's database. Keep the event id unique and the paid update conditional, in one transaction with the grant.
