# The checkouts.md playbook

The complete procedure for adding a working checkout to a project with Railbed, for a coding agent or an app builder. Follow it from top to bottom. Each step says what to do, how to check it worked, and what can go wrong. When you're done, the project takes card payments, fulfils each paid order exactly once, and has a `CHECKOUTS.md` at its root that records how.

The rules, which every step below follows (from [spec.md](https://checkouts.md/spec.md)):

- **R1** Secrets never enter chat, logs or git. Only their names go in CHECKOUTS.md.
- **R2** Paid means a verified signed webhook, then a re-read of the payment by id. Never the redirect back.
- **R3** Amounts are decimal strings in the currency's units, like `"49.00"`.
- **R4** Test before Live, and record the evidence.
- **R5** Ask the person only for the account and the approval. Everything else is the agent's job.

## The six steps

First read the project: detect its stack and pick its recipe (`https://checkouts.md/recipes/<stack>.md`), find where its server code runs, and look for `CHECKOUTS.md` at its root. If it exists, follow it and add to it; don't start over ([playbook §1](https://checkouts.md/playbook.md#1-read-the-project)). Then:

1. **Account.** Make sure the person has a Railbed account with a business. The connect link in step 2 lets them sign up (https://app.railbed.io/signup) and set up the business on the way. Live payments also need a payout wallet in Railbed's Settings ([§2](https://checkouts.md/playbook.md#2-the-account)).
2. **Connect.** Run `npx checkouts.md login` with the recipe's options (`--env-path`, never `--env-file`), or use the HTTP connect flow. Give the person the link and the code it prints; they check that the code matches, then approve. The keys go straight into gitignored env files or the host's secret store as `RAILBED_SECRET_KEY`, never into the chat. If you can't store a secret without it showing in the conversation (some app builders), the person creates the keys in Railbed (Developers → Create key) and pastes them into the platform's secrets instead, never into the chat ([§3](https://checkouts.md/playbook.md#3-connect)).
3. **Write CHECKOUTS.md** at the project's root from the template (https://checkouts.md/templates/CHECKOUTS.md), with `mode: test`: what's for sale, what happens after payment, the wiring and the evidence. Keep it current as you go ([§4](https://checkouts.md/playbook.md#4-write-checkoutsmd)).
4. **Wire the checkout** from the recipe: products priced on the server; a route that saves an order, creates a checkout session (`POST https://pay.railbed.io/v1/checkout_sessions` with the order id as `Idempotency-Key` and `reference`) and sends the buyer to its `url`; a Pay button; a success page that only shows status; and a webhook receiver that verifies `Railbed-Signature` against the raw body, re-reads the payment and fulfils each order once. Add the endpoint with `npx checkouts.md webhook <url>`, which stores `RAILBED_WEBHOOK_SECRET` ([§5](https://checkouts.md/playbook.md#5-wire-the-checkout), [§6](https://checkouts.md/playbook.md#6-the-webhook)).
5. **Prove it in Test.** Create a session through the project's own route, then start it and simulate the payment (`POST /v1/checkout_sessions/:id/start`, then `POST /v1/payments/:id/simulate` with `{"outcome":"paid"}`). Check that the signed `payment.paid` arrives and verifies, the order is fulfilled exactly once, a repeated event changes nothing and a forged one gets `400`. Record the evidence in `CHECKOUTS.md` ([§7](https://checkouts.md/playbook.md#7-prove-it-in-test), [§9](https://checkouts.md/playbook.md#9-evidence)).
6. **Go Live.** Add the Live webhook endpoint for the production address (`npx checkouts.md webhook <url> --live`, with the same `--live-to` as `login`), put the Live key and that endpoint's secret on the production host without printing them, deploy, and set `mode: live` in `CHECKOUTS.md`. The first real payment is the Live evidence ([§8](https://checkouts.md/playbook.md#8-go-live)).

Then report back in plain words: what changed, what you verified and how, what's left, and where the person sees payments and revokes keys ([§11](https://checkouts.md/playbook.md#11-report-back)).

The sections below are the same procedure in full: every decision, command, request, error code and what to say to the person.

Contents: [1. Read the project](#1-read-the-project) · [2. The account](#2-the-account) · [3. Connect](#3-connect) · [4. CHECKOUTS.md](#4-write-checkoutsmd) · [5. Wire the checkout](#5-wire-the-checkout) · [6. The webhook](#6-the-webhook) · [7. Prove it in Test](#7-prove-it-in-test) · [8. Go Live](#8-go-live) · [9. Evidence](#9-evidence) · [10. Troubleshooting](#10-troubleshooting) · [11. Report back](#11-report-back)

## 1. Read the project

### 1.1 Look for CHECKOUTS.md

Look for a file named exactly `CHECKOUTS.md` at the repository root.

- **It exists.** Read it before anything else ([spec.md](https://checkouts.md/spec.md) defines it). Its frontmatter says the format version, provider and mode; `## Wiring` lists the files that take money; `## Evidence` says what has been proven. Continue from the first step below that its evidence doesn't cover. Keep its products and wiring unless the person asked for a change, and add to its evidence rather than replacing it.
- **Its `checkouts` version starts with a number other than `0`.** It's a newer major version than this playbook knows. Read it, follow what you understand, and don't rewrite its structure.
- **Its `provider` isn't `railbed`.** Another provider implements the format here. Follow that provider's playbook, not this one.
- **It doesn't exist.** You'll write it in [§4](#4-write-checkoutsmd).

If the project already takes payments another way, add Railbed beside it and leave the other provider's code alone unless the person asked you to replace it.

### 1.2 Detect the stack and pick a recipe

| Signals in the project | Recipe |
|---|---|
| `next` in `package.json`, an `app/` directory | [nextjs](https://checkouts.md/recipes/nextjs.md) |
| `next` with only `pages/` (Pages Router) | [nextjs](https://checkouts.md/recipes/nextjs.md): same code, with the two routes as `pages/api/*.ts` (its notes cover the raw body) |
| `express` in `package.json` | [express](https://checkouts.md/recipes/express.md) |
| `wrangler.toml`, `wrangler.json` or `wrangler.jsonc` | [cloudflare-workers](https://checkouts.md/recipes/cloudflare-workers.md) |
| A `supabase/` directory, or `@supabase/supabase-js` with no other server | [supabase](https://checkouts.md/recipes/supabase.md) |
| `artisan`, `laravel/framework` in `composer.json` | [laravel](https://checkouts.md/recipes/laravel.md) |
| `manage.py`, `django` in `requirements.txt` or `pyproject.toml` | [django](https://checkouts.md/recipes/django.md) |
| `config/routes.rb`, `rails` in `Gemfile` | [rails](https://checkouts.md/recipes/rails.md) |
| Only `.html`, `.css` and `.js` files, no server code | [html](https://checkouts.md/recipes/html.md) |
| Built in Lovable (`lovable-tagger` in `package.json`, or the person says so) | [lovable](https://checkouts.md/recipes/lovable.md) |
| Built in Bolt (a `.bolt/` directory, or the person says so) | [bolt](https://checkouts.md/recipes/bolt.md) |
| Running on Replit (a `.replit` file) | [replit](https://checkouts.md/recipes/replit.md) |
| Built in v0 | [v0](https://checkouts.md/recipes/v0.md) |

**Another stack** (Fastify, Hono, Remix, SvelteKit, Nuxt, Astro, Flask, FastAPI, Go, Phoenix…): every recipe is the same four pieces, so take the closest one and translate it. The pieces are a server route that creates a checkout session, a Pay button that calls it, a success page that only reads status, and a webhook route that reads the **raw** request body. JavaScript runtimes with Web `Request` objects (Hono, Remix, SvelteKit, Astro, Bun, Deno) follow [cloudflare-workers](https://checkouts.md/recipes/cloudflare-workers.md); Python frameworks follow [django](https://checkouts.md/recipes/django.md); Go reads the raw body with `io.ReadAll(r.Body)` and verifies as in [6.3](#63-verify-the-signature).

### 1.3 Find where server code runs

The secret key works only from a server: the API sends no CORS headers and has no publishable key. You need somewhere that keeps a secret and runs two small handlers, one to create sessions and one to receive webhooks.

- **The project has a server or serverless functions:** use them.
- **It's a static site:** the [html recipe](https://checkouts.md/recipes/html.md) adds two small functions, or uses a hosted checkout page with no server at all.
- **It's a single-page app with a backend-as-a-service:** use its functions (Supabase Edge Functions, Netlify or Vercel functions, Cloudflare Workers).

Never put a secret key in browser code, a mobile app, a URL or a public environment variable. Variables named `NEXT_PUBLIC_…`, `VITE_…`, `PUBLIC_…`, `EXPO_PUBLIC_…` or `REACT_APP_…` are compiled into the browser bundle: never use those prefixes for Railbed's secrets.

### 1.4 Decide what's for sale

Take products and prices from the project: a pricing page, plan constants, a products table. Prices live on the server, in one place, as decimal strings (`"49.00"`), and the browser sends only a product id. If the project shows no price, add one clearly named placeholder product, and say so in your report.

- Amounts: `"1.00"` to `"100000.00"`, at most two decimals, sent as JSON strings, never numbers.
- Currencies: `USD`, `EUR`, `GBP`, `CAD` or `AUD`.
- Card providers have minimum amounts. In Test mode the lowest is 2.00 USD; below every provider's minimum, starting a payment answers `409 no_providers`. In Live, the dashboard's Integrations → Card providers lists the current minimums.

## 2. The account

Railbed needs an account with a business before it can create keys. You can't check this from the project, and you never create an account or type a password for the person: it's theirs.

The connect link in [§3](#3-connect) handles it. A signed-out person who opens it can sign in or sign up (https://app.railbed.io/signup), set up their business, and come back to the approval. So start connecting, and tell them what to expect:

> I'll add a checkout with Railbed. Buyers pay by card, and every sale goes to a payout wallet you control. I need two things from you: a Railbed account, and one approval on a Railbed page. I'll do the rest, and test it with simulated payments before anything goes live.

Things only the person can do, and when they come up:

- **Approve as an owner or developer.** Only people who can manage keys for the business can approve. A teammate with another role asks an owner.
- **The business the keys belong to** is the one active on the approval page. A person with several businesses switches to the right one there first.
- **Live needs a payout wallet.** Approving Live for a business without one is refused (`no_payout_wallet`); the person adds a wallet in Settings, or approves Test only for now and connects again for Live later.

## 3. Connect

Connecting gives the project two secret keys, Test (`rb_test_…`) and Live (`rb_live_…`), without either one passing through the chat or a web page. You start a request, the person approves it on Railbed by matching a code, and the process that started it collects the keys once.

### 3.1 With the CLI

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

It prints a link and a code, opens the link in the browser when it can, waits for the approval, then writes the keys and prints only their masked form (`rb_test_••••3f9a`). Relay the link and the code to the person:

> Open this link: https://app.railbed.io/connect?code=7HQ2-XK4M
> Check that the page shows the code **7HQ2-XK4M**, then choose Approve. If you don't have an account yet, the link lets you create one first. Choose "Test and Live" if you're ready to take real payments (Live needs a payout wallet in Railbed's Settings), or "Test only" for now.

| Option | What it does |
|---|---|
| `--test-only` | Ask for a Test key only |
| `--env-path <file>` | Where the Test key goes, as `RAILBED_SECRET_KEY=…`. Default `.env`. (Not `--env-file`: Node reads that flag itself and stops when the file doesn't exist yet) |
| `--live-to <target>` | Where the Live key goes, as `RAILBED_SECRET_KEY`: `env:<file>` (default `env:.env.production`), `wrangler` (runs `wrangler secret put RAILBED_SECRET_KEY` with the value on standard input) or `vercel` (runs `vercel env add RAILBED_SECRET_KEY production`, value on standard input) |
| `--no-open` | Don't open a browser; just print the link |
| `--json` | Print JSON Lines: an `approval_needed` line with the link and code, then the result. Exit codes: 0 done, 1 failed, 2 usage, 130 interrupted |

Before it asks for anything, it checks the files it will write: one git already tracks, or one it can't write to, is refused while nothing has been created. In a git repository (or a folder with a `.gitignore`) it lists every file that gets a key in `.gitignore`. It names the agent from the environment (Claude Code, Codex, Cursor, Windsurf, Copilot, VS Code, else "Terminal") and the project from `package.json` or the folder. Both appear on the approval page as the agent's own claim. Exit code 0 means connected; 1 means declined, expired or an error, with the reason printed; 130 means it was stopped with Ctrl-C before collecting the keys (nothing was created; the link simply expires).

`login` waits up to 15 minutes for the approval. Run it in the background or with a long timeout, and read the link and code from its output as soon as they appear (with `--json`, the first line, `"event": "approval_needed"`).

Pick the targets for the project's host:

| Where the app runs | Command |
|---|---|
| Vercel (Next.js and others) | `npx checkouts.md login --env-path .env.local --live-to env:.env.railbed`, then at go-live push the Live values from `.env.railbed` to Vercel and delete the file ([8](#8-go-live)). Until then, if you deploy with `vercel deploy` from this folder, list `.env.railbed` in `.vercelignore` so the Live key isn't uploaded with the source |
| Cloudflare Workers | `npx checkouts.md login --env-path .dev.vars --live-to env:.env.railbed`, then at go-live push the Live values from `.env.railbed` with `wrangler secret put` and delete the file ([8](#8-go-live)) |
| A server or host that reads an env file you deploy privately | `npx checkouts.md login` (Test in `.env`, Live in `.env.production`; if git tracks `.env.production`, add `--live-to env:.env.railbed`) |
| Any other host (Render, Fly, Railway, Heroku, a VPS…) | `npx checkouts.md login`, then move the Live key from `.env.production` into the host's secret store with a command that reads a file or standard input, never a command-line argument. For example: `fly secrets import < .env.production`. If the host has no such command, the person copies it from that file into the host's settings |
| Supabase Edge Functions | `npx checkouts.md login --env-path supabase/functions/.env --live-to env:supabase/functions/.env.production`, then `supabase secrets set --env-file supabase/functions/.env.production` for Live (see the recipe) |

`--live-to vercel` and `--live-to wrangler` also exist: they put the Live key straight into the host, but then nothing local can read it, so creating the Live webhook endpoint later needs `--key-env NAME` with the key in an environment variable. A gitignored file you delete after go-live is simpler, which is why the recipes use one.

Other commands you'll use later: `npx checkouts.md webhook <url>` ([6.2](#62-create-the-endpoint)), `npx checkouts.md test` ([7.3](#73-a-quick-smoke-test)), `npx checkouts.md status` (which keys are present, masked, their mode, whether they work, and whether CHECKOUTS.md and the webhook secret exist) and `npx checkouts.md skill` (installs the checkouts skill for the agents in this project). `webhook`, `test` and `status` read the keys from the files `login` wrote, so give them the same `--env-path` and `--live-to`.

### 3.2 With HTTP, when npx can't run

Use the HTTP flow when the CLI can't run (no Node, no npm registry access, a Deno-only sandbox) but you can still run code that writes the keys straight into a file or a secret store. The keys must go from the response into storage without passing through your output: never print the response, never `echo` a key, never put one in a message.

> [!IMPORTANT]
> Store a key yourself only if your platform lets you save it from the response straight into its secrets without the value appearing in the conversation, a log or a file the person shares. If it doesn't (some app builders), don't use this flow. Ask the person to create the key themselves in Railbed (Test mode on in the business menu, then Developers → **Create key**, purpose **Checkout API**, **Full access**: a WooCommerce key can't create webhook endpoints) and to paste it into the platform's secrets panel as `RAILBED_SECRET_KEY`, never into the chat; at go-live, the same in Live mode. That is the approval step for those platforms. Each app-builder recipe says which applies.

**Start.** `POST https://checkouts.md/api/connect`, no authentication:

```http
POST https://checkouts.md/api/connect
Content-Type: application/json

{ "client": "Claude Code", "project": "lumen", "modes": ["test", "live"] }
```

| Field | Rules |
|---|---|
| `client` | Optional, up to 40 printable characters: the tool asking. Shown on the approval page as the agent's own claim |
| `project` | Optional, up to 60 characters: the folder or package name, shown the same way |
| `modes` | Optional: `["test"]` or `["test", "live"]` (the default). The person can narrow it when approving, never widen it |

```json
{
  "connect_id": "con_…",
  "device_code": "rbdc_…",
  "user_code": "7HQ2-XK4M",
  "verification_uri": "https://app.railbed.io/connect",
  "verification_uri_complete": "https://app.railbed.io/connect?code=7HQ2-XK4M",
  "expires_in": 900,
  "interval": 3
}
```

`201 Created`. Give the person `verification_uri_complete` and `user_code`. Keep `device_code` to yourself: it collects the keys. The request expires after `expires_in` seconds (15 minutes).

**Poll.** Every `interval` seconds, `POST https://checkouts.md/api/connect/token` with `{ "device_code": "rbdc_…" }`:

| Status | Body | What to do |
|---|---|---|
| `202` | `{ "status": "pending", "interval": 3 }` | Not approved yet. Wait `interval` seconds and poll again |
| `200` | `{ "status": "approved", … }` (below) | Approved. The keys are in this answer, once. Store them and stop |
| `403` | `{ "error": { "code": "access_denied" } }` | The person declined. Stop, and ask whether they meant to |
| `410` | `{ "error": { "code": "expired_token" } }` | 15 minutes passed. Start again for a new link and code |
| `410` | `{ "error": { "code": "already_delivered" } }` | The keys were already collected. Start again for new ones |
| `409` | `{ "error": { "code": "approval_revoked" } }` | Between approval and pickup the approver lost the right to manage keys, or the business was paused, suspended or archived, or its Checkout API was turned off. Start again; a different owner or developer may need to approve |
| `404` | `{ "error": { "code": "invalid_device_code" } }` | Unknown `device_code`. Start again |
| `429` | `{ "error": { "code": "slow_down" } }` | You polled faster than `interval`. Add 5 seconds to your interval (as RFC 8628 says), or wait `Retry-After` if it's longer, then carry on |
| `400` | `{ "error": { "code": "invalid_request" } }` | No `device_code` in the body |
| `502`, `504` | `upstream_unavailable`, `upstream_timeout` | checkouts.md didn't get Railbed's answer in time. Wait a few seconds and poll again. If the next answer is `already_delivered`, the keys were created but their answer was lost: start again, and tell the person to revoke the unused "… via checkouts.md" keys in Developers |

The `200` answer:

```json
{
  "status": "approved",
  "business": { "id": "mer_…", "name": "Lumen Studio" },
  "modes": ["test", "live"],
  "keys": {
    "test": { "id": "key_…", "secret": "rb_test_…", "last4": "3f9a" },
    "live": { "id": "key_…", "secret": "rb_live_…", "last4": "81c2" }
  },
  "api_base": "https://pay.railbed.io/v1",
  "dashboard_url": "https://app.railbed.io"
}
```

- `modes` is what the person approved, which can be less than you asked for: `keys.live` is absent when they chose Test only.
- An approval made in the request's last moments can still be collected for 5 minutes after it expires.
- The keys are created at this moment and returned only in this answer; Railbed never stores them in plain text. Each is **Full access**, named "{client} via checkouts.md", and listed in the dashboard under Developers, where the person can revoke it.
- Errors use Railbed's shape, `{ "error": { "code", "message" } }`.

A complete script that does all of this, writes the Test key to `.env` and the Live key to `.env.production`, adds both to `.gitignore`, and prints only masked keys (Node 18 or later, no dependencies). Write it outside the project, run it, then delete it:

```js
// connect.mjs: checkouts.md connect over HTTP, when `npx checkouts.md login` can't run.
//   node connect.mjs [--client "Claude Code"] [--test-only] [--env-path .env] [--live-path .env.production]
// Prints the approval link and code. Never prints a key.
import { execFileSync } from 'node:child_process';
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
import { basename } from 'node:path';

const ORIGIN = (process.env.CHECKOUTS_ORIGIN || 'https://checkouts.md').replace(/\/+$/, '');
const argv = process.argv.slice(2);
const option = (name, fallback) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : fallback);
const testOnly = argv.includes('--test-only');
const envFile = option('--env-path', '.env') ?? '.env';
const liveFile = option('--live-path', '.env.production') ?? '.env.production';
const client = String(option('--client', 'Agent') ?? 'Agent').slice(0, 40);

function projectName() {
  try {
    const name = JSON.parse(readFileSync('package.json', 'utf8')).name;
    if (typeof name === 'string' && name) return name.slice(0, 60);
  } catch {}
  return basename(process.cwd()).slice(0, 60);
}

/** True when git already tracks the file, so a key written there would be committed. */
function trackedByGit(file) {
  try {
    execFileSync('git', ['ls-files', '--error-unmatch', '--', file], { stdio: 'ignore' });
    return true;
  } catch {
    return false; // not tracked, or not a git repository
  }
}

/** Adds the file to .gitignore unless it's listed already. */
function ignore(file) {
  const ignored = existsSync('.gitignore') ? readFileSync('.gitignore', 'utf8') : '';
  if (!ignored.split('\n').some((line) => [file, `/${file}`].includes(line.trim()))) {
    appendFileSync('.gitignore', `${ignored === '' || ignored.endsWith('\n') ? '' : '\n'}${file}\n`);
  }
}

/** Sets NAME=value in an env file, created readable by its owner only. */
function writeSecret(file, name, value) {
  const lines = existsSync(file) ? readFileSync(file, 'utf8').split('\n') : [];
  while (lines.length && lines[lines.length - 1] === '') lines.pop();
  const at = lines.findIndex((line) => line.startsWith(`${name}=`));
  if (at === -1) lines.push(`${name}=${value}`);
  else lines[at] = `${name}=${value}`;
  writeFileSync(file, `${lines.join('\n')}\n`, { mode: 0o600 });
}

// Before asking for anything: the files must be ignored by git, and not already tracked by it.
const files = testOnly ? [envFile] : [envFile, liveFile];
const tracked = files.filter(trackedByGit);
if (tracked.length) {
  console.error(`git tracks ${tracked.join(' and ')}, so a key written there would be committed. Choose other files with --env-path and --live-path.`);
  process.exit(1);
}
files.forEach(ignore);

async function post(path, body) {
  const res = await fetch(`${ORIGIN}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(20_000),
  });
  const data = await res.json().catch(() => ({}));
  return { status: res.status, data, retryAfter: Number(res.headers.get('Retry-After')) || 0 };
}

const sleep = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000));

const start = await post('/api/connect', {
  client,
  project: projectName(),
  modes: testOnly ? ['test'] : ['test', 'live'],
});
if (start.status !== 201) {
  console.error(`Couldn't start (${start.status} ${start.data.error?.code ?? ''}): ${start.data.error?.message ?? ''}`);
  process.exit(1);
}
const { device_code: deviceCode, user_code: userCode, verification_uri_complete: link } = start.data;
let interval = start.data.interval || 3;
console.log(`Open ${link}\nand check that the page shows the code ${userCode}. Waiting for approval…`);

const deadline = Date.now() + (start.data.expires_in || 900) * 1000;
while (Date.now() < deadline) {
  await sleep(interval);
  let poll;
  try {
    poll = await post('/api/connect/token', { device_code: deviceCode });
  } catch {
    interval = Math.min(interval * 2, 30); // a network hiccup: keep polling
    continue;
  }
  if (poll.status === 202) {
    interval = Math.max(interval, poll.data.interval || 0);
    continue;
  }
  if (poll.status === 429) {
    interval = Math.max(interval + 5, poll.retryAfter);
    continue;
  }
  if (poll.status >= 500) {
    interval = Math.min(Math.max(interval * 2, poll.retryAfter), 30);
    continue;
  }
  if (poll.status === 200) {
    const { business, keys } = poll.data;
    if (keys.test) {
      writeSecret(envFile, 'RAILBED_SECRET_KEY', keys.test.secret);
      console.log(`Connected to ${business.name}. Test key rb_test_••••${keys.test.last4} → ${envFile}`);
    }
    if (keys.live) {
      writeSecret(liveFile, 'RAILBED_SECRET_KEY', keys.live.secret);
      console.log(`Live key rb_live_••••${keys.live.last4} → ${liveFile}`);
    }
    process.exit(0);
  }
  console.error(`Stopped (${poll.status} ${poll.data.error?.code ?? ''}): ${poll.data.error?.message ?? ''}`);
  process.exit(1);
}
console.error('The code expired before it was approved. Run this again for a new one.');
process.exit(1);
```

The same two calls work in any language: start once, then poll at `interval` until a final status. Deno 2 runs the script as it is (`deno run -A connect.mjs`).

### 3.3 Where each secret lives

| Name | Value | Where |
|---|---|---|
| `RAILBED_SECRET_KEY` | `rb_test_…` | Development and preview environments: `.env`, `.env.local`, `.dev.vars`, or the platform's development secrets |
| `RAILBED_SECRET_KEY` | `rb_live_…` | The production host's secret store only |
| `RAILBED_WEBHOOK_SECRET` | `whsec_…` | Beside the key of the same mode: each endpoint has its own secret, and Test and Live endpoints are separate |
| `APP_URL` | `https://yourapp.com` | Not a secret: the app's public address, for `success_url` and `cancel_url` |

- Every env file that holds a secret is in `.gitignore` before anything is written to it. Check with `git check-ignore -v .env`.
- The code reads secrets by name (`process.env.RAILBED_SECRET_KEY`); no value appears in source, tests, fixtures, docs, commit messages or CHECKOUTS.md.
- To confirm a key is present without printing it: `npx checkouts.md status`, or `node -e "console.log((process.env.RAILBED_SECRET_KEY||'').slice(0,8))"` (prints only the `rb_test_` or `rb_live_` prefix).
- A secret that leaked (into a chat, a log, a commit): tell the person; they revoke the key in Developers (revoking a key also removes the webhook endpoints it added) or roll the endpoint's secret, and you connect again.
- Connecting again creates new keys; the old ones keep working until revoked. Name the extra keys in your report so the person can revoke the ones nothing uses, and warn them first if a key added a webhook endpoint that's still in use: revoking a key removes the endpoints it created.

## 4. Write CHECKOUTS.md

Copy [templates/CHECKOUTS.md](https://checkouts.md/templates/CHECKOUTS.md) to the project root and fill it in as you go ([spec.md](https://checkouts.md/spec.md) defines every field). Start it now, with `mode: test`:

- **Frontmatter:** `checkouts: "0.1"`, `provider: railbed`, `mode: test`, `currency` (the default currency of the products), `secrets` (the names, never the values) and `webhook` (the receiver's path).
- **Products:** the table of what's for sale, from [1.4](#14-decide-whats-for-sale).
- **After payment:** what the project does once an order is paid, in one or two sentences, and where that code is.
- **Wiring:** every file you add or change, and the webhook endpoints' ids (`we_…`, not secret) per mode.
- **Evidence:** empty until [§7](#7-prove-it-in-test).

Commit it with the code. It contains no secrets, so it's safe in a public repository.

## 5. Wire the checkout

The recipe for the stack has the complete files. This section explains what they do and why, so you can adapt them to a project that differs.

### 5.1 Keep your own orders

The project records each order before asking Railbed for a payment, and links the two. Two tables are enough; use the project's own database and ORM when it has them, and keep both unique constraints:

```sql
CREATE TABLE orders (
  id          text PRIMARY KEY,           -- order_…: your id, sent as reference and in the Idempotency-Key
  product_id  text NOT NULL,
  amount      text NOT NULL,              -- "49.00": the exact decimal string sent to Railbed
  currency    text NOT NULL,              -- "USD"
  livemode    boolean NOT NULL,           -- true when created with an rb_live_ key
  status      text NOT NULL DEFAULT 'awaiting_payment', -- awaiting_payment | paid | held | failed | expired
  payment_id  text UNIQUE,                -- pay_…, saved when the session is created
  user_id     text,                       -- the signed-in buyer, if the app has accounts
  created_at  timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  paid_at     timestamp
);

CREATE TABLE railbed_events (
  id          text PRIMARY KEY,           -- evt_…: each webhook event is handled once
  type        text NOT NULL,
  payment_id  text,
  received_at timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
);
```

This works in PostgreSQL and SQLite (use `timestamptz` in PostgreSQL if the project does). If the project already has an `orders` table, add the missing columns to it, or name this one `checkout_orders`. Resolve the buyer from your own order (`user_id`), never from the email the buyer typed at checkout.

### 5.2 Create the checkout session on the server

When the buyer clicks Pay, the server looks up the product, saves an order, and creates a session for it:

```http
POST https://pay.railbed.io/v1/checkout_sessions
Authorization: Bearer $RAILBED_SECRET_KEY
Content-Type: application/json
Idempotency-Key: order_5f0c9a8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c

{
  "amount": "49.00",
  "currency": "USD",
  "description": "Pro plan",
  "reference": "order_5f0c9a8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c",
  "metadata": { "order_id": "order_5f0c9a8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c", "product_id": "pro" },
  "success_url": "https://yourapp.com/checkout/success?order={REFERENCE}",
  "cancel_url": "https://yourapp.com/pricing"
}
```

`201 Created` (or `200` for a replay of the same key and body) answers the session:

```json
{
  "id": "pay_7AAiYH0Ykt11ED4hmfiN",
  "object": "checkout_session",
  "url": "https://pay.railbed.io/p/pay_7AAiYH0Ykt11ED4hmfiN",
  "status": "open",
  "livemode": false,
  "amount": "49.00",
  "currency": "USD",
  "description": "Pro plan",
  "reference": "order_5f0c9a8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c",
  "customer_email": null,
  "created": 1790380525,
  "expires_at": 1790466925,
  "started_at": null,
  "metadata": { "order_id": "order_5f0c9a8e-2b1d-4c3e-9f7a-1d2e3f4a5b6c", "product_id": "pro" },
  "order_id": "ord_M4tQx8ZkP1vRn6WcLs2B",
  "collect": null,
  "canceled_at": null
}
```

Save `id` (`pay_…`) as the order's `payment_id`, then send the buyer to `url`. Session and payment share that id.

| Field | Rules |
|---|---|
| `amount` | Required. Decimal string, `"1.00"` to `"100000.00"`, at most two decimals. A JSON number, an exponent or a negative is refused (`400 invalid_amount`). `"49"` becomes `"49.00"` |
| `currency` | Required. `USD`, `EUR`, `GBP`, `CAD` or `AUD`, any letter case |
| `description` | Required. Up to 120 characters, shown to the buyer |
| `reference` | Your order id, up to 120 characters. Returned on the payment and every webhook. Not unique on Railbed's side: your order table enforces that |
| `metadata` | Up to 20 string keys (1–40 characters, not starting with `__`) with string values up to 500 characters. Your own ids only, never secrets |
| `customer_email` | Optional here. The hosted page asks for it if absent |
| `success_url` | Where Railbed's checkout tab sends the buyer once the payment is confirmed. `{REFERENCE}` and `{PAYMENT_ID}` are filled in. Live needs `https://`; Test also accepts `http://` |
| `cancel_url` | The "back to your store" link on the checkout. Same rules |
| `customer`, `order` | Optional: the buyer's name and phone, and the items, discount, shipping, tax and addresses. With items, items − discount + shipping + tax must equal `amount` to the cent. See https://railbed.com/docs/api/checkout-sessions.md |
| `collect` | Optional: what the hosted page asks the buyer for besides their email (name, phone, addresses, date of birth, up to five questions of your own). Same reference |

**Idempotency.** Send an `Idempotency-Key` (1–120 printable ASCII characters) unique to the order attempt, saved with the order before the call. A retry with the same key and body returns the same session (`200`), never a second payment. The same key with a different body is `409 idempotency_conflict`: don't retry it. `409 idempotency_in_progress` means the first request hasn't finished: wait `Retry-After` (1 second) and send the same request again. Keys are remembered for 24 hours, per business and mode. Using the order id as the key works when each order is paid by one session; when a buyer retries a failed order, create a new order (or use `order_…-attempt-2`).

**Errors to handle when creating:**

| Answer | Meaning | What the server does |
|---|---|---|
| `400` (`invalid_amount`, `invalid_currency`, `missing_description`, `too_long`, `invalid_url`, `invalid_metadata`, `invalid_order_totals`…) | Your request is wrong | Fix the code. `field` names the culprit |
| `401 invalid_api_key` | The key is missing, malformed or revoked | Check `RAILBED_SECRET_KEY` is set in this environment |
| `403 insufficient_scope` | A Read only key | Use a Full access key (connect gives Full access) |
| `409 no_payout_wallet` | Live, and the business has no payout wallet | Tell the person to add one in Railbed's Settings |
| `409 commerce_paused` | The business has paused taking payments | Show "Payments are paused" and tell the person |
| `403 merchant_read_only` | The business is suspended or archived | Tell the person; they contact support@railbed.com |
| `429 rate_limited` | Over about 120 requests a minute per mode | Wait `Retry-After` seconds (60) |
| `500`, `502`, `503` | Railbed or a network failed | Retry with the **same** key and body, with backoff |

Show the buyer a plain message for anything that isn't a success ("We couldn't start the payment. Please try again."), and log the code, never the key.

### 5.3 The Pay button

The button posts the product id to your server; the server answers with a redirect to the session's `url`. A plain HTML form works with or without JavaScript:

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

Single-page apps can `fetch` the same route, get `{ "url": "…" }` back, and `window.location.assign(url)`. To keep buyers on your page instead, open the `url` in a pop-up over it with Railbed's embed script:

```html
<script src="https://pay.railbed.io/embed.js" async></script>
<script>
  async function pay(product) {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
      body: JSON.stringify({ product }),
    });
    if (!res.ok) return alert("We couldn't start the payment. Please try again.");
    const { url } = await res.json();
    if (!window.Railbed) return location.assign(url); // the script is still loading
    Railbed.open(url).then(
      () => location.reload(),   // closed: ask your server for the order's status
      () => location.assign(url), // not a session url: use the page instead
    );
  }
</script>
```

`Railbed.open(url)` accepts only a session `url` from `https://pay.railbed.io/p/pay_…`. After the payment is confirmed, the whole page goes to your `success_url`. A site with a Content Security Policy allows `https://pay.railbed.io` in `script-src` and `frame-src`.

What the buyer sees: Railbed's checkout tab (your business name, the description and the price) asks for their email, lists the card providers that serve their country and amount, and opens the chosen provider in a new tab. The buyer pays there. Railbed's tab waits, then sends them to `success_url`. Providers don't send buyers back by themselves, and the card statement shows the provider's name.

### 5.4 The success page

The success page shows the order's status from your own database. It **never** marks anything paid: anyone can open `success_url` by hand, and a buyer who paid can close the tab before it loads (R2).

- `paid`: "Payment received. Your order is confirmed."
- `awaiting_payment`: "Confirming your payment…", and refresh every few seconds for a minute or two. The webhook usually arrives before the buyer does.
- `held`: "Your payment is being reviewed. We'll email you." (Money arrived but failed a check; the business decides in the dashboard.)
- `failed` or `expired`: "This payment didn't complete", with a link to try again (a new order).

If the app has accounts, show an order only to its owner.

### 5.5 Your own payment screen (optional)

Instead of Railbed's hosted page, the server can start the session itself and the app can show the card providers: `POST /v1/checkout_sessions/:id/start` with `{ "customer_email": "…", "country": "US" }` answers the session plus `providers: [{ id, name, note, recommended, handoff_url }]`. Open the chosen `handoff_url` in a new tab from the buyer's click; never fetch it from the server or frame it. Details: https://railbed.com/docs/guides/custom-checkout.md. Most projects should use the hosted page.

### 5.6 Payment links and checkout pages through the API

Two more things the API can create, so you never need the dashboard:

- **A payment link**: a one-off request for a fixed amount, paid once, for an invoice or a custom order. `POST /v1/payment_links` with `amount`, `currency`, `description` and optional `customer_name`, `customer_email`, `reference` (up to 64 characters), `memo`, `expires_in_days` (a number from 1 to 30, default 7) and `success_url`; send the returned `url` to the payer. There's no `Idempotency-Key` here: a retry that did create a link leaves an unpaid one you can cancel. `GET /v1/payment_links`, `GET /v1/payment_links/:id` and `POST /v1/payment_links/:id/cancel` read and cancel them. A payment link's id is its payment's id, so `GET /v1/payments/:id` and the same webhooks apply.
- **A checkout page**: a reusable page every buyer can pay, with a `slug` for the buy button. `POST /v1/checkouts` with `name`, `amount`, `currency` and optional `description`, `success_url` and `button_label`; `GET /v1/checkouts` and `GET /v1/checkouts/:id` read them. The page is the returned `url`. On a website, the button needs only the slug, no secret:

```html
<script src="https://pay.railbed.io/embed.js" async></script>
<railbed-button checkout="your-checkout-slug" label="Buy now"></railbed-button>
```

Every buyer of a checkout page gets their own payment, with the order and customer in every webhook. Your server didn't create those payments, so there's no order of yours to find by `payment_id`: the receiver in [§6](#6-the-webhook) recognises them by the verified event's `data.payment.checkoutId` (the `chk_…` id the create returned), re-reads the payment for its status and amount as usual, and records its own order keyed on the payment id, so each is fulfilled once. The buyer's details are in `data.customer` and `GET /v1/orders/{order_id}`.

### 5.7 Cancel a session

`POST /v1/checkout_sessions/:id/cancel` (no body) closes a session the buyer hasn't been handed to a provider for yet, whether or not it was started: it answers the session with `status: "expired"` and `canceled_at` set, sends `payment.canceled`, and repeating it answers the same. Once the buyer has been handed to a provider it answers `409 not_cancelable`, because they may already be paying; so does a payment that is paid, held, failed or expired, and a payment from a checkout page. Payment links have the same route, `POST /v1/payment_links/:id/cancel`. Late money still settles a canceled session, so honour a `paid` that arrives after a cancel: fulfil it, or refund it from the wallet.

## 6. The webhook

Railbed tells the project about every payment change with a signed `POST` to an endpoint. This is the only thing that marks an order paid (R2).

### 6.1 Get a public https address

Railbed's servers send webhooks from the internet, so the receiver needs a public `https://` address. Test endpoints accept `http://localhost` too, but Railbed can't reach your computer that way.

- **A deployed preview or staging environment** that runs the code: use its address, with the Test key and Test webhook secret in its settings.
- **Your own computer:** run the app, then a tunnel to it in another terminal:

  ```bash
  cloudflared tunnel --url http://localhost:3000
  ```

  It prints an address like `https://random-words.trycloudflare.com` that forwards to port 3000 while it runs, with no account needed. `ngrok http 3000` works the same way once ngrok is signed in with an account. The address changes each time the tunnel starts.

The receiver's path is in the recipe, usually `/api/webhooks/railbed`. The full address is then, for example, `https://random-words.trycloudflare.com/api/webhooks/railbed`.

### 6.2 Create the endpoint

```bash
npx checkouts.md webhook https://random-words.trycloudflare.com/api/webhooks/railbed
```

It creates the endpoint in the mode of the key it uses (the Test key from the env file; add `--live` for the Live key), writes its signing secret as `RAILBED_WEBHOOK_SECRET` beside that key, and prints the endpoint's id and the masked secret. `--events payment.paid,payment.failed,…` limits the events; by default it gets all of them. Give it the same files as `login`: `--env-path <file>` when the Test key isn't in `.env`, and `--live-to env:<file>` when the Live key isn't in `.env.production`, as in the recipes. If the Live key went straight into a host's secret store (`--live-to vercel` or `wrangler`), the CLI can't read it back and stops with `key_unreadable`; pass `--key-env NAME`, naming an environment variable that holds the Live key.

Over HTTP, with the secret key:

```http
POST https://pay.railbed.io/v1/webhook_endpoints
Authorization: Bearer $RAILBED_SECRET_KEY
Content-Type: application/json

{ "url": "https://random-words.trycloudflare.com/api/webhooks/railbed", "description": "checkouts.md: lumen" }
```

`201 Created` answers the endpoint and, only this once, its `secret` (`whsec_…`):

```json
{
  "id": "we_N8nQ4vRx7KtZ2mLp9WcB",
  "object": "webhook_endpoint",
  "url": "https://random-words.trycloudflare.com/api/webhooks/railbed",
  "description": "checkouts.md: lumen",
  "events": ["payment.started", "payment.paid", "payment.held", "payment.updated", "payment.failed", "payment.expired", "payment.canceled"],
  "livemode": false,
  "mode": "test",
  "previous_secret_expires_at": null,
  "created": 1790380525,
  "secret": "whsec_…"
}
```

Store `secret` straight into the env file or secret store, as with the keys. A script that does that and prints only masked values:

```js
// webhook.mjs: node webhook.mjs <https url> [--env-path .env] [--events payment.paid,payment.held]
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';

const API = (process.env.RAILBED_API_BASE || 'https://pay.railbed.io/v1').replace(/\/+$/, '');
const [url, ...rest] = process.argv.slice(2);
const option = (name, fallback) => (rest.includes(name) ? rest[rest.indexOf(name) + 1] : fallback);
const envFile = option('--env-path', '.env');
const events = option('--events', null)?.split(',');

const read = (name) => {
  const line = existsSync(envFile)
    ? readFileSync(envFile, 'utf8').split('\n').find((l) => l.startsWith(`${name}=`))
    : undefined;
  return line ? line.slice(name.length + 1).trim().replace(/^(['"])(.*)\1$/, '$2') : process.env[name];
};
const key = read('RAILBED_SECRET_KEY');
if (!url || !key) {
  console.error(`Usage: node webhook.mjs <https url>, with RAILBED_SECRET_KEY in ${envFile}`);
  process.exit(1);
}
try {
  execFileSync('git', ['ls-files', '--error-unmatch', '--', envFile], { stdio: 'ignore' });
  console.error(`git tracks ${envFile}, so the signing secret would be committed. Use a gitignored file with --env-path.`);
  process.exit(1);
} catch {} // not tracked, or not a git repository

const res = await fetch(`${API}/webhook_endpoints`, {
  method: 'POST',
  headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ url, description: 'checkouts.md', ...(events ? { events } : {}) }),
});
const data = await res.json().catch(() => ({}));
if (res.status !== 201) {
  console.error(`Not created (${res.status} ${data.error?.code ?? ''}): ${data.error?.message ?? ''}`);
  process.exit(1);
}
const lines = existsSync(envFile) ? readFileSync(envFile, 'utf8').split('\n') : [];
while (lines.length && lines[lines.length - 1] === '') lines.pop();
const at = lines.findIndex((l) => l.startsWith('RAILBED_WEBHOOK_SECRET='));
if (at === -1) lines.push(`RAILBED_WEBHOOK_SECRET=${data.secret}`);
else lines[at] = `RAILBED_WEBHOOK_SECRET=${data.secret}`;
writeFileSync(envFile, `${lines.join('\n')}\n`, { mode: 0o600 });
console.log(`${data.mode} endpoint ${data.id} → ${data.url}\nsigning secret whsec_••••${data.secret.slice(-4)} → ${envFile}`);
```

Restart the app after the secret changes, so it reads the new value.

| Rule | Detail |
|---|---|
| Address | `https://`, no username or password, up to 2,048 characters. Live endpoints must be on the public internet |
| One mode | An endpoint receives only its own mode's events, and has its own secret |
| Limits | 10 endpoints per mode, each at a different address (`409 duplicate_url`, `409 too_many`) |
| No edits through the API | To change the address (a new tunnel), create a new endpoint and delete the old one with `DELETE /v1/webhook_endpoints/:id`; the new one has a new secret. Running `npx checkouts.md webhook` with the new address creates the new endpoint and replaces `RAILBED_WEBHOOK_SECRET`, but it doesn't delete the old endpoint: do that yourself, or in Developers |
| Tied to the key | Revoking the key that added an endpoint removes the endpoint |
| Clean up | Delete temporary tunnel endpoints when you finish testing: `curl -X DELETE https://pay.railbed.io/v1/webhook_endpoints/we_… -H "Authorization: Bearer $RAILBED_SECRET_KEY"` (in a script that reads the key from the env file, not a typed command) |

### 6.3 Verify the signature

Every delivery carries:

```text
Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
```

1. Read the header. Require one `t=<digits>` then one or more `,v1=<64 hex characters>`. Reject anything else.
2. Reject it if `t` is more than 300 seconds from your clock (a replay of a captured request).
3. Compute HMAC-SHA256 of the string `{t}.{raw body}`, keyed with the whole `whsec_…` secret as text, and hex-encode it.
4. Compare it with **every** `v1` in constant time; accept when any matches. For 24 hours after someone rolls the secret in the dashboard, the header carries two `v1` values, one per key. Never parse the header into a key/value map: that loses one of them.

Verify the **raw** request body: the exact bytes received, before any JSON parsing. Parsing and re-serializing changes spacing and escaping, and the signature then fails.

| Framework | The raw body |
|---|---|
| Express | `express.raw({ type: 'application/json' })` on the webhook route, then `req.body.toString('utf8')` |
| Next.js (App Router), Cloudflare Workers, Deno, Bun, Hono | `await request.text()` |
| Next.js (Pages Router) | `export const config = { api: { bodyParser: false } }`, then read the stream |
| Fastify | A content-type parser with `parseAs: 'string'` for the route |
| Django | `request.body` |
| Flask | `request.get_data()` |
| FastAPI | `await request.body()` |
| Laravel | `$request->getContent()` |
| Plain PHP | `file_get_contents('php://input')` |
| Rails | `request.raw_post` |
| Go | `io.ReadAll(r.Body)` |

```js
// Node.js
import crypto from 'node:crypto';

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))));
}
```

```ts
// Web Crypto: Cloudflare Workers, Deno, Bun, Next.js route handlers
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;
}
```

```python
# Python
import hashlib, hmac, re, time

def verify_railbed_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
    match = re.fullmatch(r"t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)", header or "")
    if not match:
        return False
    timestamp, signatures = match.groups()
    if abs(time.time() - int(timestamp)) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, part[3:]) for part in signatures[1:].split(","))
```

```php
<?php
// PHP
function verify_railbed_signature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool {
  if (!preg_match('/^t=(\d{1,12})((?:,v1=[0-9a-f]{64})+)$/D', $header, $match)) return false;
  [, $timestamp, $signatures] = $match;
  if (abs(time() - (int) $timestamp) > $toleranceSeconds) return false;
  $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
  foreach (explode(',', substr($signatures, 1)) as $part) {
    if (hash_equals($expected, substr($part, 3))) return true;
  }
  return false;
}
```

```ruby
# Ruby
require 'openssl'
require 'rack/utils' # secure_compare; Rails and Sinatra already load it

def verify_railbed_signature(raw_body, header, secret, tolerance_seconds = 300)
  match = /\At=(\d{1,12})((?:,v1=[0-9a-f]{64})+)\z/.match(header.to_s)
  return false unless match
  timestamp, signatures = match.captures
  return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
  signatures[1..].split(',').any? { |part| Rack::Utils.secure_compare(expected, part[3..]) }
end
```

**Test vector.** Check the HMAC step against these known values (the secret is an example):

| Input | Value |
|---|---|
| Secret | `whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e` |
| `t` | `1790380800` |
| Raw body | `{"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,"livemode":false,"data":{"payment":null,"order":null,"customer":null}}` |
| Expected `v1` | `b8b4ad5be59701daf771f57b3ada57d26b5ff7048af7b90230067f4aaafd0ead` |

The timestamp is in the past, so pass a very large tolerance to test with it. Change one byte of the body and the result must be `false`.

### 6.4 Handle the event

The receiver does this, in order:

1. **Verify** the signature against the raw body. Invalid: answer `400` and stop.
2. **Parse** the JSON. Use only the event's `id`, `type`, `livemode`, `data.payment.id` (and, for checkout pages, `data.payment.checkoutId`); everything else comes from the API in step 5.
3. **Ignore what isn't a payment event:** a `type` that doesn't start with `payment.` (`ping`, or a type added later) gets `200` and nothing else.
4. **Check the mode.** `livemode` must match the key's mode (`rb_live_` means Live). A mismatch means this environment has the other mode's key or secret: answer `500` so the delivery shows as failed in the dashboard's log, and fix the configuration.
5. **Re-read the payment** with `GET /v1/payments/{data.payment.id}`. A `404` for a Test event whose `data.payment.metadata.sample` is `"true"` is a sample from the dashboard's **Send test event**: answer `200` and do nothing. Any other error: answer `500`, and Railbed retries.
6. **Find your order** by `payment_id`. None: the payment isn't one the app created. If `data.payment.checkoutId` is one of the app's checkout pages ([5.6](#56-payment-links-and-checkout-pages-through-the-api)), create the order record now from the re-read payment, keyed on its id, and carry on; the recipes don't include this branch, so add it when the project sells through checkout pages. Anything else (a dashboard payment link, another integration): answer `200`.
7. **Check it's the right payment:** the payment's `id`, `livemode`, `reference`, `amount` and `currency` all match the order. A mismatch: log it, answer `200`, and don't fulfil (a person looks at it).
8. **Apply it once, in one database transaction:**
   - insert the event `id` into `railbed_events`; if it's already there, this event was handled: commit nothing more and answer `200`;
   - `paid`: update the order to `paid` **only if it isn't already** (`… WHERE id = ? AND status <> 'paid'`); if that changed a row, grant what the order bought, in the same transaction;
   - `held`, `failed` or `expired`: set the order's status to it, unless it's already `paid`;
   - `open` or `pending`: nothing.
9. **Answer `200`** within 10 seconds. If the transaction failed, answer `500`; Railbed retries the same event.

Why each part matters:

- **Re-reading** means you act on the payment's current state, in one shape. The event's `data.payment` is a snapshot in camelCase with millisecond timestamps; the API's payment is snake_case with Unix seconds.
- **Events repeat and arrive out of order.** Deliveries are at least once; retries and resends carry the same event `id`. A `payment.expired` can arrive after `payment.paid`. Never undo `paid`.
- **The conditional update** makes fulfilment happen once even when two different events (`payment.paid`, then `payment.updated`) both read `paid`, or two deliveries race.
- **Answer fast.** Over 10 seconds counts as a failure and is retried. Slow work (emails, shipping labels) goes on a queue after the commit.
- **Redirects fail.** A `3xx` from the receiver is a failed delivery. Register the final address (watch for trailing slashes and `http`→`https` redirects).

Retries: 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours after each failure, then the delivery is marked failed and stays in the dashboard's delivery log (Developers → the endpoint → Deliveries), where it can be resent.

### 6.5 Events

| Event | Sent when | A re-read right after shows | The receiver |
|---|---|---|---|
| `payment.started` | The buyer gave an email and was given a way to pay | `open` with `started_at` set (`pending` once they open a provider) | Nothing, or mark "awaiting payment" |
| `payment.paid` | The money arrived and passed Railbed's checks, or the business accepted a held payment | `paid` | Fulfil once |
| `payment.held` | Money arrived but failed a check (most often less than 90% of the price) | `held`, or `paid` once accepted | Don't fulfil. Mark held. If the business accepts it, `payment.paid` follows |
| `payment.updated` | A paid payment's settlement details arrived later | `paid` | Nothing new (the conditional update makes it safe) |
| `payment.failed` | A terminal failure (today, a Test simulation only) | `failed` | Mark failed. A new attempt needs a new session |
| `payment.expired` | Nobody paid within 24 hours (or a payment link's lifetime) | `expired`, or `paid`/`held` if money arrived late | Mark expired, but keep the order able to complete |
| `payment.canceled` | A payment link, or a session canceled through the API, closed before anyone paid | `expired` with `canceled_at` set | Mark expired |
| `ping` | **Send test event** or **Send ping** in the dashboard | (no payment) | `200` |

Typical sequences: a normal payment is `payment.started` → `payment.paid` (→ `payment.updated`). An underpayment the business accepts: `payment.started` → `payment.held` → `payment.paid`. A buyer who never pays: `payment.started` → `payment.expired`. A late payment: `payment.started` → `payment.expired` → `payment.paid`.

Every payment event also carries `data.order` (items, totals, addresses, `customFields`) and `data.customer` (contact details). Ship goods from your own order, or from `GET /v1/orders/{order_id}`; ship each order once.

## 7. Prove it in Test

Everything here uses the Test key: no card is charged and no money moves, but webhooks are real signed HTTP requests to your endpoint.

### 7.1 The end-to-end run

1. Start the app with the Test key and Test webhook secret in its environment, and the tunnel from [6.1](#61-get-a-public-https-address) if it runs on your computer.
2. Create the Test endpoint for the tunnel's address ([6.2](#62-create-the-endpoint)) and restart the app so it reads `RAILBED_WEBHOOK_SECRET`.
3. Create a session **through the app's own route**, as the Pay button does. For a form route that redirects:

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

   It prints the session's `url`, `https://pay.railbed.io/p/pay_…`; the `pay_…` part is the payment id. The order is now `awaiting_payment` in the app's database.
4. Pay it, either way:
   - **Like a buyer:** open the `url` in a browser, enter an email, choose Pay, and on Railbed's test provider page choose **Simulate successful payment**.
   - **From a script:** start the session and simulate its outcome through the API:

```js
// simulate.mjs: node simulate.mjs pay_… [paid|underpaid|declined|failed] [--env-path .env]
import { existsSync, readFileSync } from 'node:fs';

const API = (process.env.RAILBED_API_BASE || 'https://pay.railbed.io/v1').replace(/\/+$/, '');
const [id, outcome = 'paid', ...rest] = process.argv.slice(2);
const envFile = rest.includes('--env-path') ? rest[rest.indexOf('--env-path') + 1] : '.env';
const line = existsSync(envFile)
  ? readFileSync(envFile, 'utf8').split('\n').find((l) => l.startsWith('RAILBED_SECRET_KEY='))
  : undefined;
const key = line ? line.slice('RAILBED_SECRET_KEY='.length).trim().replace(/^(['"])(.*)\1$/, '$2') : process.env.RAILBED_SECRET_KEY;
if (!id?.startsWith('pay_') || !key?.startsWith('rb_test_')) {
  console.error('Usage: node simulate.mjs pay_… [paid|underpaid|declined|failed], with a Test key (rb_test_…).');
  process.exit(1);
}

async function api(path, body) {
  const res = await fetch(`${API}${path}`, {
    method: body === undefined ? 'GET' : 'POST',
    headers: { Authorization: `Bearer ${key}`, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  if (!res.ok) throw new Error(`${path}: ${res.status} ${data.error?.code ?? ''} ${data.error?.message ?? ''}`);
  return data;
}

const session = await api(`/checkout_sessions/${id}`);
if (!session.started_at) {
  await api(`/checkout_sessions/${id}/start`, session.customer_email ? {} : { customer_email: 'test-buyer@example.com' });
}
const payment = await api(`/payments/${id}/simulate`, { outcome });
console.log(`${payment.id}: ${payment.status}${payment.hold_reason ? ` (${payment.hold_reason})` : ''}`);
```

5. Check, in this order: the app's log shows the webhook verified; the order in the database is `paid` with `paid_at` set; what it bought was granted exactly once; `railbed_events` has the event ids. In the dashboard (https://app.railbed.io, Test mode), the payment's timeline shows **Webhook delivered**.

### 7.2 The scenarios

Run each with a new order (a finished payment can't be simulated into another outcome):

| Scenario | How | Payment afterwards | Events | The app should |
|---|---|---|---|---|
| Paid | `simulate.mjs pay_… paid` | `paid` (about 97% of the price arrives, as a provider's fee would leave it) | `payment.started`, `payment.paid` | Fulfil once |
| Underpaid | `simulate.mjs pay_… underpaid` | `held`, `hold_acceptable: true` (half arrived) | `payment.started`, `payment.held` | Not fulfil; show "being reviewed" |
| Card declined | `simulate.mjs pay_… declined` | still `open` or `pending`, the same payment | `payment.started` only | Not fulfil; the buyer can pick another provider on the same session |
| Terminal failure | `simulate.mjs pay_… failed` | `failed` | `payment.started`, `payment.failed` | Not fulfil; a retry needs a new order and session |
| Never paid | Create a session and leave it | `expired` after 24 hours | `payment.expired` | Not fulfil |
| The same event twice | `replay.mjs` below | unchanged | (yours) | `200` both times, fulfilled once |
| A forged webhook | `replay.mjs` below | unchanged | (yours) | `400` |
| A lost create response | Send the same create twice with one `Idempotency-Key` | one payment, same `id` | none | One session |

Live payments can't be simulated (`403 live_payment`), and a session must be started before it's simulated (`409 not_started`).

**Duplicate and forged deliveries.** Railbed's own resend lives in the dashboard (Developers → endpoint → Deliveries → Resend). From a script, send your receiver correctly signed copies of one event for a real Test payment, then a copy with a wrong signature. The script signs with the Test webhook secret from the env file and prints neither:

```js
// replay.mjs: node replay.mjs <receiver url> pay_… [--env-path .env]
// Expect: 200, 200 (and the order fulfilled once), then 400.
import crypto from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';

const [url, paymentId, ...rest] = process.argv.slice(2);
const envFile = rest.includes('--env-path') ? rest[rest.indexOf('--env-path') + 1] : '.env';
const line = existsSync(envFile)
  ? readFileSync(envFile, 'utf8').split('\n').find((l) => l.startsWith('RAILBED_WEBHOOK_SECRET='))
  : undefined;
const secret = line ? line.slice('RAILBED_WEBHOOK_SECRET='.length).trim().replace(/^(['"])(.*)\1$/, '$2') : process.env.RAILBED_WEBHOOK_SECRET;
if (!url || !paymentId || !secret) {
  console.error(`Usage: node replay.mjs <receiver url> pay_…, with RAILBED_WEBHOOK_SECRET in ${envFile}`);
  process.exit(1);
}

const t = Math.floor(Date.now() / 1000);
const body = JSON.stringify({
  id: `evt_replay_${crypto.randomUUID().replaceAll('-', '')}`,
  type: 'payment.paid',
  created: t,
  livemode: false,
  data: { payment: { id: paymentId, metadata: null }, order: null, customer: null },
});
const sign = (key) => crypto.createHmac('sha256', key).update(`${t}.${body}`).digest('hex');
const send = async (signature) => {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Railbed-Signature': `t=${t},v1=${signature}` },
    body,
  });
  return res.status;
};
console.log('signed:', await send(sign(secret)));
console.log('same event again:', await send(sign(secret)));
console.log('wrong signature:', await send(sign(`${secret}x`)));
```

### 7.3 A quick smoke test

```bash
npx checkouts.md test --amount 5.00
```

It creates a session with the Test key, starts it, simulates `paid` and waits until the payment reads `paid`, then prints an evidence line with its id. It proves the key and the API work, and it triggers a real `payment.paid` delivery to the mode's endpoints, but it doesn't check that delivery (look at your receiver's log, or the endpoint's delivery log in Developers) and it doesn't go through your app's own route or fulfilment (there's no order of yours for it, so your receiver answers `200` and ignores it). Use [7.1](#71-the-end-to-end-run) for the evidence.

Assistants connected to Railbed's MCP server (`https://mcp.railbed.io/mcp`) with a Test, Full access connection can also simulate a started Test payment with `railbed_simulate_payment`, with no browser confirmation; it refuses any Live connection.

### 7.4 Clean up and record

- Delete temporary tunnel endpoints ([6.2](#62-create-the-endpoint)); 10 is the limit per mode. Keep a Test endpoint for a deployed test or staging environment if there is one.
- Delete `connect.mjs`, `webhook.mjs`, `simulate.mjs` and `replay.mjs` if you wrote them outside the project; they hold no secrets, so they can stay in the project (for example under `scripts/`) if it's useful.
- Write the evidence into `CHECKOUTS.md` ([§9](#9-evidence)).

## 8. Go Live

1. **Payout wallet.** The business needs one (Railbed → Settings). Without it, Live sessions answer `409 no_payout_wallet`.
2. **The Live key.** If the person approved Live when connecting, it's where `--live-to` put it. If they chose Test only, connect again ([3.1](#31-with-the-cli)) choosing Test and Live; the new Test key can replace the old one.
3. **The Live webhook endpoint at the production address** (public `https://`): `npx checkouts.md webhook https://yourapp.com/api/webhooks/railbed --live` (with the same `--live-to` as when connecting), which stores the Live `RAILBED_WEBHOOK_SECRET` beside the Live key. Over HTTP, create it with the Live key. Then check the Live key works while it's still in the file: `npx checkouts.md status` with the same `--env-path` and `--live-to` as `login` (it calls Railbed with the key and prints only its masked form).

   **Both values on the production host,** moved from the file without printing them. Vercel and Wrangler read a value from standard input; run the loop for the project's host:

   ```bash
   # Vercel
   for name in RAILBED_SECRET_KEY RAILBED_WEBHOOK_SECRET; do
     grep "^$name=" .env.railbed | cut -d= -f2- | tr -d '\r\n' | vercel env add "$name" production
   done
   # Cloudflare Workers
   for name in RAILBED_SECRET_KEY RAILBED_WEBHOOK_SECRET; do
     grep "^$name=" .env.railbed | cut -d= -f2- | tr -d '\r\n' | npx wrangler secret put "$name"
   done
   rm .env.railbed
   ```

   (`vercel env add` refuses a name that exists; remove the old value first with `vercel env rm NAME production -y`.) Other hosts: a command that reads a file or standard input, as in [3.1](#31-with-the-cli). Delete the local file once the host has the values.
4. **`APP_URL`** on the production host is the public `https://` address (Live `success_url` must be `https://`).
5. **Deploy**, then check without spending money:
   - the Live key worked in `npx checkouts.md status` (item 3 above);
   - an unsigned `POST` to the production receiver answers `400`: it's reachable and verifying (`curl -s -o /dev/null -w '%{http_code}\n' -X POST https://yourapp.com/api/webhooks/railbed -d '{}'`);
   - creating a session through the production route redirects to `https://pay.railbed.io/p/pay_…` with `livemode: true` (cancel it with [5.7](#57-cancel-a-session) if you made one only to check; an unpaid one simply expires in 24 hours).
6. **Update `CHECKOUTS.md`:** `mode: live`, the Live endpoint's id under Wiring, the checks above under Evidence.
7. **The first real payment** is the Live evidence: its timeline in the dashboard shows **Webhook delivered** and the order is fulfilled once. Live payments can't be simulated. Tell the person it's ready; they can make one small real purchase themselves to see it end to end, or wait for the first buyer. Record it in Evidence when it happens.

## 9. Evidence

Record what you checked, in `CHECKOUTS.md` under `## Evidence`, one row per check: the date, the mode, what you did, what you observed, and the ids (payment `pay_…`, event `evt_…`, endpoint `we_…`). Ids aren't secret; buyer emails and names are personal, so leave them out.

Say exactly what you verified. These are different claims:

| Claim | What it takes |
|---|---|
| "Builds" / "typechecks" | The build or typecheck passed. Says nothing about payments |
| "Session created" | A real `POST /v1/checkout_sessions` answered `201` with a `pay_…` id |
| "Test payment simulated" | `POST /v1/payments/:id/simulate` answered with the expected status |
| "Webhook delivered and verified" | Your receiver's log shows a verified event, and the dashboard shows the delivery succeeded |
| "Fulfilled once" | The order is `paid`, the grant exists once, and a repeated event changed nothing |
| "Live wired" | Live key, Live endpoint and deployed receiver checked as in [§8](#8-go-live) |
| "Live payment received" | A real payment reached `paid`, its webhook was delivered and the order fulfilled |

## 10. Troubleshooting

### Connecting

| Code | Where | Fix |
|---|---|---|
| `access_denied` (403) | Polling | The person declined. Ask whether they meant to, then start again |
| `expired_token` (410) | Polling | 15 minutes passed. Start again |
| `already_delivered` (410) | Polling | The keys were already collected (by an earlier run of this request). Start again for new keys; ask the person to revoke unused ones in Developers |
| `approval_revoked` (409) | Polling | The approver lost the right to manage keys, or the business was paused, suspended or archived, or its Checkout API was turned off. Start again; an owner or developer approves once the business can take payments |
| `invalid_device_code` (404) | Polling | Wrong or truncated `device_code`. Start again |
| `slow_down` (429) | Polling | Add 5 seconds to the interval (or wait `Retry-After`) and keep polling |
| Any other `429` | Starting | Too many connect requests from this address. Wait `Retry-After` seconds |
| `400 invalid_modes` | Starting | `modes` must be `["test"]` or `["test","live"]` |
| `400` (another code) | Starting | The body is wrong: `client` up to 40 printable characters, `project` up to 60 |
| `400 invalid_request` | Polling | Send `{ "device_code": "rbdc_…" }` |
| `415 unsupported_media_type`, `400 invalid_json` | Either | Send a JSON object with `Content-Type: application/json` |
| `503 connect_unavailable` | Starting | Railbed couldn't start a request just now. Try again in a moment |
| `403 forbidden_origin` | Either | The call came from a web page on another site. Call from a server or a terminal |
| `upstream_unavailable` (502), `upstream_timeout` (504) | Either | checkouts.md didn't get Railbed's answer. Retry in a few seconds; a poll that then answers `already_delivered` lost its keys in transit: start again and have the person revoke the unused keys |
| `not_found` (404), `method_not_allowed` (405) | Either | Only `POST /api/connect` and `POST /api/connect/token` exist |
| The page says the person can't approve | Approval | They aren't an owner or developer of the business, or the business is paused, suspended or archived, or its Checkout API is turned off. An owner approves, or they email support@railbed.com |
| `no_payout_wallet` on the approval page | Approval | Live needs a payout wallet: add one in Settings, or approve Test only |

### The API

| Status and code | Cause | Fix |
|---|---|---|
| `400 invalid_json` | The body isn't a JSON object | Send `JSON.stringify(…)` with `Content-Type: application/json` |
| `400 invalid_amount` | `amount` is a number, has more than two decimals, or is outside 1.00–100000.00 | Send a string like `"49.00"` (R3) |
| `400 invalid_currency` | Not `USD`, `EUR`, `GBP`, `CAD` or `AUD` | Use one of those |
| `400 missing_description` | `description` missing or blank | Send the product's name |
| `400 too_long` | `description` or `reference` over 120 characters (webhook endpoint `description` over 80) | Shorten it; `field` says which |
| `400 invalid_reference` | `reference` isn't a string | Send your order id as a string |
| `400 invalid_email` | `customer_email` isn't a valid address, or starting needs one and none is saved | Send a valid email, or let the hosted page ask |
| `400 invalid_url` | `success_url`/`cancel_url` isn't a full URL, has credentials, is over 1,000 characters, or isn't `https://` in Live (webhook `url`: not `https://`, or not public in Live) | Fix `APP_URL` or the endpoint address |
| `400 invalid_metadata` | Over 20 keys, a key over 40 characters or starting with `__`, or a non-string value | Send string values only |
| `400 invalid_idempotency_key` | Blank, over 120 characters, or not printable ASCII | Use your order id |
| `400 invalid_customer`, `invalid_order`, `invalid_order_totals`, `invalid_collect` | The `customer`, `order` or `collect` object is wrong, or the order doesn't add up to `amount` | Read `field` and the message; items − discount + shipping + tax = `amount` |
| `400 invalid_country` | `country` at start isn't two letters | Send `US`, `DE`, or leave it out |
| `400 invalid_outcome` | `outcome` isn't `paid`, `underpaid`, `declined` or `failed` | Fix the simulate call |
| `400 invalid_expiry` | A payment link's `expires_in_days` isn't a whole number from 1 to 30 | Send a number, like `7` |
| `400 invalid_events` | An empty list or an unknown event type | Use the `payment.*` types in [6.5](#65-events); `ping` isn't subscribed to, it's sent on request |
| `400 invalid_limit`, `invalid_cursor`, `invalid_status`, `invalid_dates` | A list's paging or filters | `limit` 1–100; pass `next_cursor` as `starting_after` |
| `401 invalid_api_key` | No `Authorization: Bearer`, a malformed or revoked key | Check `RAILBED_SECRET_KEY` is set in this environment (`npx checkouts.md status`); connect again if it was revoked |
| `403 insufficient_scope` | A Read only key, or an agent token, for a write; or a WooCommerce key or agent token for webhook endpoints | Use the Full access secret key from connecting (or a dashboard key with purpose Checkout API) |
| `403 live_payment` | Simulating a Live payment | Simulate in Test only |
| `403 merchant_read_only` | The business is suspended or archived | Tell the person; support@railbed.com |
| `403 integration_disabled` | The API integration is turned off for the business | Tell the person; support@railbed.com |
| `404 not_found` | The id isn't in this business and mode (a Test key can't see Live objects), or a sample event's made-up payment | Check the key's mode matches the object's; acknowledge samples with `200` |
| `405 method_not_allowed` | Wrong HTTP method for the path | Use a method from the `Allow` header |
| `409 idempotency_conflict` | The same `Idempotency-Key` with a different body | Never retry it; a new order gets a new key |
| `409 idempotency_in_progress` | The first request with that key hasn't finished | Wait `Retry-After` (1 s) and resend the same request |
| `409 no_payout_wallet` | Live, no payout wallet | The person adds one in Settings |
| `409 commerce_paused` | The business paused taking payments | Tell the person |
| `409 order_paid` | A session for an `order.id` that's already paid | Don't ask the buyer to pay again; check the order |
| `409 order_erased` | That `order.id` belongs to a customer whose details were deleted | Use a new `order.id` |
| `409 no_providers` | No card provider takes this amount and currency now (Test: below 2.00 USD) | Raise the amount, or try later |
| `409 unavailable` | The business can't take payments now (Live: no payout wallet) | The person adds a payout wallet |
| `409 already_paid`, `held`, `failed` | Starting a payment that's finished | Read the payment; a new attempt needs a new session |
| `409 start_changed` | The business's payment settings changed during the start | Retry the start |
| `409 not_started` | Simulating before starting | Start the session first |
| `409 not_cancelable` | Canceling a session or payment link the buyer has already taken to a provider, one that's paid, held, failed or expired, or a checkout page's payment | Leave it; honour it if it's paid |
| `409 event_conflict` | An order event id reused with a different type or amount | Use a new id |
| `409 duplicate_url`, `too_many` | The endpoint address already exists in this mode, or 10 endpoints already | Delete the old or unused endpoint first |
| `410 expired`, `canceled` | The session can't be paid any more | Create a new order and session |
| `413 request_too_large` | Body over 64 KiB | Send less |
| `415 unsupported_media_type` | Not sent as `application/json` | Set the `Content-Type` header |
| `429 rate_limited` | About 120 requests a minute per mode, 12 starts a minute, or 60 a minute with a bad key | Wait `Retry-After` (60 s); queue requests |
| `500 internal`, `502 network_unavailable`, `503 misconfigured` | Railbed or the card network failed | Retry with backoff; creates are safe with the same `Idempotency-Key` |

Any code not listed: handle it by its status. An unknown `4xx` is a request to fix; an unknown `5xx` is a reason to retry.

### Webhooks

| Symptom | Likely cause | Fix |
|---|---|---|
| Every delivery fails verification | The wrong secret (Test and Live differ; each endpoint has its own), or quotes or spaces around it | Store the `whsec_…` value exactly; restart the app after changing it |
| Some deliveries fail verification | The body was parsed and re-serialized before verifying | Verify the raw body ([6.3](#63-verify-the-signature)) |
| Deliveries fail with a `3xx` | The address redirects (trailing slash, `http`→`https`, `www`) | Register the final address |
| Deliveries time out | The receiver does slow work before answering | Answer within 10 s; queue slow work |
| `404` from the receiver | Wrong path, or the tunnel's address changed | Delete the endpoint and create one for the current address |
| `403` from the receiver | A CSRF check or a firewall blocks Railbed's `POST` | Exempt the webhook route from CSRF; allow the path through |
| Nothing arrives | No endpoint in this mode, or the endpoint is on another mode's key | `GET /v1/webhook_endpoints` with the key of the mode you're testing |
| Verified, but the order isn't paid | `payment_id` wasn't saved on the order, the mode or amount doesn't match, or the payment isn't `paid` | Log what step 7 of [6.4](#64-handle-the-event) compared |
| The receiver throws "mode mismatch" | The environment has the other mode's key or secret | Put matching Test or Live values together |
| Test events pass, real ones fail | A proxy or security plugin changes the body or strips `Railbed-Signature` | Let the webhook path through untouched |
| Failures after a while | The server's clock drifted beyond 5 minutes | Sync it with NTP |

## 11. Report back

End with a short report in plain words. The person isn't necessarily a developer. Template:

> **Checkout added.** Buyers can now pay for {products} by card from {where the button is}. Payments go to your Railbed account; payouts go to your payout wallet.
>
> **What I changed:** {files, one line each}. `CHECKOUTS.md` at the project root records how it works, for you and any agent that works on payments later.
>
> **What I checked (Test mode, no money moved):** I created a payment through the app's own button, simulated a successful payment, and confirmed the signed webhook arrived and the order was fulfilled once (payment {pay_…}). I also checked {underpaid, declined, a repeated event, a forged event}.
>
> **What's left:** {deploying; the Live endpoint; the first real payment}. {If Test only: "To take real payments, add a payout wallet in Railbed's Settings and tell me; I'll connect Live."}
>
> **Where to look:** payments and orders at https://app.railbed.io. Keys and webhook endpoints are under Developers; you can revoke them there at any time. {Name any extra keys from repeated connects that can be revoked, and say which webhook endpoints each one added: revoking a key removes them.}

Never include a key, a signing secret or a buyer's personal details in the report.
