# Recipe: a static site

For a site of plain HTML, CSS and JavaScript files, with no server of its own. 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.

> [!IMPORTANT]
> A secret key can't live in a static page. Anything in an HTML or JavaScript file is readable by every visitor, and Railbed's API refuses browser calls anyway (no CORS, no publishable key). A static site either uses a checkout page Railbed hosts, which needs no key in the site at all, or adds a small server function that holds the key.

Pick one:

| | A. A hosted checkout page | B. A Pay button and two small functions |
|---|---|---|
| What's added to the site | One `<script>` and one `<railbed-button>` tag | A Pay form, a success page, and a Worker (or other function) for checkout and webhooks |
| Secrets in the project | None | The secret key and webhook secret, in the function's secret store |
| Prices | Set on the checkout page | In the function's code |
| After payment | The business fulfils from Railbed's dashboard (Orders), by hand | The function's webhook receiver fulfils automatically, exactly once |
| Good for | Selling one thing, services, early sales | Anything delivered automatically: downloads, access, credits |

## A. A hosted checkout page

A checkout page is a reusable page on Railbed with a fixed price, which every buyer pays separately. The site needs only its `slug`.

### 1. Create the checkout page

Either the person creates it in the dashboard (Create → Checkout page), or you create it with the API from a one-off script, after connecting ([playbook 3](https://checkouts.md/playbook.md#3-connect)). The script reads the key from `.env` and prints only the slug and the page's address:

```js
// make-checkout.mjs: node make-checkout.mjs [env file]   (RAILBED_SECRET_KEY in .env, or the file named)
import { existsSync, readFileSync } from 'node:fs';

const envFile = process.argv[2] ?? '.env';
const line = existsSync(envFile)
  ? readFileSync(envFile, 'utf8').split('\n').find((l) => l.startsWith('RAILBED_SECRET_KEY='))
  : undefined;
const key = line?.slice('RAILBED_SECRET_KEY='.length).trim().replace(/^(['"])(.*)\1$/, '$2');
if (!key) {
  console.error(`No RAILBED_SECRET_KEY in ${envFile}. Connect first: npx checkouts.md login`);
  process.exit(1);
}

const res = await fetch('https://pay.railbed.io/v1/checkouts', {
  method: 'POST',
  headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    name: 'The field guide (PDF)',
    description: 'A 120-page guide, delivered by email.',
    amount: '19.00',
    currency: 'USD',
    success_url: 'https://example.com/thanks.html', // the site's thanks page (step 3)
    button_label: 'Buy for $19', // the checkout page's pay button (32 characters at most)
  }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${data.error?.code}: ${data.error?.message}`);
console.log(`${data.livemode ? 'Live' : 'Test'} checkout ${data.id}: slug ${data.slug}, page ${data.url}`);
```

Checkout pages belong to one mode, like keys: a Test key makes a Test page (simulated payments), and the Live key a Live one with a different slug (`node make-checkout.mjs .env.production`, naming the file that holds the Live key). Use the Test slug while testing and the Live slug at go-live.

The site itself never uses the key. If it's published by uploading its folder rather than from git (which leaves gitignored files out), keep `.env` and `.env.production` out of the upload.

### 2. Add the button

On the page that sells, once per page:

```html
<script src="https://pay.railbed.io/embed.js" async></script>

<railbed-button checkout="your-checkout-slug" label="Buy for $19"></railbed-button>
```

`label` is optional (the default is "Buy now"); `shape="rounded"` gives slightly rounded corners instead of a pill. The button opens the checkout over the page; the card provider opens in its own tab. A site with a Content Security Policy allows `https://pay.railbed.io` in `script-src`, `frame-src` and `connect-src` (the button reads the page's name and price from there). For a link instead of a button, link straight to the page's `url` (`https://pay.railbed.io/c/your-checkout-slug`).

### 3. The thanks page

`success_url` sends buyers to your page once Railbed has confirmed their payment. Anyone can open that address by hand, so it only thanks them and says what happens next. It never hands out what was bought (no download link, no access code) (R2):

```html
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Thank you</title></head>
<body>
  <main>
    <h1>Thank you for your order.</h1>
    <p>We'll email you at the address you gave at checkout.</p>
  </main>
</body>
</html>
```

### 4. After payment

Every paid order appears in the dashboard (https://app.railbed.io, Orders), with the buyer's email and any details the checkout page asked for. With no server, the business fulfils from there. To deliver automatically, add B's Worker: a checkout page's payments arrive with `data.payment.checkoutId` set to the page's `chk_…` id (playbook [5.6](https://checkouts.md/playbook.md#56-payment-links-and-checkout-pages-through-the-api)). B's receiver as written finds no order of its own for them and ignores them, so add the checkout-page branch that playbook [6.4](https://checkouts.md/playbook.md#64-handle-the-event) describes.

### 5. Test and go live

- Test: with the Test slug, click the button, enter an email, and on the test provider page choose **Simulate successful payment**; check the order appears as paid in the dashboard in Test mode.
- Live: create the Live checkout page (the person, or the script with the Live key), put the Live slug in the button, publish the site, and set `mode: live` in `CHECKOUTS.md`.

`CHECKOUTS.md` for A: Products lists the checkout page by its id (`chk_…`) with its price; After payment says "fulfilled by hand from Railbed's dashboard, Orders"; Wiring lists the page with the button and the thanks page; `secrets: []`, no `webhook`.

## B. A Pay button and two small functions

The site stays static; a small function holds the key, creates a checkout session per order, and receives webhooks. On Cloudflare this is one Worker that also serves the site's files.

### 1. The Worker

Follow [recipes/cloudflare-workers.md](https://checkouts.md/recipes/cloudflare-workers.md) for the Worker itself: `src/railbed.ts`, `src/index.ts`, the D1 migration, connecting (`npx checkouts.md login --env-path .dev.vars --live-to env:.env.railbed`) and the webhook endpoint. Point its static assets at the site's folder:

```jsonc
{
  "name": "my-site",
  "main": "src/index.ts",
  "compatibility_date": "2026-09-20",
  // The static site's own folder: every file is served as is; only /api/* runs the Worker.
  "assets": { "binding": "ASSETS", "directory": "./site", "run_worker_first": ["/api/*"] },
  "d1_databases": [{ "binding": "DB", "database_name": "my-site", "database_id": "<from wrangler d1 create>" }],
  "vars": { "APP_URL": "https://example.com" }
}
```

Everything in that folder is public. If the site's files sit at the project root, move them into a folder of their own first: `.dev.vars`, `.env.railbed`, `src/` and `wrangler.jsonc` must stay outside it.

### 2. The Pay button

In any page of the site, a form that posts to the function. It works without JavaScript; the function redirects the buyer to Railbed's checkout:

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

The product id must be one of `PRODUCTS` in `src/railbed.ts`: the price comes from there, never from the page.

To keep buyers on the page, open the checkout in a pop-up instead:

```html
<script src="https://pay.railbed.io/embed.js" async></script>
<button type="button" id="pay">Pay $49.00</button>
<script>
  document.getElementById('pay').addEventListener('click', async () => {
    const res = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ product: 'pro' }),
    });
    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);
    Railbed.open(url).catch(() => location.assign(url));
  });
</script>
```

### 3. The success page

`site/checkout/success.html` is the cloudflare-workers recipe's `public/checkout/success.html`: it asks `/api/orders/:id` for the order's status and shows it, and never marks anything paid.

### 4. Test and go live

As in the [cloudflare-workers recipe](https://checkouts.md/recipes/cloudflare-workers.md#5-test-it): `npx wrangler dev`, a tunnel, `npx checkouts.md webhook https://<tunnel>.trycloudflare.com/api/webhooks/railbed --env-path .dev.vars`, a session through the form, simulate, check the order is paid once. Then [go live](https://checkouts.md/recipes/cloudflare-workers.md#6-go-live) the same way: the migration on the production database, `APP_URL` in `vars`, the Live endpoint (`npx checkouts.md webhook https://example.com/api/webhooks/railbed --live --live-to env:.env.railbed`), both Live values as Worker secrets from `.env.railbed` (then delete the file), `npx wrangler deploy`, and `mode: live` in `CHECKOUTS.md`.

### Hosting somewhere else

The handlers are plain Web `Request` → `Response` functions, so they move to other hosts' functions with the same bodies, given `process.env` where the Worker passes `env`:

| Host | Where the checkout handler goes | Secrets |
|---|---|---|
| Netlify | `netlify/functions/checkout.mjs`: `export default async (req) => …` and `export const config = { path: '/api/checkout' }` | Site settings → Environment variables |
| Vercel | `api/checkout.js`: `export async function POST(request) { … }` | Project settings → Environment Variables (or `vercel env add`, value on standard input) |

The webhook handler goes beside it at `/api/webhooks/railbed`, and the order-status handler the success page reads at `/api/orders/:id` (Netlify: `path: '/api/orders/:id'`, the id in `context.params.id`; Vercel: `api/orders/[id].js`, the id from the request's URL). On Vercel, also set `"cleanUrls": true` in `vercel.json`, so `/checkout/success` serves `checkout/success.html`. Replace the D1 calls with the host's database; keep the event id unique and the paid update conditional, in one transaction with the grant (playbook [6.4](https://checkouts.md/playbook.md#64-handle-the-event)).
