# Recipe: Express

For Node.js 18 or later with Express 4.17 or 5, on any 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 | The Express app |
| Database | PostgreSQL through `pg`. If the project has an ORM or another database, keep the two tables and the two unique constraints, and translate `applyPayment` |
| Webhook path | `/api/webhooks/railbed` |
| Connect | `npx checkouts.md login` (Test in `.env`, Live in `.env.production`) |

The code is ES modules (`"type": "module"` in `package.json`). In a CommonJS project, change each `import x from 'y'` to `const x = require('y')` and each `export` to `module.exports`.

## What this adds

| File | What it does |
|---|---|
| `db/railbed.sql` | The `orders` and `railbed_events` tables |
| `src/railbed/products.js` | What's for sale, priced on the server |
| `src/railbed/db.js` | The database pool |
| `src/railbed/api.js` | The API client and signature verification |
| `src/railbed/orders.js` | Orders, and applying a payment to its order exactly once |
| `src/railbed/routes.js` | `POST /api/checkout`, `GET /checkout/success` and the webhook route |
| The app's entry file | Mounts the routes, the webhook **before** `express.json()` |
| A page that sells | The Pay button |
| `CHECKOUTS.md` | How this project takes payments |

```bash
npm install pg
```

## Environment

| Name | Development (`.env`, gitignored) | Production (the host's secret store) |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` | `rb_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 |

Load `.env` the way the project does (`dotenv`, or `node --env-file=.env server.js` on Node 20.6 or later).

## 1. Connect

```bash
npx checkouts.md login
```

The Test key goes to `.env` and the Live key to `.env.production`, both gitignored. Relay the link and code to the person and wait for the command to finish. At go-live, move the Live key from `.env.production` into the host's secret store with a command that reads a file or standard input (playbook [3.1](https://checkouts.md/playbook.md#31-with-the-cli)).

## 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

`src/railbed/products.js`:

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

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

`src/railbed/db.js`:

```js
import pg from 'pg';

export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
```

`src/railbed/api.js`:

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

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

export class RailbedError extends Error {
  constructor(status, code, message, retryAfter) {
    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. */
export async function railbed(path, { body, idempotencyKey } = {}) {
  const key = process.env.RAILBED_SECRET_KEY;
  if (!key) throw new Error('RAILBED_SECRET_KEY is not set');
  const headers = { Authorization: `Bearer ${key}`, Accept: 'application/json' };
  if (body !== undefined) headers['Content-Type'] = 'application/json';
  if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
  const res = await fetch(`${API_BASE}${path}`, {
    method: body === undefined ? 'GET' : 'POST',
    headers,
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(15_000),
  });
  const data = await res.json().catch(() => null);
  if (res.ok) return data;
  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, header, secret, toleranceSeconds = 300) {
  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))));
}
```

`src/railbed/orders.js`:

```js
import { randomUUID } from 'node:crypto';
import { pool } from './db.js';

export async function createOrder(product, livemode, userId = null) {
  const { rows } = await pool.query(
    `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, paymentId) {
  await pool.query('UPDATE orders SET payment_id = $2 WHERE id = $1', [orderId, paymentId]);
}

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

export async function findOrderByPayment(paymentId) {
  const { rows } = await pool.query('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, order) {
  void client;
  console.info(`fulfil ${order.id} (${order.product_id})`);
}

/** 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, eventType, order, payment) {
  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 = 'recorded';
    if (payment.status === 'paid') {
      const paid = await client.query(
        `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 (['held', 'failed', 'expired'].includes(payment.status)) {
      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();
  }
}
```

`src/railbed/routes.js`:

```js
import express from 'express';
import { isLiveKey, railbed, RailbedError, verifyRailbedSignature } from './api.js';
import { applyPayment, createOrder, findOrder, findOrderByPayment, savePaymentId } from './orders.js';
import { findProduct } from './products.js';

/** POST /api/webhooks/railbed. Mount it before express.json(): it needs the raw body. */
export const railbedWebhook = express.Router();

railbedWebhook.post('/api/webhooks/railbed', express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const raw = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
    const secret = process.env.RAILBED_WEBHOOK_SECRET;
    if (!secret || !verifyRailbedSignature(raw, req.get('Railbed-Signature'), secret)) {
      return res.status(400).send('Invalid signature');
    }
    const event = JSON.parse(raw);
    if (!event.type.startsWith('payment.') || !event.data?.payment) return res.sendStatus(200);
    if (event.livemode !== isLiveKey()) {
      console.error('Railbed webhook: mode mismatch. RAILBED_SECRET_KEY and RAILBED_WEBHOOK_SECRET must be the same mode.');
      return res.status(500).send('Mode mismatch');
    }

    let payment;
    try {
      payment = await railbed(`/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 res.sendStatus(200);
      throw error;
    }

    const order = await findOrderByPayment(payment.id);
    if (!order) return res.sendStatus(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 res.sendStatus(200);
    }

    const result = await applyPayment(event.id, event.type, order, payment);
    console.info(`Railbed webhook ${event.type} ${event.id}: ${result}`);
    return res.sendStatus(200);
  } catch (error) {
    console.error('Railbed webhook failed; Railbed will retry', error instanceof RailbedError ? error.code : error);
    return res.status(500).send('Try again later');
  }
});

/** POST /api/checkout and GET /checkout/success. Mount after express.urlencoded() and express.json(). */
export const railbedCheckout = express.Router();

railbedCheckout.post('/api/checkout', async (req, res) => {
  const product = findProduct(req.body?.product);
  if (!product) return res.status(400).json({ error: 'Unknown product.' });

  const appUrl = (process.env.APP_URL ?? `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
  let order;
  try {
    // If the app has accounts, pass the signed-in user's id as the third argument.
    order = await createOrder(product, isLiveKey());
    const session = await railbed('/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 req.is('application/json') ? res.json({ url: session.url }) : res.redirect(303, session.url);
  } catch (error) {
    console.error('Railbed: checkout session not created', order?.id, error instanceof RailbedError ? error.code : error);
    return res.status(502).json({ error: "We couldn't start the payment. Please try again." });
  }
});

const MESSAGES = {
  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).
railbedCheckout.get('/checkout/success', async (req, res, next) => {
  try {
    const order = typeof req.query.order === 'string' ? await findOrder(req.query.order) : null;
    const message = order ? MESSAGES[order.status] ?? 'Checking your order…' : 'We couldn’t find that order.';
    const waiting = order?.status === 'awaiting_payment';
    const retry = order?.status === 'failed' || order?.status === 'expired';
    res.set('Cache-Control', 'no-store').send(`<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
${waiting ? '<meta http-equiv="refresh" content="3">' : ''}<title>Your order</title></head>
<body><main><h1>${message}</h1>${waiting ? '<p>This usually takes a few seconds.</p>' : ''}${retry ? '<p><a href="/">Try again</a></p>' : ''}</main></body></html>`);
  } catch (error) {
    next(error);
  }
});
```

The app's entry file (for example `server.js`):

```js
import express from 'express';
import { railbedCheckout, railbedWebhook } from './src/railbed/routes.js';

const app = express();
app.set('trust proxy', 1); // behind a host's proxy, so req.protocol is https
app.use(railbedWebhook); // first: it reads the raw body
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(railbedCheckout);
// …the project's other routes…
app.listen(process.env.PORT ?? 3000);
```

If the project already calls `express.json()` before its routes, move `app.use(railbedWebhook)` above it: once `express.json()` has parsed the body, the exact bytes are gone and every signature fails. If the project uses CSRF middleware (`csurf`, `csrf-csrf`), mount the webhook before it too. Authentication middleware mustn't cover `/api/webhooks/railbed`.

The Pay button, on any page:

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

A front end that calls the API with `fetch` sends `Content-Type: application/json` and gets `{ url }` back; it sends the browser there with `window.location.assign(url)`, or opens it over the page with `Railbed.open(url)` (playbook [5.3](https://checkouts.md/playbook.md#53-the-pay-button)).

## 4. The webhook endpoint

```bash
node --env-file=.env server.js                   # or the project's dev command
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
```

The CLI writes `RAILBED_WEBHOOK_SECRET` into `.env`. Restart the server so it 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**, or run the playbook's `simulate.mjs` with the `pay_…` id ([7.1](https://checkouts.md/playbook.md#71-the-end-to-end-run)).
3. Check: the log shows `Railbed webhook payment.paid evt_…: fulfilled`; `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): underpaid, declined, failed, a repeated event (`replay.mjs`) and a forged one.
5. Record the ids under Evidence in `CHECKOUTS.md`, and delete the tunnel's endpoint.

## 6. Go live

1. Put `RAILBED_SECRET_KEY` (from `.env.production`), `APP_URL` (`https://…`) and `DATABASE_URL` in the host's secret store without printing the key, and apply `db/railbed.sql` to the production database.
2. Deploy.
3. Add the Live endpoint: `npx checkouts.md webhook https://yourapp.com/api/webhooks/railbed --live`. It writes the Live `RAILBED_WEBHOOK_SECRET` into `.env.production` beside the Live key; move it to the host the same way, and restart.
4. Check: `npx checkouts.md status`; `curl -s -o /dev/null -w '%{http_code}\n' -X POST https://yourapp.com/api/webhooks/railbed -H 'Content-Type: application/json' -d '{}'` answers `400`.
5. 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/products.js`: the products and their prices.
- `src/railbed/routes.js`: `POST /api/checkout` (saves the order, creates the checkout session with Idempotency-Key = order id, redirects), `GET /checkout/success` (status only, never marks paid), `POST /api/webhooks/railbed` (verifies, re-reads, applies once via `applyPayment` in `src/railbed/orders.js`).
- `server.js`: mounts the webhook before `express.json()`.
- `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` (gitignored); Live in the host's secret store.
```

## Notes

- **Fastify** reads the raw body with a content-type parser for the route: `fastify.addContentTypeParser('application/json', { parseAs: 'string' }, (req, body, done) => done(null, body))` in an encapsulated plugin that registers only the webhook route; then `request.body` is the raw string.
- **Koa**: `ctx.request.rawBody`, which `koa-bodyparser` sets, or read `ctx.req` yourself before any body parser.
- **Behind a proxy** (Heroku, Render, Fly, Nginx), set `trust proxy` so `req.protocol` is `https`, or set `APP_URL`, which this code prefers.
