# Beluga documentation

Generated 2026-09-25 from the same sources as https://belugajs.com.
Each section below is one page of the site; its URL is in the heading.


---

# What Beluga is (and is not)
<https://belugajs.com/start/what-beluga-is/>

_A self-hosted, Stripe-backed store you fork and build on. Not a marketplace, not a hosted service, not a shipping platform._

Beluga is open-source software for running your own ecommerce site. It is a
**React 19 storefront and admin**, an **Express 5 API**, a **SQLite or Postgres
database**, and **Stripe Checkout** for payment. You clone it, run it, and deploy it
on a server you control.

The reader of these docs is a developer who wants a custom store. Beluga will
always require development skills to run. The admin covers the day-to-day, but the
landing page is a React component and the storefront is yours to fork.

### What it looks like

Every shot below is Whale Hello There, a small store built through the admin API
and captured from a real build by `npm run screenshots`. Nothing is mocked up, and
nothing is retouched.


_Screenshot: The Whale Hello There storefront: a header with collections, a hero band with a beluga, and a Featured row of three products with prices._
*The storefront. The hero is set in Settings and falls back to the store name until it is; the price range under Beluga Buffet is a product with a priced axis.*

_Screenshot: A product page showing Beluga Buffet, its price, an Animal dropdown, a Gift wrap dropdown, a quantity box and an Add to cart button._
*A product page. Animal is a priced axis with a picture pinned to each choice; Gift wrap is an option group, which changes neither price nor stock.*

_Screenshot: The shop page listing two collections with cover images above a searchable, sortable grid of six products._
*The catalogue, with collections, search and sorting.*

_Screenshot: The admin product list: six products in a table of name, slug, status, order and actions, each marked "Live · not published", above Export CSV, Import CSV and New product buttons._
*The product list. "Live · not published" is two facts, not one: the product is on the storefront, and Stripe has never been told about it.*

_Screenshot: The admin product editor for Beluga Buffet, with Basics, Visibility and Search appearance panels and a greyed-out Publish to Stripe button._
*The product editor. It saves automatically to your database — and nothing reaches Stripe until Publish is pressed, which is [the invariant](/start/invariants/) most worth knowing.*

_Screenshot: The admin settings page showing store name, currency, language and a publishable-key field._
*Settings. The publishable key is editable here; the secret key lives only in the server's environment and never in a browser.*


### What it does

- **A catalogue.** Products with up to three priced axes (size × colour × material),
  per-variant stock, weights, SKUs, compare-at prices and images. Collections with
  Markdown introductions. Prose pages. CSV import and export.
- **A cart and checkout** on Stripe's hosted page. Card fields, 3-D Secure, wallets
  and address collection are Stripe's, which keeps a Beluga store at PCI SAQ-A.
- **Orders**, recorded in your database when Stripe's webhook confirms payment, with
  fulfilment status, tracking, refunds, restocking and a CSV export.
- **Shipping** as zones and rates bounded by weight and subtotal. "Free over $50" and
  "heavy parcels cost more" are both one rate each.
- **Tax** through Stripe Tax, when you turn it on.
- **Discount codes** through Stripe's promotion codes.
- **Email** for orders, shipping, refunds, password resets and abandoned carts, over
  any SMTP provider.
- **Accounts**: staff invitations, customer logins with order history and an address
  book, and abandoned-cart reminders.
- **Outbound webhooks**, signed, retried, and delivered to whatever you run. This is
  the feature that stands in for an app ecosystem.
- **A theme editor** for palette, radius, logo and web font, and a **preview mode**
  that puts a password on a store that is deployed but not open.

### What it is not

- **There is no hosted Beluga.** You deploy it, always. That is the step between
  forking it and having a store, which is why [Deploying](/deploying/shape/) comes
  before everything else in these docs.
- **Not a marketplace, and no app store.** One store per install, and integrations
  happen over [webhooks](/integrating/webhooks/), not plugins running inside the process.
- **No live carrier rates, labels or tracking.** Those need Stripe's `elements`
  checkout mode, which means owning the checkout page again. [Shipping](/money/shipping/)
  explains the trade and the alternatives.
- **Digital delivery is not built.** A product can be marked digital and sold, but
  handing the buyer a file is currently your job. See [Digital products](/catalogue/digital-products/).
- **Not a serverless app.** It is one long-lived Node process with a filesystem, so
  Vercel, Netlify and Heroku are the wrong shape for it.

### How the docs are organised

Every page answers three questions: what is the model, what is the API, and what
may I change. The how-to is compressed to the shortest correct version, because you
can ask an assistant how to add an Express route. What no assistant can know are
Beluga's local rules: money never comes from the request, only the webhook marks an
order paid, publishing is the only thing that writes to Stripe. Those are on
[the invariants page](/start/invariants/), and they are the reason most of the rest
exists.

If you are working with an AI assistant, the whole site is also one Markdown file at
[`/docs/all.md`](/docs/all.md). See [For AI assistants](/reference/ai/).


---

# Quickstart
<https://belugajs.com/start/quickstart/>

_From a clone to a running store with the demo catalogue, in a few minutes._

You need **Node 22**. Node 18 fails with misleading "command not found" errors from
the build tooling, so run `nvm use` first; the version is pinned in `.nvmrc`.

```bash
git clone https://github.com/binx/beluga-v2.git my-store
cd my-store
nvm use
npm install
npm run setup       # generates secrets, checks your Stripe key, makes your admin account
npm run dev:all     # storefront on :5173, API on :4000
```

`npm run setup` asks a handful of questions and writes `.env` once. It never touches
that file again, so re-running it will not repair a value you changed by hand. Give
it a Stripe **test** secret key if you have one; it validates the key against Stripe
before storing it and says plainly whether you handed it a live one. Say yes to
seeding the demo catalogue.

Then open <http://localhost:5173>. The storefront shows the demo products, and
<http://localhost:5173/admin> signs in with the account setup created.

### Prefer a browser?

Skip setup and run `npm run dev:all` on its own. The server starts unconfigured on
purpose, and <http://localhost:5173/setup> walks the same three steps in a wizard.

### No terminal at all (containers, CI)

```bash
SESSION_SECRET=$(openssl rand -base64 32) \
ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='a long passphrase' \
npm run db:seed
```

### Taking a test payment

A product must be **published** before it can be bought, and only Stripe's webhook
marks an order paid. Locally that means forwarding webhooks with the
[Stripe CLI](https://stripe.com/docs/stripe-cli):

```bash
stripe listen --forward-to localhost:4000/api/webhooks/stripe
```

Put the `whsec_…` it prints into `.env` as `STRIPE_WEBHOOK_SECRET`, **restart the
API**, publish a product from the admin, and pay with card `4242 4242 4242 4242`.
The restart is the step everyone skips.

### What you have now

A store that browses, carts and sells against a SQLite file under `data/`, with
uploaded images under `public/assets/`. Neither needs a database server. To make it
yours, read [Architecture](/start/architecture/) once, then
[Build your first store](/tutorials/first-store/).


---

# Architecture
<https://belugajs.com/start/architecture/>

_The directory layout, the shared contract, and the one seam that let the data source change without touching a component._

```
src/       React 19 storefront (Vite)
src/admin/ Admin and setup wizard, loaded on demand
server/    Express 5 API (TypeScript)
scripts/   The `npm run setup` CLI and the roadmap generator
db/        Drizzle schema, migrations, repositories, seed
shared/    zod schemas and helpers, imported by both sides
emails/    Handlebars templates
e2e/       Playwright specs
legacy/    An earlier codebase, kept for reference. Nothing builds from it.
```

In development there are two processes: Vite on port 5173 serving the storefront
with hot reload, and the API on port 4000. In production there is **one**: the
Express app serves the built client from `dist/` and uploads from the assets
directory, so a deployment is a single Node process on a single port.

### The contract

`shared/schema.ts` is the shape of a store: products, variants, options, images,
collections, pages, theme, hero, shipping countries. It is a set of zod schemas, and
both the storefront and the API validate against them. The storefront parses
`/api/store` through `storeSchema` before rendering anything; the API parses every
admin write through the input schemas in `shared/api.ts` before it reaches a
repository.

That is what makes a missing step a type error rather than an `undefined` in
production, and it is why [adding a field](/tutorials/adding-a-field/) touches
`shared/` before it touches a route.

### The seam

`src/lib/store-source.ts` is the only place the storefront learns where its data
comes from. `loadStore()` fetches `/api/store`; set `VITE_BELUGA_API=false` and it
validates the bundled demo fixture through the same schema instead, so you can do
UI work with no database running.

The worked example is Beluga's own history: swapping the Phase 1 fixture for the
Phase 2 database changed exactly that one file. [The seams](/building/seams/) lists
the others.

### The database

SQLite by default, because a store should run without provisioning anything. Point
`DATABASE_URL` at Postgres when a catalogue outgrows a file. The query layer is
written once against Drizzle, and `db/dialect.test.ts` runs the same assertions
against both engines, so a query that only works on one fails there first.

Schemas are two files, `db/schema.sqlite.ts` and `db/schema.pg.ts`, edited
together. Migrations run automatically at boot and are idempotent.

### The admin

`/admin` is a second application, loaded only when someone goes there. A shopper
downloads none of it. It talks to `/api/admin/*`, which sits behind a session and a
CSRF check applied to the whole router at once, so a new admin route cannot be
mounted unprotected by accident. [The API](/building/api/) has the surface.

### Money

Every amount, everywhere, is an integer number of minor units. The cart holds
product and variant identifiers only, never a price. Line items sent to Stripe are
built on the server from prices read out of the database. Only Stripe's webhook
marks an order paid. Those four sentences are most of what is worth knowing, and
[the invariants](/start/invariants/) say them properly.


---

# Build your first store
<https://belugajs.com/tutorials/first-store/>

_From a fresh clone to a paid test order, with a product you created, through Stripe's real test mode._

By the end of this you will have a store with a product you made, a completed test
payment, and an order in the admin that Stripe's webhook marked paid. It takes about
twenty minutes, most of it waiting for `npm install`.

You need Node 22, a [Stripe account](https://dashboard.stripe.com/register) in test
mode, and the [Stripe CLI](https://stripe.com/docs/stripe-cli).

### 1. Run it

```bash
git clone https://github.com/binx/beluga-v2.git my-store
cd my-store
nvm use
npm install
npm run setup
```

Setup asks for a database (accept the SQLite default), your Stripe **test** secret
key and publishable key, a public URL (leave the default while developing), an admin
email and password, and a store name. Say **yes** to the demo catalogue; you will
delete it in a moment, and it is useful to see a populated store first.

```bash
npm run dev:all
```

Open <http://localhost:5173>. That is the demo store. Open
<http://localhost:5173/admin> and sign in.

### 2. Forward Stripe's webhook

In a second terminal:

```bash
stripe listen --forward-to localhost:4000/api/webhooks/stripe
```

Copy the `whsec_…` it prints into `.env`:

```
STRIPE_WEBHOOK_SECRET=whsec_...
```

Restart `npm run dev:all`. Without this step everything will *look* like it works
and no order will ever be marked paid, because
[only the webhook does that](/start/invariants/).

### 3. Make a product

**Admin → Products → New product.** The editor is one form that saves itself as you
type; the indicator in the corner says when. Fill in:

- A name and a description. The slug is generated from the name.
- **Type**: physical.
- One option axis, say **Size** with values `Small` and `Large`. The variant table
  below fills itself in with one row per value. Give each a price and a weight.
- An image. Drop a JPEG or PNG; it is re-encoded to WebP, stripped of EXIF, and
  resized copies are written for `srcset`.

Set the product **live**, then click **Publish**. That is the first and only time
anything is written to Stripe: a Product and one Price per variant appear in your
test dashboard. Until you publish, a live product shows in the storefront but cannot
be bought, and the dashboard says so.

Delete the demo products now if you like. **Products → ⋯ → Delete** names what is
about to go and refuses an unknown id.

### 4. Add a shipping rate

A store with no rates ships everything free and nothing at checkout says so. The
admin Overview warns you. **Admin → Shipping → Add rate**: name it `Standard`, give
it a price, leave the zone and bounds empty. An unpinned, unbounded rate applies
everywhere, which is the whole configuration a flat-rate store needs.

### 5. Buy it

Back in the storefront, add your product to the cart. The cart asks which country
you are shipping to; that is how a zone-priced store knows the postage before
Stripe's page exists. Check out, and on Stripe's page use:

```
4242 4242 4242 4242    any future expiry    any CVC    any postcode
```

You land on `/confirm`, which polls the order's status until the webhook has
recorded payment. Your `stripe listen` terminal shows `checkout.session.completed`
arriving and a `200` going back.

### 6. See the order

**Admin → Orders.** The order is `paid`, with the shipping address Stripe collected,
the line as it was bought, and stock decremented on the variant. Mark it
`processing`, then `shipped` with a carrier and tracking number. Each transition
sends an email, or logs one if `SMTP_URL` is not set yet.

Replay the webhook and prove idempotency to yourself:

```bash
stripe events resend <event id from the listen output>
```

Stock does not move a second time.

### Where to next

- [Deploy it](/tutorials/deploy/), because there is no store until it is somewhere.
- [Restyle the storefront](/tutorials/restyle-the-storefront/) so it stops looking
  like the demo.
- [Go live](/tutorials/going-live/) when it is real.


---

# Deploy your store
<https://belugajs.com/tutorials/deploy/>

_What a DigitalOcean Droplet, Fly.io and Railway each cost and require, then the steps for all three._

:::caution[Written from the source, checked against a build]
The Beluga steps here follow the build and boot path in the code and a local
production build. The platform-specific steps are each platform's documented
ones. If one does not match what you see today, the
[shape of a deployment](/deploying/shape/) page explains what each step is *for*,
which is enough to adapt it.
:::

Beluga is one Node process on one port, and the only two things that need to
survive a redeploy are the database and the uploaded images — see
[the shape of a deployment](/deploying/shape/) for why that rules some platforms
out entirely. DigitalOcean, Fly.io and Railway all satisfy it; the difference
between them is cost, and how much server administration you're signing up for.

### What it costs

The sizing assumption is the same everywhere: one Node process, and `sharp` doing
image re-encodes on upload, which is the only memory spike. 512 MB is tight for
that; 1 GB is the honest minimum and 2 GB is comfortable.

| | DigitalOcean Droplet | Fly.io | Railway |
| --- | --- | --- | --- |
| Compute | $6 (1 vCPU, 1 GB) or $12 (1 vCPU, 2 GB) | $5.92 shared-cpu-1x, 1 GB (Amsterdam; US similar) | Metered: $20 per vCPU, $10 per GB RAM, per second of actual use |
| Disk for SQLite and images | Included (25 to 50 GB SSD) | $0.15 per GB volume; 10 GB is $1.50 | $0.15 per GB volume; 10 GB is $1.50 |
| Backups | 20% of Droplet price for weekly, 30% for daily | Snapshots $0.08 per GB, first 10 GB free | Not itemised |
| Bandwidth | 1 to 2 TB included | $0.02 per GB in NA and EU | $0.05 per GB egress |
| Plan fee | None | None | Hobby $5, which is a $5 usage credit, not an extra charge |
| Dedicated IPv4 | Included | $2 (a shared one is free and works for HTTPS) | Not needed |
| **SQLite store, realistic total** | **$7 to $15** | **$8 to $12** | **$5 to $10** |
| Managed Postgres, if you outgrow one box | $15.15 (1 GB) or $30.45 (2 GB) | $38 Basic (1 GB) plus $0.28 per GB storage | Runs as a metered service like the app |
| **With managed Postgres** | **$22 to $30** | **$45 to $55** | **$15 to $25** |

Three things worth knowing behind those numbers:

- **Railway is cheapest only because it bills what you use.** An idle Beluga sits
  around a tenth of a vCPU and 300 MB, which is a few dollars and mostly inside
  the Hobby credit. A busy store or a burst of uploads costs more, and you cannot
  predict the bill the way you can with a Droplet.
- **DigitalOcean is the only one with a flat, all-inclusive price.** Disk,
  bandwidth and IP are in the number. It is also the only one where you pay the
  operations cost in your own time: OS updates, TLS, the process manager.
- **Fly's managed Postgres is the outlier.** At $38 minimum it is more than the
  rest of the deployment combined. On Fly, SQLite on a volume is the sensible
  default, and Postgres is a reason to consider a Droplet with DO's $15 database
  instead.

If you want a predictable, flat bill and don't mind owning TLS and the process
manager yourself, use the Droplet. If you want the platform to own those instead
and are staying on SQLite, Fly is the cheapest way to do that. If your traffic is
low or bursty and you'd rather pay for what you use than for a box that sits idle,
Railway is the cheapest at rest — with a bill that moves.

### Deploy to a DigitalOcean Droplet

A Droplet is a VM. Both the SQLite file and the image directory live on its disk
with no volume to mount. The cost is that you own the process manager, TLS, and
OS updates. Some people actively want that.

**Use a Droplet, not App Platform.** App Platform's container filesystem is
ephemeral and wiped on every deploy, so uploaded product images would vanish with
each release. That is the Heroku failure wearing a different logo, and it is the
same constraint that [rules platforms in and out](/deploying/shape/) generally.

#### 1. The box

A 1 GB Droplet on Ubuntu LTS is enough for a small store. Create it with your SSH
key, then:

```bash
apt update && apt upgrade -y
adduser beluga && usermod -aG sudo beluga
ufw allow OpenSSH && ufw allow 80 && ufw allow 443 && ufw enable
```

Install Node 22 as the `beluga` user with nvm, plus the build tools
`better-sqlite3` and `sharp` need:

```bash
sudo apt install -y build-essential python3
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/master/install.sh | bash
nvm install 22
```

#### 2. Clone and build

```bash
git clone https://github.com/<you>/<your-fork>.git ~/store
cd ~/store
nvm use
npm ci
npm run build
```

`npm run build` typechecks everything including the tests, then emits the server
to `dist-server/` and the client to `dist/`.

#### 3. Configure

Create `~/store/.env`:

```
NODE_ENV=production
API_PORT=4000
API_HOST=127.0.0.1
DATABASE_URL=file:./data/beluga.sqlite
ASSETS_DIR=public/assets
SESSION_SECRET=<openssl rand -base64 32>
PUBLIC_URL=https://shop.example.com
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
```

`API_HOST=127.0.0.1` is the one that matters on a VM. With a reverse proxy on the
same box, binding to all interfaces would leave the Node port reachable from the
internet directly, arriving over plain HTTP with whatever `X-Forwarded-*` headers
a caller cares to send. `TRUST_PROXY` defaults to one hop in production, which is
exactly the proxy you are about to add.

#### 4. Run it under systemd

`/etc/systemd/system/beluga.service`:

```ini
[Unit]
Description=Beluga store
After=network.target

[Service]
User=beluga
WorkingDirectory=/home/beluga/store
ExecStart=/home/beluga/.nvm/versions/node/v22.23.2/bin/node dist-server/server/index.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production

[Install]
WantedBy=multi-user.target
```

Check the node path with `which node` while `nvm use` is active. Then:

```bash
sudo systemctl enable --now beluga
journalctl -u beluga -f
```

The log prints that the store is not set up, and a **setup token**. Keep this
terminal open for step 6.

#### 5. TLS and the proxy

Caddy is the least work: it obtains and renews the certificate itself.

```bash
sudo apt install -y caddy
```

`/etc/caddy/Caddyfile`:

```
shop.example.com {
    reverse_proxy 127.0.0.1:4000
}
```

```bash
sudo systemctl reload caddy
```

Point DNS at the Droplet first, or the certificate request fails. An
[nginx configuration](/examples/reverse-proxy/) is in the examples if you prefer
it.

#### 6. Claim the store

Open `https://shop.example.com/setup`, paste the token from the log, and create
the administrator. Or create it from the shell before the proxy is up:

```bash
cd ~/store && ADMIN_EMAIL=you@example.com ADMIN_PASSWORD='...' npm run db:seed
```

#### 7. Point Stripe at it

Add a webhook endpoint at `https://shop.example.com/api/webhooks/stripe` for
`checkout.session.completed`, `checkout.session.expired` and `charge.refunded`,
put its signing secret in `.env` as `STRIPE_WEBHOOK_SECRET`, and
`sudo systemctl restart beluga`.

#### 8. Verify, then back up

Publish a product and pay for it with the test card. Upload an image. Then read
[Backups and restore](/deploying/backups/), because nobody else is backing this
disk up. A cron job that copies `data/beluga.sqlite` and `public/assets/` off the
box nightly is the minimum.

**Redeploying:**

```bash
cd ~/store && git pull && npm ci && npm run build && sudo systemctl restart beluga
```

Migrations run at boot. There is a few seconds of downtime while the process
restarts; if that matters, put two processes behind the proxy and move to
[Postgres](/deploying/postgres/), since two processes cannot share a SQLite file
safely under load.

### Deploy to Fly.io

Fly.io hands off the OS and process supervision: one machine, one persistent
volume holding both the SQLite file and the uploaded images, and a Dockerfile
that pins the Node version and builds the native modules. The constraint is one
machine, so scale up rather than out, and [back the volume up](/deploying/backups/).

If you would rather keep images in a bucket, set the `ASSETS_S3_*` group instead
of `ASSETS_DIR` and the volume only has to hold the database. With Postgres as
well, no volume at all — though see the cost table above before reaching for
Fly's managed Postgres.

#### 1. Add a Dockerfile

Beluga's `npm run build` compiles the server to plain JavaScript in
`dist-server/` and the client to `dist/`, and `npm start` runs
`node dist-server/server/index.js`. The runtime image needs those two
directories, `db/migrations/`, `emails/`, and production dependencies, and it
must start from the repository root because paths are resolved from the working
directory. Use [the example Dockerfile](/examples/dockerfile/).

Add a `.dockerignore` with `node_modules`, `dist`, `dist-server`, `data`,
`public/assets`, `.env`, `test-results` and `legacy`.

#### 2. Create the app and the volume

```bash
fly launch --no-deploy        # answer no to Postgres and Redis
fly volumes create beluga_data --size 3 --region <your region>
```

Then in the generated `fly.toml`, mount the volume and point both persistent
things at it:

```toml
[env]
  NODE_ENV = "production"
  API_PORT = "8080"
  DATABASE_URL = "file:/data/beluga.sqlite"
  ASSETS_DIR = "/data/assets"

[[mounts]]
  source = "beluga_data"
  destination = "/data"

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = false
  auto_start_machines = true
  min_machines_running = 1
```

`auto_stop_machines = false` matters: the background timers for webhook delivery
and abandoned-cart reminders run inside the API process, and a machine that
stops between requests stops them too.

#### 3. Secrets

```bash
fly secrets set \
  SESSION_SECRET=$(openssl rand -base64 32) \
  PUBLIC_URL=https://<app>.fly.dev \
  STRIPE_SECRET_KEY=sk_test_... \
  STRIPE_PUBLISHABLE_KEY=pk_test_...
```

Keep test keys until [going live](/tutorials/going-live/). `PUBLIC_URL` is what
Stripe sends buyers back to and what every emailed link starts with; a deploy
that leaves it at the default fails silently. Add `SMTP_URL` and `EMAIL_FROM`
when you have them, and see the [environment reference](/deploying/environment/)
for the rest.

#### 4. Deploy and claim it

```bash
fly deploy
fly logs
```

Boot runs the migrations, creates the assets directory, and prints that the
store is not set up. In production it also prints a **setup token**, because
`/setup` is public until an administrator exists and a public address is a race
against every scanner on the internet. Open `https://<app>.fly.dev/setup`, paste
the token, and create your account. If you would rather not read the log, set
`SETUP_TOKEN` as a secret first and the wizard asks for that instead.

Alternatively, create the admin before anyone can reach the app:

```bash
fly ssh console -C "sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'"
```

#### 5. Point Stripe at it

In the Stripe dashboard, **Developers → Webhooks → Add endpoint**:

```
https://<app>.fly.dev/api/webhooks/stripe
```

Subscribe to `checkout.session.completed`, `checkout.session.expired` and
`charge.refunded`. Copy the signing secret and set it:

```bash
fly secrets set STRIPE_WEBHOOK_SECRET=whsec_...
```

This is the step most often skipped, and until it is done orders are never
marked paid while everything else looks fine.

#### 6. Verify

- Publish a product, complete a test payment, and confirm the order shows `paid`.
- Upload an image, then `fly deploy` again and confirm the image is still there.
  That proves the volume is mounted where `ASSETS_DIR` points.
- `curl -s https://<app>.fly.dev/product/<slug> | grep '<title>'` shows the
  product name, which proves the production HTML handler is running.

**Redeploying:** `fly deploy` builds a new image and swaps the machine.
Migrations run at boot and are idempotent. With one machine there is nothing to
roll one-at-a-time; if you add a second, move to
[Postgres](/deploying/postgres/) first, because a volume attaches to one
machine.

### Deploy to Railway

Railway is the metered version of the same shape: build from a Dockerfile, attach
a volume for the database and images, and pay per second of actual CPU and
memory rather than for a reserved machine. That makes it the cheapest option for
a store that mostly sits idle, and the least predictable one for a store that
doesn't.

#### 1. Add a Dockerfile

The same Dockerfile as the Fly.io section above works unchanged — Railway
builds from it directly. It must use a Debian-based Node image, not Alpine,
because `better-sqlite3` and `sharp` need a toolchain to build their native
code. See [the example Dockerfile](/examples/dockerfile/).

#### 2. Create the project and a volume

```bash
railway login
railway init
railway volume add --mount-path /data
railway domain
```

`railway domain` generates a `<project>.up.railway.app` address; you'll need it
for `PUBLIC_URL` below. The Dockerfile's `EXPOSE 4000` is enough for Railway to
route to the container — there's no separate port-mapping step like Fly's
`internal_port`.

#### 3. Variables

```bash
railway variables \
  --set "NODE_ENV=production" \
  --set "API_PORT=4000" \
  --set "DATABASE_URL=file:/data/beluga.sqlite" \
  --set "ASSETS_DIR=/data/assets" \
  --set "SESSION_SECRET=$(openssl rand -base64 32)" \
  --set "PUBLIC_URL=https://<project>.up.railway.app" \
  --set "STRIPE_SECRET_KEY=sk_test_..." \
  --set "STRIPE_PUBLISHABLE_KEY=pk_test_..."
```

Same rule as everywhere else: `PUBLIC_URL` left at the default fails silently,
because it's what Stripe redirects buyers back to.

#### 4. Deploy and claim it

```bash
railway up
railway logs
```

Boot runs the migrations and prints a **setup token**, since `/setup` is public
until an administrator exists. Open `https://<project>.up.railway.app/setup`,
paste it, and create your account — or shell in and seed one directly:

```bash
railway ssh -- sh -c 'ADMIN_EMAIL=you@example.com ADMIN_PASSWORD=... npm run db:seed'
```

#### 5. Point Stripe at it

In the Stripe dashboard, **Developers → Webhooks → Add endpoint**:

```
https://<project>.up.railway.app/api/webhooks/stripe
```

Subscribe to `checkout.session.completed`, `checkout.session.expired` and
`charge.refunded`, then set the signing secret:

```bash
railway variables --set "STRIPE_WEBHOOK_SECRET=whsec_..."
```

Until this is set, orders are never marked paid while everything else looks
fine.

#### 6. Verify

- Publish a product, complete a test payment, and confirm the order shows
  `paid`.
- Upload an image, redeploy with `railway up`, and confirm the image is still
  there — that proves the volume is mounted where `ASSETS_DIR` points.
- `curl -s https://<project>.up.railway.app/product/<slug> | grep '<title>'`
  shows the product name.

**Redeploying:** `railway up` builds a new image and replaces the running one.
Migrations run at boot and are idempotent. Watch the metered compute cost if you
scale to more than one instance — like Fly, a single SQLite file on a volume
only tolerates one writer, so move to [Postgres](/deploying/postgres/) first.


---

# Go live
<https://belugajs.com/tutorials/going-live/>

_Test keys to live keys, the webhook that has to be re-created, the checklist the admin keeps for you, and the one state that gets a persistent warning._

A deploy is not a launch. Between the two, Stripe gets connected for real, SMTP gets
a real sender, and shipping zones get argued about. All of that needs a *deployed*
store, because webhooks cannot reach localhost, and while it happens the catalogue
is three placeholder products on a test key. This page is the stretch between.

### 1. Lock the storefront while you work

**Settings → Visibility → Password.** Shoppers see a password prompt; your own
admin session always passes. Mint a share link to show a friend without telling them
the password. [Storefront visibility](/operating/visibility/) has the details.

### 2. Replace the demo catalogue with the real one

Delete the demo products, create yours, and publish each one. Or export the CSV,
edit it, and [import it](/catalogue/csv/). Remember the import never publishes:
imported products land as drafts unless `is_live` says otherwise, and even a live
one is not published until someone clicks Publish.

### 3. Shipping, tax, email

- **Shipping.** Zones for where you ship, rates for what it costs. The Overview
  warns when a store has live physical products and no rates, and when a zone
  covers a country no rate can price. A gap ships free, silently. [Shipping](/money/shipping/).
- **Tax** is off by default, and under-collecting is silent: every order goes
  through, the buyer pays, and you owe the difference. Read [Tax](/money/tax/) before
  the switch; three things have to be true in Stripe first.
- **Email.** Until `SMTP_URL` is set, sending is a logged no-op. Set it, then
  **Settings → Email → Send a test** to prove the credentials work.

### 4. Swap to live keys

Test and live keys have **separate catalogues** in Stripe. Everything you published
under the test key exists only in the test account.

1. Set `STRIPE_SECRET_KEY` and `STRIPE_PUBLISHABLE_KEY` to the live pair and restart.
2. In the **live** Stripe dashboard, create the webhook endpoint again at
   `https://your-store/api/webhooks/stripe` with the same three events. It has a
   new signing secret. Set `STRIPE_WEBHOOK_SECRET` to it and restart again.
3. **Republish every product.** The Overview lists the ones that are not published
   under the current key. Publishing mints live Products and Prices.

### 5. Read the checklist

**Settings → Visibility** carries the checklist behind **Open the store**: Stripe
connected, a live key, webhooks connected, the public URL not localhost, shipping
rates that cover where the store ships, an email provider, and something live to
buy. Nothing on it blocks the switch; a catalogue-only store with no Stripe is a
legitimate thing to open. But flipping to public with rows still failing asks for
confirmation first and names what is outstanding.

There is no launch wizard. Four of the seven items are environment variables read
once at boot, so a wizard step for any of them would have nothing to click.

### 6. The one state that gets more than a checklist row

**Public and holding a test key.** Checkout completes, the buyer sees a
confirmation, the webhook records a paid order, and no money moved. The Overview
carries a persistent error-level notice until the key or the visibility changes.

### 7. Open it

Flip visibility to **Public**. Make one real purchase with a real card for a small
amount and refund it from the admin; that exercises the live webhook, the refund
webhook, and the restock in one go.

Then [back the data up](/deploying/backups/), if you have not already.


---

# Add a field to products
<https://belugajs.com/tutorials/adding-a-field/>

_A nullable subtitle on products, end to end, because most of what a fork does day to day is exactly this and skipping a step is how it silently 404s._

This is the one walkthrough worth doing in full, because it crosses every
convention at once: both dialect schemas, a migration for each, the shared zod
schema, the input schema, the repository, the route, the security test, and the
editor. We will add a nullable `subtitle` to products.

Skipping a step is how a field works in the admin and silently 404s in the
storefront, or types clean and fails at runtime on Postgres only.

### 1. Both dialect files

Add the column to `products` in `db/schema.sqlite.ts` **and** `db/schema.pg.ts`,
same name, same nullability, in the same commit.

```ts
// db/schema.sqlite.ts
subtitle: text("subtitle"),

// db/schema.pg.ts
subtitle: text("subtitle"),
```

They are two files because SQLite and Postgres do not share a `drizzle-kit`
dialect; nothing else forces them apart.

### 2. Generate both migrations

```bash
npm run db:generate
```

That emits one migration into `db/migrations/sqlite/` and one into
`db/migrations/pg/`. Commit both. A migration for only one dialect is
[invariant 6](/start/invariants/) broken silently, since `npm test` seeds SQLite and
will not notice Postgres never got the column.

### 3. The shared schema

`shared/schema.ts` is the type the storefront, the admin and the server all import,
so this is the step that turns a forgotten later step into a type error.

```ts
export const productSchema = z.object({
  // ...
  subtitle: z.string().max(120).nullable().default(null),
```

### 4. The input schema

`shared/api.ts` holds the schema an admin write is validated against before it
reaches the database. Add the field there, never inline in the route.

```ts
export const productInputSchema = z.object({
  // ...
  subtitle: z.string().trim().max(120).nullable().default(null),
```

### 5. The repository

`buildProduct` in `db/repository.ts` is where a row becomes a `Product`; add the
column on the read side. `createProduct` and `updateProduct` in
`db/admin-repository.ts` are the write side.

```ts
// db/repository.ts, in buildProduct
subtitle: row.subtitle ?? null,

// db/admin-repository.ts, in the insert and update value maps
subtitle: input.subtitle,
```

### 6. The route

The admin product route in `server/routes/admin.ts` already parses the body with
the schema from step 4 and calls the repository function from step 5. Usually
nothing changes here, which is the payoff of routes staying thin.

### 7. The security test

For a field on an existing route there is nothing to add. This step is here because
it is the one that is easy to forget when it *does* apply: a genuinely new route
goes into `MUTATIONS` or `READS` in `server/security.test.ts`, and that file is what
stops an unprotected endpoint shipping.

### 8. The editor and the storefront

Add an input to `src/admin/ProductEditorPage.tsx`; the form autosaves through the
same input schema. Then render it wherever it belongs, for instance under the name
in `src/components/product/ProductDetails.tsx`.

### 9. Prove it on both engines

```bash
npm run typecheck && npm run lint && npm test -- --reporter=verbose
```

Look for `repository on postgres` in the output. Postgres skips silently if
`embedded-postgres` cannot start, so a green run without that line is not proof
both dialects passed.

### Also worth updating

- The CSV export and import in `shared/catalogue-csv.ts`, if merchants should be
  able to bulk-edit the field.
- The `product.published` webhook payload in `shared/webhooks.ts`, if subscribers
  need it. Payloads are built field by field, so a new column is not exposed by
  accident.


---

# Restyle the storefront
<https://belugajs.com/tutorials/restyle-the-storefront/>

_Theme it from the admin first, then replace the components that are yours to replace, without touching the ones that hold a rule._

There are two ways to make a Beluga store look like yours, and they stack. The theme
editor covers palette, corner radius, logo and typeface with no code. Forking the
storefront components covers everything else. Do the first before the second, because
a replacement component should still read the theme tokens or it stops matching the
rest of the store the moment a merchant opens the editor.

### 1. The theme editor

**Settings → Look.** Primary colour, accent, page background, light or dark scheme,
a corner radius from 0 to 4 px, a logo, and a font.

The font needs two fields. A font stack alone only renders on a machine that already
has the face installed, so **Font stylesheet URL** holds the stylesheet that defines
it: for Google Fonts, the `href` out of the `<link>` they give you. Saving it widens
the store's Content-Security-Policy by exactly the origins that stylesheet needs,
which is why `fonts.gstatic.com` gets allowed even though it appears nowhere in the
URL you pasted. The preview cannot show a typeface you have not saved yet, for the
same reason.

**Settings → Landing page** sets the hero: heading, a line of text, a button label
and target, and a background image. Leave any empty and the storefront falls back
to the store name and a **Shop everything** button.

### 2. The tokens

Everything above lands on `:root` as `--beluga-*` custom properties: `--beluga-ink`,
`--beluga-muted`, `--beluga-line`, `--beluga-surface`, `--beluga-page`,
`--beluga-primary`, `--beluga-on-primary`, `--beluga-accent`, `--beluga-on-accent`,
`--beluga-radius`. `src/index.css` carries the light values as a first-paint
fallback; `src/lib/theme.ts` is the source of truth for both schemes. Read colours
and radius from these in anything you write.

### 3. Replace the landing page

`src/pages/LandingPage.tsx` is presentation. It reads `store.hero` and two helpers,
`getFeaturedProducts` and `getVisibleCollections` from `shared/catalog.ts`, and
nothing else depends on its internals. Rewrite it. [A custom landing page](/examples/landing-page/)
is a worked example that keeps the hero fallbacks and adds a section of its own.

### 4. Replace the product card

`src/components/product/ProductCard.tsx` takes a name, a preformatted price string,
an image and a sold-out flag. It takes no `Product` and calls nothing from `shared/`,
on purpose, so the theme editor can render a real-looking card with no catalogue
behind it. `ProductList.tsx` is the thin layer above it that turns a `Product[]`
into those props via `formatPriceRange` and `isSoldOut`.

Replace the card freely. If you replace the list too, keep it deriving price and
sold-out state from the catalogue rather than caching them; that is the cart rule
applied one layer up. [A custom product card](/examples/product-card/).

### 5. The header and footer

`src/components/layout/Banner.tsx` and `Footer.tsx` build their links from
`store.pages` and the visible collections. Read the footer once before rewriting
it: it deliberately lists **every** published page whether or not it is in the
header menu. That is the only way a returns policy a merchant publishes but forgets
to add to the menu is still one link from every page of the shop. Keep that
behaviour.

### 6. Email

`emails/*.hbs` are Handlebars: a `body.hbs` and `subject.hbs` per template, a shared
`layout.hbs`, and an `items.hbs` partial. The locals are pre-formatted display
strings, so a template does no arithmetic and cannot get money wrong by rounding it
twice. Restyle them as HTML email allows.

### What not to touch while you are in there

`CartPage.tsx`, `ConfirmPage.tsx` and `ProductDetails.tsx` look like the files
above and are not. Each is the specific place an invariant is enforced on the
client: the cart holds identifiers and re-derives every price on render, the
confirmation page polls for status rather than assuming payment, and the quantity
control re-clamps against stock when the variant changes. The styling around those
rules is yours; the rules are not. [The seams](/building/seams/) has the full map.


---

# Connect a fulfilment webhook
<https://belugajs.com/tutorials/fulfilment-webhook/>

_Subscribe a small receiver to order.paid, verify the signature, and send yourself a pick list. The first genuinely useful thing outbound webhooks unlock._

Beluga has no app ecosystem. What it has instead is a signed POST to a URL you own
whenever something happens, retried until you answer. This tutorial wires one up:
a receiver that gets `order.paid`, verifies it, and posts a pick list to a Slack
channel. Swap the last step for your warehouse, your ledger, or your printer.

You need a Beluga store that can take a test payment and somewhere to run twenty
lines of Node that Beluga can reach over HTTPS. A tunnel is fine for development.

### 1. Write the receiver

The whole thing is in [A webhook receiver](/examples/webhook-receiver/). The parts
that matter:

- It uses `express.raw`, not `express.json`, on the webhook route. The signature is
  an HMAC over the raw bytes, and a body parser that has already turned the request
  into an object cannot reproduce them.
- It rejects a timestamp more than five minutes old. The timestamp is inside the
  signed material so a replay is detectable.
- It answers `200` **before** doing the work. Beluga waits ten seconds and then
  treats the delivery as failed.
- It deduplicates on the event id. Delivery is at-least-once.

Run it, and expose it:

```bash
node receiver.js
ngrok http 3000
```

### 2. Register the endpoint

**Admin → Webhooks → Add an endpoint.** Paste the tunnel's `https://` URL with the
`/hooks/beluga` path, tick **order.paid**, save.

You are shown the **signing secret exactly once.** Put it in the receiver's
environment as `BELUGA_WEBHOOK_SECRET`. Nothing reads it back afterwards; lose it
and you roll a new one, which invalidates the old one immediately.

The URL must be `https://` and its hostname must resolve to a public address. A
tunnel satisfies both. For a receiver that genuinely lives on localhost, and only on
a machine nobody else can reach, `WEBHOOK_ALLOW_INSECURE_TARGETS=true` lifts both
rules.

### 3. Trigger it

Complete a test payment. `checkout.session.completed` arrives at Beluga, the order
is marked paid, and an `order.paid` event is queued. Delivery is a background pass
every ten seconds, never inline, so a slow subscriber can never delay Beluga's
answer to Stripe.

Your receiver logs the order. The Slack message says what to pick.

### 4. Read the delivery log

The endpoint's row in the admin expands into its recent deliveries: event type,
attempt count, response status, the error if there was one. **Redeliver** puts one
back at the front of the queue with its attempts reset, which is the fastest loop
while you are debugging the handler.

Stop the receiver and pay again. The delivery fails, and retries after 1 minute,
5, 25, 2 hours, 10 hours, then gives up. Five give-ups in a row disable the
endpoint; re-enabling clears the count and sends what is still queued.

### 5. Make it idempotent for real

Resend a delivery with the receiver running. The same event id arrives twice. If
your dedup store is in memory, that survives a restart badly; use the database you
already have. Two subscribers to the same event receive the same id, so key the
store per receiver.

### What to build on this

- `order.updated` fires on fulfilment status, carrier or tracking changes; a
  cancellation sends `order.cancelled` instead, so you do not diff statuses.
- `inventory.low` fires per variant, once per qualifying sale, at five units or
  fewer. It is not latched; filter on `remaining` for a different threshold.
- `product.published` carries `kind`, so a fulfilment receiver can branch on
  physical versus digital before raising a pick list.

[Build a receiver](/integrating/webhook-receiver/) is the reference: every payload,
verification in Node and Python, the truncation rule for very large orders, and a
troubleshooting table.


---

# Sell a digital product
<https://belugajs.com/tutorials/digital-product/>

_Mark a product digital, see what changes in the cart and at checkout, and deliver the file yourself through a webhook, because Beluga does not deliver it yet._

Beluga knows what a download **is**. It does not yet hand one over. This tutorial
sets up a digital product correctly, shows what the flag changes, and closes the
gap with the tool that exists for it: an `order.paid` webhook into a small
fulfilment script.

### 1. The product

**Admin → Products → New product**, and set **Type** to **Digital**.

Three things follow, and the editor enforces them:

- **Stock is always unlimited.** The API refuses a finite count on a digital
  variant. This is not tidiness: the inventory decrement would count it down, reach
  zero, and start flagging paid orders `oversold` for a file that cannot run out.
- **Weight is irrelevant** and the field is hidden.
- **Tax code.** Digital goods are taxed differently from tangible ones in many
  places. If you use Stripe Tax, set a code for it; the store default
  `txcd_99999999` is general tangible goods.

Publish it.

### 2. What changes in the cart

A cart holding only downloads never asks for a destination country and reaches
Stripe with no address collection and no shipping options. A mixed cart still
collects an address, priced on its physical lines only.

Digital lines are **excluded** from the parcel, not zeroed. The difference matters:
a zero-gram line still participates, so a cart of nothing but PDFs would report a
0 g parcel, match the store's lightest weight band, and charge postage on a parcel
that does not exist. The same rule applies to subtotal bands: a $40 download does
not push a $10 box over a "free over $50" threshold, and on an upper bound it is
worse, because it could push the cart past every band, match nothing, and ship free
in silence.

`physicalLines`, `requiresShipping` and `parcelFor` in `shared/shipping.ts` are the
API for this, and they are what checkout uses too, from the catalogue rather than
the request, so a buyer cannot declare their order digital to skip the address.

### 3. Delivering the file

There is no file upload for products, no entitlement record, and no download route.
That is a [known gap](/catalogue/digital-products/), and the approach has not been
chosen, so do not build it into the statically served assets directory: anything
under `public/assets/` is downloadable by whoever guesses the path.

The supported route today is a webhook:

1. Store the files somewhere private: object storage with signed URLs is the usual
   answer.
2. Follow [Connect a fulfilment webhook](/tutorials/fulfilment-webhook/) and
   subscribe to `order.paid`.
3. In the receiver, look at each item's `productId` and `variantId`, mint a
   time-limited signed URL for the matching file, and email it to `data.email`.
4. Deduplicate on the event id, because at-least-once delivery would otherwise
   send the link twice.

The payload snapshots `productName` and `variantLabel` at purchase, so the email can
name what was bought even if the product is renamed later.

### 4. Fulfilment status

The order status vocabulary is `paid`, `processing`, `shipped`, `cancelled`,
`refunded`. None of those means "delivered". Mark a download `shipped` once the
link has gone out if you want the admin to show it as done, and know that the
Shipped email talks about carriers. Adding a `delivered` status is one of the two
collisions the gap document records, along with what a refund should do to an
entitlement.

### 5. Refunds

A refund of a download restocks nothing, since there is no stock. It should revoke
access, which is your receiver's job: subscribe to `order.refunded` and expire the
signed URL, or mark the entitlement revoked in whatever you built in step 1.


---

# The shape of a deployment
<https://belugajs.com/deploying/shape/>

_One Node process serving API and static files, what must persist, and what breaks on a platform that forgets its disk._

There is no hosted Beluga, so every reader deploys before they have a store to
build out. This page is platform-agnostic; [Deploy your store](/tutorials/deploy/)
is the same skeleton filled in for DigitalOcean, Fly.io and Railway.

### One process, one port

In production the Express app serves the built client from `dist/` and answers
`/assets/<path>` for uploaded images, so a deployment is **one Node process on one
port**. No separate static host, no CDN required, no reverse proxy needed to get
started, though a VM usually wants one for TLS.

```
install all deps  →  npm run build  →  prune to production  →  node dist-server/server/index.js
```

`npm run build` typechecks everything including the tests, then compiles the server
to plain JavaScript in `dist-server/` and the client to `dist/`. `npm start` runs
the compiled server under `node`; there is no TypeScript at runtime and nothing in
`dist-server/` imports a devDependency, so a production image can prune to
`dependencies` after building.

**Migrations run automatically at boot**, are idempotent, and the data backfills are
written to be no-ops on a database that has had them. There is no release-phase
step. Roll one instance at a time rather than booting several into an unmigrated
database.

### What must persist

Two things, and this is the constraint that rules platforms in and out.

1. **The database.** SQLite is a file under `data/` by default. Point
   `DATABASE_URL` at [Postgres](/deploying/postgres/) and this constraint goes away.
2. **Uploaded images.** Written to `ASSETS_DIR` (`public/assets` by default), or to
   an S3-compatible bucket when `ASSETS_S3_BUCKET` and its group are set. With a
   bucket, this constraint goes away too.

So the honest options are:

| Database | Images | Needs a persistent disk? | Can run two instances? |
| --- | --- | --- | --- |
| SQLite | disk | yes, one volume for both | no |
| SQLite | bucket | yes, for the database | no |
| Postgres | disk | yes, for the images | no |
| Postgres | bucket | **no** | **yes** |

A store on a platform with an ephemeral filesystem, and neither Postgres nor a
bucket configured, loses its catalogue's images and its orders on the next deploy.
Nothing warns you. This is the biggest trap in the project and the reason Heroku,
Vercel, Netlify and DigitalOcean App Platform are wrong for the default
configuration. With Postgres and a bucket, a platform like App Platform, Railway
or Render becomes workable; the remaining requirement is a long-lived process,
because the webhook delivery and cart-reminder timers run inside it and a
serverless function has nowhere to run them.

### Paths resolve from the working directory

`dist`, `db/migrations/*`, `emails/` and a relative `ASSETS_DIR` are all resolved
against the process's working directory. Start from the repository root, and keep
`db/migrations/` in the deployed image: a build that prunes source files breaks
boot, not just uploads.

### Native modules

`better-sqlite3` and `sharp` compile native code or download prebuilt binaries.
On Alpine or musl base images, and on ARM builders, make sure the build stage has a
toolchain. The [example Dockerfile](/examples/dockerfile/) uses a Debian-based
Node image for exactly this reason.

### Behind a proxy

`TRUST_PROXY` defaults to one hop in production. The login rate limit and the
`Secure` session cookie both depend on it. Trusting more hops than exist lets a
caller choose their own IP. On a VM where the proxy shares the box, also set
`API_HOST=127.0.0.1`, or the Node port is reachable from the internet directly.

### The setup token

`/setup` is public until the store has an administrator. In production the server
prints a one-off token at boot and the wizard asks for it, so only someone who can
read the log can claim a fresh deploy. Set `SETUP_TOKEN` to choose the value, or
create the admin with `ADMIN_EMAIL` and `ADMIN_PASSWORD` through `npm run db:seed`
before the app is exposed.

### The two things to verify after any deploy

- Upload an image, redeploy, confirm it is still there.
- `curl -s https://your-store/product/<slug> | grep '<title>'` shows the product
  name. The SEO head rewriting runs only in the production branch, so this proves
  the production handler is what is serving.

Then [back it up](/deploying/backups/).


---

# Environment reference
<https://belugajs.com/deploying/environment/>

_Every variable the server reads, what it defaults to, and what breaks when it is wrong._

Configuration is read once from `.env` (or the file `ENV_FILE` names) and from the
process environment, then validated. An invalid value fails at boot with the
variable named, rather than as a 500 later. `.env` is gitignored; use platform
environment variables in production.

### Core

| Variable | Default | Notes |
| --- | --- | --- |
| `NODE_ENV` | `development` | `production` turns on the setup token, the compiled-client handler, `Secure` cookies and `TRUST_PROXY=1`. |
| `API_PORT` | `4000` | Separate from Vite's port. Avoid 5000 on macOS: AirPlay Receiver binds it. |
| `API_HOST` | `0.0.0.0` | Set `127.0.0.1` on a VM with a reverse proxy on the same box. |
| `TRUST_PROXY` | `1` in production, `false` otherwise | `false`, `true`, a hop count, or a list of addresses or subnets. The login rate limit and the session cookie depend on it. |
| `PUBLIC_URL` | `http://localhost:5173` | Where shoppers reach the store. Behind Stripe's success and cancel URLs, the sitemap, canonical and Open Graph tags, and every emailed link. A deploy that leaves the default fails silently. |
| `SESSION_SECRET` | generated per boot in development | **Required in production**, at least 32 characters. `openssl rand -base64 32`. Without it in development, restarting the API signs you out. |
| `SETUP_TOKEN` | printed at boot in production | At least 16 characters. What the first-run wizard asks for before it will create the first administrator. |
| `DATABASE_URL` | `file:./data/beluga.sqlite` | `file:` for SQLite, `postgres://` for Postgres. |

### Stripe

| Variable | Notes |
| --- | --- |
| `STRIPE_SECRET_KEY` | `sk_test_…` or `sk_live_…`. Server-only. A store without it browses but cannot take money. |
| `STRIPE_PUBLISHABLE_KEY` | `pk_…`. Public by design; served to the browser through `/api/store`. |
| `STRIPE_WEBHOOK_SECRET` | `whsec_…` from the Stripe dashboard endpoint, or from `stripe listen`. Without it no order is ever marked paid. |

### Images

| Variable | Default | Notes |
| --- | --- | --- |
| `ASSETS_DIR` | `public/assets` | Where uploads are written and served from under the local driver. Relative to the working directory. Point it at a volume. The URL is always `/assets/<path>` regardless. |
| `MAX_UPLOAD_BYTES` | 20 MB | Ceiling for imagery; uploads are re-encoded and capped at 2400 px anyway. |
| `ASSETS_S3_BUCKET` | unset | Setting it selects the bucket driver and makes the next four required. |
| `ASSETS_S3_REGION` | | `auto` for R2 and similar. |
| `ASSETS_S3_ACCESS_KEY_ID` | | |
| `ASSETS_S3_SECRET_ACCESS_KEY` | | |
| `ASSETS_PUBLIC_URL` | | The bucket's public address or a CDN in front of it. `/assets/<path>` redirects here. |
| `ASSETS_S3_ENDPOINT` | AWS | For any provider other than AWS itself: Spaces, R2, Backblaze, MinIO. |
| `ASSETS_S3_ACL` | unset | `public-read` for providers that want it per object (Spaces does; AWS with bucket-owner-enforced ownership rejects it). |

A half-configured bucket group is refused at boot with the missing variables
named, and a stray `ASSETS_S3_*` value with no bucket is refused too, because it
would quietly write to disk on a platform that loses it. The boot log says which
driver is active next to the port.

### Email

| Variable | Notes |
| --- | --- |
| `SMTP_URL` | Any SMTP provider: `smtps://user:pass@smtp.example.com:465`. Until set, sending is a logged no-op, not an error. |
| `EMAIL_FROM` | `"My Store <orders@example.com>"`. |

### Webhooks

| Variable | Default | Notes |
| --- | --- | --- |
| `WEBHOOK_ALLOW_INSECURE_TARGETS` | `false` | Lets outbound endpoints be plain `http://` or resolve to private addresses. Leave it off anywhere public. |

### Client (Vite)

| Variable | Default | Notes |
| --- | --- | --- |
| `VITE_BELUGA_API` | `true` | `false` renders the bundled demo fixture with no database, for UI work. Confusing if you set it and forget. |

### Seeding without a terminal

`npm run db:seed` reads `ADMIN_EMAIL` and `ADMIN_PASSWORD` from the environment
and creates the administrator if none exists, which is how containers and CI get a
store without answering prompts.

### e2e tests

`playwright.config.ts` points `ENV_FILE` at a file that does not exist, on purpose,
so `.env` is irrelevant to `npm run test:e2e`. Copying one in will not fix a
failing run.


---

# Postgres
<https://belugajs.com/deploying/postgres/>

_When to move off SQLite, how, and what it unlocks._

SQLite is the default because a store should run without provisioning anything,
and for a small shop it is the right answer indefinitely. Move when one of these
is true:

- You want **more than one instance** of the API. Two processes cannot share a
  SQLite file safely under load, and a volume attaches to one machine.
- The catalogue or order history outgrows what a single file on a small volume
  handles comfortably.
- Your platform offers **managed Postgres with backups** and you would rather not
  write the cron job yourself.

### Switching

```bash
DATABASE_URL=postgres://user:pass@host:5432/beluga npm run db:migrate
```

Migrations for Postgres live in `db/migrations/pg/` and run at boot exactly as the
SQLite ones do. A fresh Postgres database boots unconfigured, and the setup wizard
or `npm run db:seed` creates the administrator.

There is no SQLite-to-Postgres data migration tool. For a store with real orders,
export them as [CSV](/money/order-export/) for your records, export the
[catalogue CSV](/catalogue/csv/), and import the catalogue into the new store. Stripe
Products and Prices are untouched by any of this; republish and the existing Stripe
objects are reused where the ids match.

### What it unlocks

Sessions live in the database, and the background timers for webhook delivery and
abandoned-cart reminders are guarded by conditional updates, so **multiple API
instances are safe on Postgres**. Two ticks racing to send the same reminder cost
one wasted query, never a duplicate email. Pair Postgres with a bucket for images
and nothing in the deployment needs a disk. See
[the shape of a deployment](/deploying/shape/).

### Both engines are tested together

`db/dialect.test.ts` runs the same repository assertions against SQLite and
Postgres, the latter through `embedded-postgres` so no system install is needed.
If `embedded-postgres` cannot start, that half of the suite **skips silently** and
the run is still green. Confirm with:

```bash
npm test -- --reporter=verbose | grep "repository on postgres"
```

### Schema changes

Both dialect files are edited together, then `npm run db:generate` emits a
migration for each. That is [invariant 6](/start/invariants/); the
[adding a field](/tutorials/adding-a-field/) tutorial walks through it.


---

# Backups and restore
<https://belugajs.com/deploying/backups/>

_The volume holds the store, and nobody else is backing it up._

Everything a Beluga store is lives in two places: the database and the uploaded
images. On a Droplet or a Fly volume that is a disk you own, and no one is backing
it up unless you are.

### What to back up

- **The database.** `data/beluga.sqlite` for SQLite, or the Postgres database.
- **The images.** `ASSETS_DIR` (`public/assets` by default), unless they are in a
  bucket, in which case the provider's versioning or replication is your backup.
- **The environment.** `.env` or the platform's secrets. The Stripe webhook secret
  and the session secret are not recoverable from anywhere else, and a restored
  store with a new session secret signs everyone out, which is fine, while a
  restored store with the wrong webhook secret records no orders, which is not.

Not Stripe. Products, Prices, Customers and payments are Stripe's, and survive
anything that happens to your server.

### SQLite

Copy the file while the database is consistent. The safe way is SQLite's own
backup command, which works on a live database:

```bash
sqlite3 data/beluga.sqlite ".backup 'backups/beluga-$(date +%F).sqlite'"
```

A plain `cp` of a file that is mid-write can produce a corrupt copy. Sessions and
the webhook queue are in the database too, so a restore rewinds them; that is
harmless, since delivery is at-least-once and a session just expires.

### Images

```bash
rsync -a public/assets/ backups/assets/
```

Derivatives are regenerated only on upload, so back up the whole tree, not just
the originals.

### Postgres

`pg_dump` on a schedule, or the managed provider's point-in-time recovery. The
Fly and DigitalOcean managed offerings both do daily snapshots by default; check
the retention window is longer than the time it would take you to notice a
problem.

### A nightly job

On a VM, a cron entry that runs the SQLite backup and the rsync, then copies the
result off the box with `rclone` or `aws s3 sync`, is the minimum. Test the restore
once: point a fresh checkout at the copied database and directory, boot it, and
look at an order.

### Restore

1. Stop the process.
2. Put the database file and the assets directory back where `DATABASE_URL` and
   `ASSETS_DIR` point.
3. Start it. Migrations are idempotent, so a backup from an older release is
   brought up to date at boot.
4. Check that the Stripe webhook secret in the environment is the one the live
   endpoint has, and take a test payment.


---

# The seams
<https://belugajs.com/building/seams/>

_Which files are yours to replace, which are designed to be extended in one direction, and which hold a rule that breaks silently when edited like the first kind._

A fork's `src/` tree looks like one undifferentiated pile of components, and it is
not. Some of it is styling that exists to be overwritten. Some of it is a documented
seam meant to be extended. Some of it is the specific place an
[invariant](/start/invariants/) is enforced, and breaks without a compile error when
someone edits it as if it were the first kind. This page is the map.

### 1. Yours to replace

Presentation. Rewrite, restyle or delete; nothing depends on the internals, only on
the data they are handed.

- **`src/pages/LandingPage.tsx`** reads `store.hero` (every field optional, with a
  fallback to the store name and a **Shop everything** button) and
  `getFeaturedProducts` / `getVisibleCollections` from `shared/catalog.ts`. The
  collection tiles are inline here; there is no separate component to swap.
- **`src/components/layout/Banner.tsx`** and **`Footer.tsx`**. Both build their links
  from `store.pages` and the visible collections. The footer deliberately lists
  *every* published page whether or not it is in the header menu; keep that.
- **`src/components/product/ProductCard.tsx`** takes a name, a preformatted price, an
  image and a sold-out flag, and nothing from `shared/`, so the theme editor can
  render one with no catalogue behind it. `ProductList.tsx` is the thin layer above
  it. If you replace the list too, keep it deriving price and sold-out state from
  the catalogue rather than caching them.
- **`src/index.css`**. The `--beluga-*` custom properties are the baseline every
  component is styled against and what **Settings → Look** overwrites at runtime.
  Read colours and radius from these tokens in anything new.
- **`emails/*.hbs`**. Handlebars locals are pre-formatted display strings, so a
  template does no arithmetic.

### 2. Extend through the seam

Each of these is a function or interface with an existing example of exactly the
substitution it exists for.

- **The catalogue's source**: `loadStore` in `src/lib/store-source.ts`. Fetches
  `/api/store` and validates it with `storeSchema`; with `VITE_BELUGA_API=false` it
  validates the demo fixture instead. Point it at a different backend by keeping
  the return type, a schema-validated `Promise<Store>`, the same.
- **Search and sort**: `searchProducts` / `sortProducts` in `shared/catalog.ts`,
  client-side against the snapshot. Past `STORE_SNAPSHOT_LIMIT` (200 products), the
  swap is to `GET /api/products?search=`, which already exists, behind the same
  `store-source.ts` file.
- **Which email goes out when**: `templateForStatus` in `server/email.ts` maps an
  order status to a template. Add a status and a matching `.hbs` pair.
- **Where uploaded images live**: `ImageStore` in `server/image-store.ts`, with
  `put`, `delete` and `publicUrl`. Two drivers ship, local disk and S3-compatible,
  chosen by environment. A third backend implements the interface; the database
  stores the same relative path under all of them and the storefront keeps asking
  for `/assets/<path>`.

### 3. Not without reading the invariants

These look like the files in section 1 and are not.

- **`src/pages/CartPage.tsx`**. The cart holds identifiers and quantities, never a
  price or an image URL; every displayed price is re-derived from the current
  catalogue on render. If the cart cached a price, that is the value a stale tab
  would send back.
- **`src/pages/ConfirmPage.tsx`** polls `GET /api/checkout/:sessionId` rather than
  assuming payment succeeded because the buyer landed there.
- **`src/components/product/ProductDetails.tsx`** clamps quantity through
  `normalizeQuantity` on every change, including a re-clamp when switching
  variants, so a variant with less stock cannot keep a quantity that oversells it.
- **`server/routes/checkout.ts`** reads price, in cents, from the database for every
  id the client sent. Skip that read for a "quick" optimisation and a client can
  name its own price.
- **`server/routes/webhook.ts`** is the only place `paid`, `refunded` and cancellation
  states are written, and it deduplicates by event id. Add a new webhook handler
  here, not a new payment-state write anywhere else.
- **Anything under `db/`**: both dialect schemas edited together, a migration for
  each, and `db/dialect.test.ts` green on both engines.

### 4. Theming versus forking

The theme editor changes tokens. Forking changes components. A forked component
that hardcodes a colour renders correctly until a merchant opens the theme editor,
then quietly stops matching the rest of the store. [Theming](/building/theming/)
has the token list; [Restyle the storefront](/tutorials/restyle-the-storefront/)
walks through doing both.


---

# Theming
<https://belugajs.com/building/theming/>

_Theme tokens versus forking components. What the editor sets, where it lands, and how to write a component that keeps up with it._

### What the editor sets

**Settings → Look** holds the theme: a primary colour, an accent, an optional page
background, light or dark scheme, a corner radius from 0 to 4 px, a logo that
replaces the wordmark, a font stack, and a font stylesheet URL. All of it is one
`theme` object on the store, validated by `themeSchema` in `shared/schema.ts`, and
sent to every shopper in `/api/store`.

The radius is capped at 4 on purpose. Past that the storefront stops reading as a
shop and starts reading as a dashboard, and the range 4 to 24 was almost entirely
occupied by looks no shop wanted.

There is one scheme per store, not a per-viewer toggle. A shop's look is the same in
every screenshot anyone takes of it.

### Where it lands

`ThemeVars` in the storefront injects the saved theme onto `:root` as custom
properties. `src/lib/theme.ts` holds both base palettes and derives the rest:
`--beluga-on-primary` and `--beluga-on-accent` are picked by WCAG contrast against
the chosen colour, so white or near-black text is chosen for you.

| Token | Meaning |
| --- | --- |
| `--beluga-ink` | Body text |
| `--beluga-muted` | Secondary text |
| `--beluga-line` | Hairlines and borders |
| `--beluga-surface` | Cards, panels |
| `--beluga-page` | Page background |
| `--beluga-primary` / `--beluga-on-primary` | Buttons and the text on them |
| `--beluga-accent` / `--beluga-on-accent` | Highlights, sale badges |
| `--beluga-radius` | Corner radius |
| `--beluga-gutter` / `--beluga-measure` | Layout rhythm and line length |

`src/index.css` carries the light values as a **first-paint fallback only**. The
dark palette lives only in `theme.ts`, so there is nowhere for it to drift.
`src/lib/theme.test.ts` checks the dark palette's own contrast as arithmetic,
because no browser in CI renders it.

### Web fonts and the CSP

A font stack alone only renders on a machine that already has the face installed.
**Font stylesheet URL** holds the stylesheet that defines the faces: the Google
Fonts `css2?family=…` href, or a self-hosted sheet under `/assets/`.

The store's Content-Security-Policy allows stylesheets and fonts from `'self'`
only, so before this field a Google Fonts `<link>` added by hand was refused by the
browser with nothing on screen to say why. Saving a URL widens the policy by
exactly the origins that stylesheet needs: Beluga fetches the sheet once and reads
them out of it, which is how `fonts.gstatic.com` gets allowed. A URL that cannot be
fetched is refused at save. Clearing the field puts the header back exactly as it
was. The preview cannot show a typeface you have not saved yet, because the policy
naming it is the one the store is currently serving.

### Writing a component that keeps up

```css
.card {
  background: var(--beluga-surface);
  color: var(--beluga-ink);
  border: 1px solid var(--beluga-line);
  border-radius: var(--beluga-radius);
}
.card button {
  background: var(--beluga-primary);
  color: var(--beluga-on-primary);
}
```

That is the whole rule. A component styled this way renders correctly today and
still matches after a merchant changes the palette, switches to dark, or sets a
radius. One that hardcodes `#fff` does not.

The admin has its own theme in `src/admin/adminTheme.ts`, deliberately not the
store's. Its blue is meant to look like a tool, not a shop.

### When to fork instead

The editor cannot change layout, typography scale, or what a section contains.
For that, replace the component. [The seams](/building/seams/) says which ones
are safe, and [Restyle the storefront](/tutorials/restyle-the-storefront/) does it.


---

# Adding a route
<https://belugajs.com/building/adding-a-route/>

_The thin-route convention, the router-wide auth and CSRF, the error helpers, and the security-test registration that is not optional._

### Where it goes

Admin routes go on `adminRouter` in `server/routes/admin.ts`. `requireAdmin` and
`verifyCsrf` are applied once to the whole router, so anything mounted on it is
protected by construction and cannot be added unprotected by accident. **Never
mount an admin endpoint anywhere else.**

Public reads go on `publicRouter` in `server/routes/public.ts`. Nothing there
authenticates or writes, and nothing there may expose a secret; the store payload
carries the publishable Stripe key, which is public by design, and never the
secret key.

Customer-facing writes (cart sync, account) have their own routers with
`requireCustomer`. A customer session sets `req.session.customerId`, never
`adminId`, so `requireAdmin` refuses it exactly like an anonymous request.

### The shape of a route

Parse, call a repository function, respond.

```ts
adminRouter.put("/products/:id/subtitle", async (req, res) => {
  const parsed = subtitleInputSchema.safeParse(req.body);
  if (!parsed.success) throw httpError(400, "That subtitle could not be read.");

  const product = await updateProductSubtitle(req.params.id, parsed.data.subtitle);
  if (!product) throw httpError(404, "Product not found.");

  res.json(product);
});
```

- The input schema lives in `shared/api.ts`, never inline. It is imported by the
  admin too, so the form validates the same way the server does.
- All SQL lives in `db/*-repository.ts`. A route never touches Drizzle.
- Errors use `httpError(status, message)` from `server/middleware.ts`, and
  `toHttp(error)` maps known error types. Messages are user-facing: say what went
  wrong and what to do.
- Throwing inside an async handler is fine; Express 5 forwards rejections to the
  error handler.

### Rate limits

`loginRateLimit`, `emailRateLimit`, `writeRateLimit` and `setupRateLimit` exist in
`server/middleware.ts`. Anything that sends email or checks a password takes one.

### The client side

Mutations go through `csrfPost`, `csrfPut` and `csrfDelete` from `src/lib/api.ts`,
which fetch the session's CSRF token once and send it as `x-csrf-token`. Wrap the
call in a hook in `src/admin/queries.ts` that invalidates both its own query key
and the public store key, so the storefront preview updates.

### Register it in the security test

Add the route to `MUTATIONS` or `READS` in `server/security.test.ts`. That test
asserts, for every admin route, that an anonymous request gets 401, a customer
session gets 401, and a mutation without a CSRF token is refused. **This is not
optional**; it is what stops an unprotected endpoint shipping.

### If it sends money anywhere

It does not. Prices come from the database, Stripe is written to only by publish
and refund, and payment state is written only by the webhook. If the route you are
adding wants to do any of those things, read [the invariants](/start/invariants/)
first and then put the logic in the place that already does it.

[Adding a field](/tutorials/adding-a-field/) is the companion walkthrough.


---

# The API
<https://belugajs.com/building/api/>

_The route surface, how authentication and CSRF work, the public store snapshot and its cap._

The API is Express 5 under `/api`. Everything returns JSON except the CSV exports,
the Stripe webhook, `/sitemap.xml` and `/robots.txt`. Money is integer cents in
every field, named `*Cents`.

### Authentication

- **Admin**: a session cookie set by `POST /api/session`, 24 hours, `SameSite=Lax`,
  `Secure` in production. `GET /api/session` returns `{ isAdmin, csrfToken,
  isConfigured }`. Every mutation sends the token as `x-csrf-token`.
- **Customer**: a separate session on `/api/account/session`, with the same CSRF
  rule. A customer session is refused by every admin route.
- **Storefront password**: when visibility is `password`, everything below the
  gate returns 401 until `POST /api/storefront/unlock` succeeds. The admin's own
  session always passes.

The login route answers identically for a known and an unknown email, and hashes a
decoy password so the two branches cost the same.

### Public

| Route | Returns |
| --- | --- |
| `GET /api/store` | The whole store snapshot: settings, theme, hero, collections, page summaries, live products. Capped at `STORE_SNAPSHOT_LIMIT` (200) products. 503 with `needsSetup: true` before setup; 401 when locked. |
| `GET /api/collections` | Collections with rendered introductions. |
| `GET /api/products?collection=&search=&limit=&offset=` | Paginated live products, for catalogues too large for the snapshot. |
| `GET /api/products/:slug` | One live product. |
| `GET /api/pages`, `GET /api/pages/:slug` | Published pages; bodies rendered from Markdown on the server. |
| `POST /api/shipping/quote` | Rates for a cart and a destination country. |
| `POST /api/checkout` | Creates a Stripe Checkout Session from `{ lines: [{ productId, variantId, quantity, options }], shipToCountry }`. Returns the redirect URL. |
| `GET /api/checkout/:sessionId` | Order status for the confirmation page. |
| `POST /api/cart/sync`, `/recover`, `/unsubscribe` | Abandoned-cart machinery. |
| `POST /api/storefront/unlock` | Storefront password or share-link token. |
| `POST /api/webhooks/stripe` | Stripe's inbound webhook. Raw body, signature-verified. |
| `GET /sitemap.xml`, `GET /robots.txt` | Live products and collections; empty when locked. |

### Account

`/api/account/register`, `/verify`, `/session` (POST and DELETE),
`/password/forgot`, `/password/reset`, and under `/api/account/me`: the profile,
`/orders`, `/orders/:id`, `/addresses` with create, update and delete.

### Session and setup

`GET | POST | DELETE /api/session`, `POST /api/invites/accept`,
`POST /api/session/forgot-password`, `POST /api/session/reset-password`,
`GET | POST /api/setup`.

### Admin

All under `/api/admin`, all behind the session and CSRF.

| Area | Routes |
| --- | --- |
| Environment | `GET /environment`, `POST /email/test` |
| Products | `GET /products`, `GET /products/full`, `GET /products/:slug`, `POST /products`, `PUT /products/:id`, `DELETE /products/:id`, `POST /products/reorder`, `POST /products/:id/publish` |
| Product images | `POST /products/:id/images`, `PUT /products/:id/images` (alt and variant), `DELETE /products/:id/images`, `POST /products/:id/images/reorder` |
| Catalogue CSV | `GET /products.csv`, `POST /products/import/validate`, `POST /products/import/commit` |
| Collections | `GET`, `POST`, `PUT /:id`, `DELETE /:id`, `POST /:id/cover`, `POST /reorder` |
| Pages | `GET`, `POST`, `PUT /:id`, `DELETE /:id`, `POST /preview`, `POST /reorder` |
| Settings | `GET /settings`, `PUT /settings`, `POST /settings/logo`, `POST /settings/hero-image` |
| Storefront | `GET | PUT /storefront`, `PUT | DELETE /storefront/password`, `POST | DELETE /storefront/share-link` |
| Shipping | `GET | PUT /shipping` |
| Orders | `GET /orders`, `GET /orders.csv`, `GET /orders/:id`, `PUT /orders/:id`, `POST /orders/:id/refund` |
| Staff | `GET /users`, `POST /users` (invite), `DELETE /users/:id`, `DELETE /users/invites/:id`, `PUT /users/me/password` |
| Webhooks | `GET`, `POST`, `PUT /:id`, `POST /:id/secret`, `DELETE /:id`, `GET /:id/deliveries`, `POST /deliveries/:id/redeliver` |

Every one of these is listed in `server/security.test.ts`, which is what
guarantees the table above is complete and protected.

### Shapes

Request and response shapes are the zod schemas in `shared/schema.ts`,
`shared/api.ts`, `shared/orders.ts`, `shared/shipping.ts` and `shared/webhooks.ts`.
They are the documentation; a shape that is not in `shared/` is not part of the
contract.

### Scripting it

There is no API key. A script signs in with an administrator's email and password,
keeps the cookie, fetches the CSRF token, and sends it on every write.
[Scripting the admin API](/examples/admin-api-script/) is a working example.


---

# Conventions and contributing
<https://belugajs.com/building/conventions/>

_How work is organised in the repository, what a change has to satisfy, and the commands that prove it._

### Task briefs

Work is organised as briefs under `docs/tasks/`, one file per unit of work, written
to be handed to an agent cold: the files to open, the API surface to add, the tests
that must pass, and what is out of scope. Each opens with frontmatter that is the
single source of truth for status, and `npm run roadmap` generates `docs/roadmap.html`
from it. Understood-but-undecided work lives in `docs/gaps/`, deliberately outside
the numbering, so listing it does not claim a plan that does not exist.

`docs/tasks/README.md` holds [the invariants](/start/invariants/); read it before
any brief.

### Conventions to match

- **Validation lives in `shared/`** as zod schemas, imported by both sides of the
  wire. Input schemas go in `shared/api.ts` (or `shared/orders.ts` for order
  shapes), never inline in a route.
- **Routes stay thin.** Parse, call a repository function, respond. SQL lives in
  `db/*-repository.ts`.
- **Errors** use `httpError(status, message)`; messages are user-facing.
- **Client mutations** go through `csrfPost` / `csrfPut` / `csrfDelete`, wrapped in
  a hook in `src/admin/queries.ts` that invalidates its own key and the public
  store key.
- **Comments explain why, not what.** The codebase comments decisions and the
  mistakes they head off. Match that register.
- **Money is integer cents**, and CSV columns are `*_cents` for the same reason.

### Commands

```bash
npm run typecheck      # tsc -b
npm run lint           # eslint
npm test               # vitest: unit, component, and both database dialects
npm run test:e2e       # playwright
npm run db:generate    # after editing BOTH schema files
npm run db:migrate     # apply
```

### Things that look like bugs and are not

- **A fresh checkout has no store in it.** `npm test` seeds per suite, but
  `npm run test:e2e` drives the real app against `data/beluga.sqlite`. In a new
  clone or worktree the whole suite fails on a missing heading, which reads as a
  broken storefront and is an empty database. `npm run db:migrate && npm run db:seed`
  once per checkout.
- **`.env` is irrelevant to e2e.** Playwright points `ENV_FILE` at a file that
  does not exist, on purpose.
- **Postgres skips silently** in `db/dialect.test.ts` if `embedded-postgres`
  cannot start. Confirm with `--reporter=verbose` and look for
  `repository on postgres`.

### Definition of done

- `npm run typecheck && npm run lint && npm test` pass.
- New routes are in `MUTATIONS` or `READS` in `server/security.test.ts`.
- New schema fields are in both dialect files and both migration folders.
- The README section for the area is updated if behaviour changed.
- No `console.log` left behind except deliberate operator-facing lines.
- The brief's frontmatter says `done` with a date and a PR number, and
  `npm run roadmap` output is committed.

### Branching

The base branch is `main`. Branch from it, open pull requests against it, and
delete the branch when it lands. Several sessions may be working in the
repository at once, so always create a branch before starting.


---

# Products, variants and options
<https://belugajs.com/catalogue/products/>

_The product model, up to three priced axes, per-variant stock and images, and what publishing actually does._

### The model

A **product** has a slug, a name, a description, bullet points, a type (`physical`
or `digital`), images, an optional tax code, optional SEO overrides, and a live
flag. A draft is editable but absent from the storefront.

A product has one or more **variants**. Each variant is a separately priced,
separately stocked thing, and becomes a Stripe Price when the product is published.
A variant carries:

- `priceCents`, and an optional `compareAtPriceCents` shown struck through beside
  it with a Sale badge. Compare-at is display only, never sent to Stripe, and never
  what checkout charges.
- `inventory`: `infinite`, or `finite` with a quantity.
- `weightGrams`, for weight-banded shipping. Zero means "not recorded", which is
  fine for a flat-rate store and quietly matches the lightest band otherwise.
- An optional `sku`, unique across the catalogue when set. Beluga never looks a
  variant up by it; it exists for a warehouse or accounting system, and appears on
  order lines, the order CSV, the `order.paid` webhook, and as `metadata.sku` on the
  Stripe Price.
- `optionValues`: one value per axis, in order.

**Options** are the priced axes: **Size** with `Small` and `Large`, **Colour** with
`Blue` and `Red`, up to three. Every variant names exactly one value on every axis,
and the label `Large / Blue` is generated from them on every save. A product with no
options has one variant with an empty label.

**Option groups** are the other kind of choice: gift wrap, a monogram. They do not
affect price or stock, and the buyer's answers are recorded on the order line as
`options`. They are kept apart from the priced axes on purpose, because the two
are easy to confuse.

### Per-variant images

An image can be pinned to one variant instead of the whole product, so picking a
colour shows that colour's picture first. Removing the variant does not delete the
image; it falls back to the whole product.

### The editor

One autosaving form. The draft is local, it saves itself to Beluga's database, and
**Publish** is the only thing that ever writes to Stripe, so abandoning an edit
halfway leaves nothing orphaned in a live account.

Display order is edited with buttons rather than drag-and-drop, because it is real
persisted data and should be editable from a phone or a keyboard.

### Publishing

`POST /api/admin/products/:id/publish` creates or updates the Stripe Product and one
Price per variant, and records the ids on the rows. Until a product is published, a
live product shows in the storefront but cannot be bought; the checkout refuses it
with a plain message to the shopper and a reason in the log, and the Overview lists
it.

Two rules follow from Stripe:

- **Prices are immutable.** Changing an amount mints a new Price and archives the
  old one, which is why historic orders still resolve.
- **Test and live keys have separate catalogues.** Publishing under a test key puts
  nothing in the live account. [Going live](/tutorials/going-live/) republishes
  everything.

Tax behaviour is immutable on a Price too, so changing how the store quotes prices
reaches Stripe only when each product is published again. Nothing republishes
itself; the Overview lists what is stale.

### Inventory

Stock lives in Beluga, not Stripe. Checkout checks it when building the session
and again, authoritatively, when the webhook confirms payment. If stock ran out in
between, the order is recorded and flagged `oversold` rather than silently dropped,
because the money has been taken. A digital variant is always unlimited and the API
refuses a finite count on one.

`inventory.low` fires as an [outbound webhook](/integrating/webhooks/) when a sale
leaves a finite variant at five or fewer.

### Deleting

Deletes go by id, the API refuses an unknown one, and the dialog names what is about
to go. A miss is an error, never a different product.

### Related

[Images](/catalogue/images/), [Digital products](/catalogue/digital-products/),
[Catalogue CSV](/catalogue/csv/), [Tax](/money/tax/).


---

# Digital products
<https://belugajs.com/catalogue/digital-products/>

_What marking a product digital changes, and the delivery gap that is currently your job to close._

Every product is `physical` or `digital`, set by **Type** in the editor. Today the
flag is a modelling decision: it governs shipping and stock. The file itself, the
entitlement that grants access to it, and the download route are **not built**.

### What the flag changes

- **Shipping.** Digital lines are excluded from the parcel, not zeroed. A cart of
  only downloads reaches Stripe with no address collection and no shipping
  options; a mixed cart collects an address, priced on the physical lines. Both the
  weight and the subtotal a rate is matched against come from the physical lines
  only. [Shipping](/money/shipping/) explains why zeroing would be wrong.
- **Stock** is always unlimited. The API refuses a finite count, because the
  inventory decrement would otherwise count a file down to zero and start flagging
  paid orders `oversold`.
- **Webhooks.** `product.published` carries `kind`, so a fulfilment receiver can
  branch on it before raising a pick list.
- **Tax.** Digital goods carry different tax codes in many jurisdictions. Set one
  on the product if you use Stripe Tax.

### The gap

A merchant can mark a product digital, publish it, take money for it, and then has
no mechanism to give the buyer anything. The approach has not been chosen, so it
sits in `docs/gaps/digital-delivery.md` rather than as a numbered task.

What any solution has to handle, recorded there:

- Files must **not** live under the statically served assets directory, or anyone
  who guesses the path can download them. A separate, non-served location streamed
  through an authenticated route.
- An entitlement per order line: a hashed token, a download cap, an expiry, and a
  revocation on refund.
- Granted inside the webhook's idempotent path, delivered by a link in the order
  email.
- A `delivered` status, since `processing` and `shipped` mean nothing for a
  download.

### What to do today

Deliver it yourself over a webhook. Subscribe to `order.paid`, mint a signed URL for
the file that matches each line's `productId` and `variantId`, and email it to
`data.email`. Subscribe to `order.refunded` to revoke it.
[Sell a digital product](/tutorials/digital-product/) walks through exactly that.


---

# Collections
<https://belugajs.com/catalogue/collections/>

_Ordered groups of products with a Markdown introduction, and the one reserved slug._

A collection is a slug, a name, an optional cover image, an introduction, and an
ordered list of product ids. Collections appear in the header and footer, as tiles
on the landing page, and at `/collection/<slug>`, where the shop's search and sort
work within them.

### The introduction

Written in Markdown under **Collections** in the admin. It is rendered to HTML on
the server, on every read, through the same sanitiser as [pages](/catalogue/pages/),
and the storefront receives `descriptionHtml` and never the source. It appears
under the collection's heading and becomes the collection's search-result
description.

The admin receives the Markdown source, on a separate `collectionDraft` shape,
because an editor has to round-trip what the merchant typed.

### Ordering

Both the order of collections and the order of products within one are persisted
and edited with buttons. There is no drag-and-drop, for the reasons on the
[products page](/catalogue/products/).

### Featured products

The landing page's featured section is a normal collection with the reserved slug
`featured-products`. Create it, add products to it, and they appear. There is no
separate feature flag.

### Covers

A cover image goes through the same upload path as product images: re-encoded to
WebP, EXIF stripped, derivatives generated. See [Images](/catalogue/images/).

### In the API

`GET /api/collections` returns every collection with its rendered introduction.
`/api/store` includes them in the snapshot. `GET /api/products?collection=<slug>`
pages through a collection's products for a catalogue too large for the snapshot.


---

# Images
<https://belugajs.com/catalogue/images/>

_What an upload becomes, the derivative naming contract, where the files live, and the bucket driver._

### What an upload becomes

Every upload is re-encoded by `sharp`, which is what strips EXIF and anything
appended after the image data, and capped at 2400 px. Resized copies are written
alongside the original at 400, 800, 1200 and 1600 px, skipping any width at or above
the source so nothing is upscaled. The widths actually generated are recorded on
the row, so the storefront advertises only files that exist, and an image uploaded
before derivatives existed keeps working with a single `src`.

The effect is worth stating plainly: a thumbnail rendered 70 px wide downloads
2.3 kB instead of a 17 kB original.

`MAX_UPLOAD_BYTES` (20 MB) is the ceiling, with room for a camera original.

### The naming contract

`abc.webp` at 800 wide is `abc-800.webp`. The rule lives in `shared/images.ts`,
imported by both the server that writes the files and the storefront that names
them in `srcset`, because nothing type-checks that the two agree: a drift would be
a 404 per image rather than a compile error. `buildSrcSet` returns `null` for an
image with no derivatives, since `srcset=""` is not the same as no attribute.

### Alt text

Required on every image, so product imagery is never invisible to a screen reader.
The editor asks for it.

### Where the files live

The database stores a relative path, `products/abc.webp`, and the storefront always
asks for it at `/assets/<path>`. Where the bytes are is a driver behind the
`ImageStore` interface in `server/image-store.ts`:

- **Local**, the default. Written to and served from `ASSETS_DIR`
  (`public/assets`). Point it at a volume on any platform that rebuilds the
  filesystem on deploy, or every image vanishes with the next release.
- **S3-compatible**, when `ASSETS_S3_BUCKET` and its group are set: AWS,
  DigitalOcean Spaces, Cloudflare R2, Backblaze, MinIO. Under this driver the
  server answers `/assets/<path>` with a redirect to `ASSETS_PUBLIC_URL/<path>`,
  cached for a month, rather than teaching every URL builder a second base. The
  Content-Security-Policy is widened by that one origin at boot.

Switching drivers is configuration, not a data migration, because the stored path
is the same under both. Moving an existing directory into a bucket is task 28,
`npm run assets:migrate`, not yet built; until then, copy the tree into the bucket
with the provider's tool, keeping the same keys.

A half-configured bucket group is refused at boot with the missing variables named.
The boot log says which driver is active next to the port. The bundled demo images
stay on disk under either driver.

### The seam

A third backend implements `put`, `delete` and `publicUrl`. The traversal guards
apply under every driver: a key of `../x` is refused before it reaches the store.


---

# Pages
<https://belugajs.com/catalogue/pages/>

_Prose the catalogue does not hold, written in Markdown, rendered on the server, and always reachable from the footer._

A store needs prose the catalogue does not hold: a returns policy, shipping
information, contact terms. Consumer-protection rules in several jurisdictions and
Stripe's own account requirements expect a shop to publish them.

**Pages** in the admin writes them. Each has a title, a slug, and a body in
**Markdown**. A page is a draft until it is published, and can optionally be linked
in the storefront menu.

### Every published page is in the footer

The menu flag decides whether a page is also in the header. It does not decide
whether the page can be found. A returns policy left out of the menu is still one
link from every page of the shop, which is the point of publishing it. Keep that
behaviour if you rewrite the footer.

### Rendered on the server, on every read

Bodies are stored as Markdown and rendered to HTML on the server every time they
are read, never stored as HTML. Two things follow. Tightening the sanitiser applies
retroactively to every page already written. And no Markdown parser reaches a
shopper's bundle, which is why the admin's preview asks the server to render it
(`POST /api/admin/pages/preview`): a second implementation in the browser would
eventually disagree with the first about what is safe.

The allow-list is prose and nothing else: headings, paragraphs, lists, links,
emphasis, code, quotes, rules. No scripts, no styles, no frames, no event handlers.
External links carry `rel="nofollow noopener noreferrer"`.

### Reserved slugs

`shop`, `cart`, `confirm`, `product`, `collection`, `about`, `admin`, `setup` and
`account` are refused with a message naming the conflict. A page is served from
`/:slug`, registered last so it cannot shadow a static route, but a page at `/cart`
would simply never render, with nothing to say why.

### About

An existing store's `aboutText` became an About page the first time the pages
migration ran. The column is deprecated and stays for one release so an install can
roll back.

### In the API

`/api/store` carries page **summaries** only, since the header needs titles on first
paint and a store with ten long policies should not put all of that in every
shopper's initial payload. `GET /api/pages/:slug` fetches a body.


---

# Search and sort
<https://belugajs.com/catalogue/search/>

_Client-side against the snapshot, and the seam to swap it for the server when a catalogue outgrows the cap._

The shop and collection pages have a search box and a sort control, backed by
`searchProducts` and `sortProducts` in `shared/catalog.ts`. Matching is case- and
diacritic-insensitive across name, description and bullet points, and every typed
term has to match: "blue tote" returns blue totes, not everything blue.

### It filters client-side

Against the catalogue the storefront already loaded from `/api/store`. That is
instant, costs no request, and works against the bundled fixture with
`VITE_BELUGA_API=false`.

`?q=` and `?sort=` live in the URL, so a result is shareable, survives a reload, and
the back button undoes a search rather than one keystroke.

### When the catalogue outgrows the snapshot

`/api/store` is capped at `STORE_SNAPSHOT_LIMIT`, 200 live products. Past that, the
swap is to `GET /api/products?search=&collection=&limit=&offset=`, which already
exists and applies the same matching on the server, behind
`src/lib/store-source.ts`, the same seam that absorbed the fixture-to-database
change. No component needs to know.


---

# Catalogue CSV
<https://belugajs.com/catalogue/csv/>

_Export the whole catalogue one row per variant, edit it, and import it back through a validate-then-commit flow that reports every error at once._

`GET /api/admin/products.csv` writes the whole catalogue, drafts included, **one
row per variant** with the product's own fields repeated across its rows. That is
the shape Shopify exports, so the two files can be diffed. **Export CSV** and
**Import CSV** on the Products screen are the same thing with a preview attached.

### Columns

`slug`, `name`, `kind`, `description`, `bullet_points`, `seo_title`,
`seo_description`, `tax_code`, `option1_name` / `option1_value` through
`option3_*`, `variant_sku`, `variant_price_cents`, `variant_compare_at_price_cents`,
`variant_inventory_type`, `variant_inventory_quantity`, `variant_weight_grams`,
`is_live`, `image_paths`, `variant_image_paths`.

Lists inside one cell, bullet points and image paths, are `|`-separated, because
the comma is taken. Prices are integer cents, and a decimal in a `*_cents` column is
refused by name rather than rounded. [An example file](/examples/catalogue-csv/).

### Importing is two requests

```
POST /api/admin/products/import/validate  → { rows, creates, updates, errors[], products[] }
POST /api/admin/products/import/commit    → { created, updated, skipped }
```

Both take the file as the request body with `Content-Type: text/csv`, parsed as a
stream; the caps are 5 MB and 5,000 rows. **Validation writes nothing and reports
every error at once**, each with its row and column. A merchant fixing a 500-row
file one error per attempt gives up. If anything fails, the whole file is refused
unless `?skipInvalid=true`, which the preview offers and defaults to off; skipping
is per product, since half a variant matrix is not a product. The commit
re-parses and re-validates rather than trusting a token from the preview.

### Before importing over a live catalogue

- **Products are matched by `slug`.** Present is an update, absent is a create.
- **A column the file omits leaves the stored value alone.** A three-column price
  list will not blank every description. A column that is present but empty *does*
  clear the field.
- **A variant keeps its id** when its SKU matches an existing variant's, or, for a
  variant with no SKU, when its option values still match, so an update does not
  orphan the Stripe Price behind it. An import refuses to collapse a product's
  options by leaving their columns out.
- **An import never writes to Stripe.** Imported products land as drafts unless
  `is_live` says otherwise, and even a live one is not published until someone
  publishes it.
- **Images and option groups are not managed by the file.** `image_paths` and
  `variant_image_paths` are written on export and ignored on import.

### Spreadsheet safety

Fields whose first character is `=`, `+`, `-` or `@` are prefixed with an apostrophe
on export, because a product named `=HYPERLINK(...)` is a live formula the moment
the file opens in Excel. The file starts with a UTF-8 BOM so Excel reads accented
names correctly.


---

# Stripe setup
<https://belugajs.com/money/stripe/>

_Checkout Sessions on Stripe's hosted page. What Beluga keeps in Stripe, what it keeps at home, and the two keys._

Beluga uses Stripe **Checkout Sessions**: the hosted page owns the card fields, 3-D
Secure, wallets and address collection, which keeps a Beluga store at PCI SAQ-A.

### What lives where

| In Stripe | In Beluga |
| --- | --- |
| A Product per published product, a Price per variant | The catalogue, drafts included, and every image |
| Payments, refunds, disputes | Orders, fulfilment status, tracking |
| Promotion codes and coupons | The discount amount an order used |
| Tax registrations, rates, calculation | Whether tax is on, and how prices are quoted |
| Customers, if you enable them | Customer accounts and address books |
| | Inventory. Stripe Prices have no stock concept. |

### Keys

Three environment variables. `STRIPE_SECRET_KEY` is server-only and never reaches
a browser. `STRIPE_PUBLISHABLE_KEY` is public by design and is served to the
storefront through `/api/store`. `STRIPE_WEBHOOK_SECRET` verifies inbound events.

`npm run setup` validates the secret key against Stripe before storing it and says
plainly whether it is a live one. A store without any key browses and carts but
cannot take money, and the admin says so.

**Test and live keys have separate catalogues.** Everything published under a test
key exists only in the test account. [Going live](/tutorials/going-live/) covers
the swap.

### Publishing

Products reach Stripe only when explicitly published. Saving a product never
writes to Stripe. Publishing creates or updates a Product and one Price per variant,
with the store's tax behaviour and the product's tax code, and records the ids.

Prices are immutable in Stripe. Changing an amount, or the tax behaviour, mints a
new Price and archives the old one; historic orders keep resolving against the
archived one. Nothing republishes itself.

### Webhooks

Locally, forward events with the CLI and put the printed secret in `.env`:

```bash
stripe listen --forward-to localhost:4000/api/webhooks/stripe
```

Deployed, create an endpoint in the dashboard at
`https://your-store/api/webhooks/stripe` for `checkout.session.completed`,
`checkout.session.expired` and `charge.refunded`. Restart the API after setting
the secret. [Stripe webhooks (inbound)](/integrating/stripe-webhooks/) has what
each one does.

### What Beluga deliberately does not do with Stripe

- No `ui_mode: 'elements'`. Owning the checkout page would put card fields,
  3-D Secure and PCI scope back on your server. This is what rules out live
  carrier rates; see [Shipping](/money/shipping/).
- No Stripe Shipping Rates objects. Beluga's rates are passed inline on the
  session, so there is nothing to keep in sync.
- No Stripe-side inventory. There is no such thing.
- No subscriptions. One-off payments only.


---

# Checkout
<https://belugajs.com/money/checkout/>

_What happens between the cart and the confirmation page, and the three rules the code holds to._

### The sequence

1. The cart holds `{ productId, variantId, quantity, options }` per line and a
   destination country, and asks `POST /api/shipping/quote` for the rates that
   apply. Nothing displayable is stored; every price on the cart page is re-derived
   from the current catalogue on render.
2. Checkout posts the same lines to `POST /api/checkout`. The server loads every
   referenced product from the live catalogue, reads each variant's price from the
   database, checks stock, refuses anything not published to Stripe, sums the
   subtotal, resolves shipping again from the same function the cart used, and
   creates a Checkout Session with `allow_promotion_codes`, address collection if
   any line is physical, `automatic_tax` if tax is on, and the store's `PUBLIC_URL`
   as success and cancel targets.
3. The shopper pays on Stripe's page.
4. Stripe redirects to `/confirm?session_id=…`. The page polls
   `GET /api/checkout/:sessionId` and shows the order once its status is `paid`.
5. Stripe's webhook delivers `checkout.session.completed`. The handler records the
   order, decrements inventory, links it to a verified customer account if one
   matches, sends the confirmation email, and queues an `order.paid` outbound
   event.

Step 5 can arrive before or after step 4. The confirmation page copes with either.

### Three rules

- **Line items are built server-side from stored price ids.** The client sends
  identifiers and quantities and never a price, so a tampered cart cannot change
  what anything costs. `compareAtPriceCents` never enters the calculation.
- **The webhook is the only thing that marks an order paid.** The success redirect
  proves nothing: a buyer can close the tab, and the URL can be visited directly.
- **Webhook delivery is at-least-once**, so events are deduplicated by id, and a
  failed handler releases the dedup record so Stripe's retry is processed rather
  than dismissed.

### Stock

Checked when the session is built and again, authoritatively, when payment is
confirmed. If it ran out in between, the money has been taken, so the order is
recorded and flagged `oversold` for the merchant rather than dropped. The quantity
control in the storefront clamps against stock on every change, including a
re-clamp when the variant changes, so the shopper finds out before payment rather
than at it.

### Shipping and address collection

The cart asks for the destination country because hosted Checkout collects the
address *after* the session exists, and a zone-priced store has to know the
destination before then. The session is then restricted to that country, so a
buyer cannot hold a domestic rate against an international address. A cart with
no physical line collects no address at all. [Shipping](/money/shipping/).

### Country list

With no zones configured, Stripe is offered a small default list. With zones, the
countries the zones name, plus every Stripe-shippable country if there is a
catch-all zone.

### Guest and signed-in

Guest checkout is the default and nothing forces an account. The cart offers
signing in and prefills the shipping country from a signed-in buyer's default
address. A guest order under an email that belongs to a verified account is linked
to that account by the webhook, never to an unverified one.

### Testing

`stripe listen`, the printed secret in `.env`, an API restart, and card
`4242 4242 4242 4242`. Replaying a delivered event with `stripe events resend`
must not move stock a second time.


---

# Shipping
<https://belugajs.com/money/shipping/>

_Zones and rates bounded by weight and subtotal, resolved on the server. The three ways it fails silently, and why the cart asks for a country._

Rates a store configures, matched against destination, parcel weight and order
subtotal. No carrier account, and Stripe's hosted checkout is untouched.

### The model

**Zones** group countries. A zone naming no countries is the catch-all, so a store
can price the world without listing it. Explicit listings win over the catch-all
regardless of position, so adding "rest of world" can never quietly capture a
country a specific zone already prices.

**Rates** hang off zones, optionally, and are bounded by parcel weight and parcel
subtotal, inclusively at both ends, with `null` meaning unbounded. "Free over $50"
is a rate with `minSubtotalCents: 5000` and a price of zero. "Heavy parcels cost
more" is two rates with adjacent weight bands. Each rate carries its own tax
behaviour, because postage is taxable in some jurisdictions and not others.

Two behaviours to state up front:

- **An unpinned rate applies everywhere.** A flat-rate store needs one rate and no
  zones at all.
- **Every matching rate is offered, cheapest first.** Not one winner. That is what a
  standard-versus-express pair needs.

Resolution is one pure function, `resolveShippingRates` in `shared/shipping.ts`,
called by both the cart quote and the checkout route so the two cannot disagree.
The rates are passed inline on the Checkout Session; there are no Stripe Shipping
Rate objects to keep in sync.

### Why the cart asks for a country

It looks like a UX mistake until you know why. Hosted Checkout collects the address
*after* the session exists, so a zone-priced store has to know the destination
before it can price postage. The session is then restricted to that country, so a
buyer cannot keep a domestic rate on an international address: the line-item
price-integrity rule, applied to postage. Anyone who reads the cart and thinks
"I'll move this to the Stripe page" needs to hit this paragraph first.

### Three silent failures

`findCoverageGaps` exists because these are invisible. Treat the admin's warnings
as a routine check, not a curiosity.

1. **A coverage gap ships free.** When no rate matches, the buyer is offered
   nothing and pays no postage. The order still completes. Deliberate, since
   inventing a price would be worse, but it means a misconfiguration is discovered
   when a parcel arrives with no postage on it. The Overview warns when a store
   has live physical products and no rates, and when a zone covers a country no
   rate can price.
2. **No recorded weights means a 0 g parcel**, which matches the lightest weight
   band. Weight bounds configured before variant weights are filled in do not fail
   closed; everything quietly qualifies for the cheapest band.
3. **Subtotal bounds count physical lines only.** A $40 download does not push a
   $10 box over a free-shipping threshold. On an upper bound it is worse: without
   this rule a digital-heavy cart sails past every band's ceiling, matches nothing,
   and ships free.

### Digital lines are excluded, not zeroed

Giving a download `weightGrams: 0` looks equivalent and is not. A zero-gram line
still participates, so a cart of nothing but PDFs would report a 0 g parcel, match
the lightest band, and charge the buyer postage on a parcel that does not exist.
`physicalLines`, `requiresShipping` and `parcelFor` are the API; a cart with no
physical line collects no address at all, and a mixed cart is priced on its
physical lines. The reasoning generalises to anything anyone adds later.

### What was deliberately not built

Live carrier rates, address validation, label purchase, tracking. Live rates need
`ui_mode: 'elements'`, which means Beluga owning the checkout page, and with it the
card fields and PCI scope that Stripe's hosted page keeps off your server. The trade, the provider
evaluation (Shippo, EasyPost, Easyship), and a middle path that quotes on the cart
page are in `docs/shipping.md` in the repository. Treat it as a payments phase
with a shipping payload, not an increment on this.


---

# Tax
<https://belugajs.com/money/tax/>

_Off by default, calculated by Stripe Tax when on, and silent when under-collecting. Three things must be true in Stripe first._

Tax is **off by default** and calculated by **Stripe Tax** when it is on. Beluga does
no tax arithmetic of its own, holds no rate tables, and files nothing on anyone's
behalf.

### Why the default is off, and why that is dangerous

Under-collecting is silent. Every order goes through, the buyer pays, and the
merchant owes the difference with nothing anywhere to say so. So the admin Overview
says "this store is not collecting tax" while it is off, and Settings → Tax says the
following before the switch.

### Three things that have to be true in Stripe

None of them can be done from Beluga.

1. **Stripe Tax is activated** on the account. It is a paid add-on, billed per
   transaction.
2. **Tax registrations are recorded** in the dashboard, one for each place the
   merchant is obliged to collect. Deciding where that is remains the merchant's
   job; Stripe collects nothing for a jurisdiction with no registration.
3. **Products carry tax codes.** Every product uses the store default,
   `txcd_99999999` (general tangible goods), unless it sets its own. Books, food,
   digital goods and clothing are taxed differently in many places.

### Inclusive or exclusive

Prices are quoted inclusive or exclusive, per store. EU and UK shops normally quote
inclusive prices; US shops quote exclusive and add tax at checkout. Shipping rates
carry their own behaviour. With inclusive pricing the tax line reads "Includes tax"
rather than adding a row, because an additive-looking row on a total that already
contains the tax reads as a second charge.

`tax_behavior` is **immutable on a Stripe Price**, like the amount. Changing how a
store quotes prices reaches Stripe only when each product is published again,
which mints new Prices and archives the old ones. Nothing republishes itself. The
Overview lists the products that are out of date and links to each one.

### What gets recorded

The webhook reads `total_details.amount_tax` off the completed session into
`taxCents` on the order, which the confirmation page, the admin, the email, the
CSV export and the `order.paid` webhook all carry.


---

# Discount codes
<https://belugajs.com/money/discounts/>

_Created and managed in the Stripe dashboard, entered on Stripe's page, recorded by Beluga._

Discount codes are created and managed **in the Stripe dashboard**, not in Beluga.
Checkout sets `allow_promotion_codes`, so Stripe's hosted page owns the code field
and everything behind it: validation, expiry, usage caps, per-customer limits.

Beluga records what came off as `discountCents` on the order and shows it on the
confirmation page, the admin order, the confirmation email, the CSV export and the
`order.paid` webhook. It does not create, list or edit codes.

`discountCents` is stored, not subtracted: `subtotalCents` is Stripe's pre-discount
figure and `totalCents` its post-discount one, so deducting it again would double it.

### Why

Owning codes in Beluga would mean owning validation, races on usage caps, and
coupon synchronisation. This way the feature is complete and correct on day one.

Cart-condition discounts ("10% off orders over $50") are not supported, because
computing them Beluga-side would break the rule that prices only ever come from
the database. Stripe's own "minimum order amount" restriction on a promotion code
covers the common case.


---

# Orders and fulfilment
<https://belugajs.com/money/orders/>

_The order model, the statuses and what moves them, the emails each one sends, and the admin's view._

Stripe's Orders API is gone and has no server-side replacement, so an order is a
record Beluga owns. Stripe remains the authority on *payment*; everything about
fulfilment lives here.

### The model

An order has an id and a short `reference` (the first eight hex digits, uppercased,
which is what a customer quotes), the buyer's email, a status, currency, and
`subtotalCents`, `shippingCents`, `taxCents`, `discountCents`, `totalCents` and
`refundedCents`, all integer cents. It carries the shipping address Stripe
collected, an optional carrier and tracking number, an `oversold` flag, and a
timestamp.

Each **line** snapshots `productName`, `variantLabel`, `sku` and `unitPriceCents`
at purchase, so an order always renders as it was bought even after the product is
renamed, repriced or deleted. `productId` and `variantId` are nullable for that
reason. `options` holds the buyer's non-priced choices.

### Statuses

| Status | Set by | Email |
| --- | --- | --- |
| `pending` | Checkout Session created | |
| `paid` | The Stripe webhook, and nothing else | Ordered |
| `processing` | The admin | Processing |
| `shipped` | The admin, with carrier and tracking | Shipped |
| `refunded` | The `charge.refunded` webhook, once the whole charge is covered | Refunded |
| `cancelled` | The admin | |

`templateForStatus` in `server/email.ts` maps a status to a template; add a status
and an `.hbs` pair and it is picked up. Until `SMTP_URL` is set, each email is
logged rather than sent.

### Outbound events

`order.paid` on payment, `order.updated` on any fulfilment change including
carrier and tracking, `order.cancelled` on cancel (not `order.updated`, so you do
not have to diff), and `order.refunded` on any refund. See
[Outbound webhooks](/integrating/webhooks/).

### Oversold

Set when payment succeeded but stock ran out in the meantime. The money is taken,
so the order is recorded and flagged for the merchant rather than silently dropped.

### The admin

**Orders** lists and filters by status. An order's page shows the lines, the
address, the money breakdown, the fulfilment controls, and a refund button that
calls Stripe and stops there; see [Refunds and restocking](/money/refunds/).
`GET /api/admin/orders.csv` streams the lot; see [Order CSV export](/money/order-export/).

### Customers

A buyer retrieves a guest order by its unguessable session id from the
confirmation email. A [customer account](/accounts/customers/) sees its order
history under `/account`, and only orders under a verified email are linked there.


---

# Refunds and restocking
<https://belugajs.com/money/refunds/>

_A refund is asked of Stripe and recorded by Stripe's webhook. Full refunds and cancellations restock, once; partial refunds do not._

**Refunds follow the same rule as payment.** `POST /api/admin/orders/:id/refund`
calls Stripe and stops there. `refundedCents` and the order's status are written by
the `charge.refunded` webhook, which is where the money actually settles. The admin
shows the refund as pending until then.

Refunds are additive, so several partial refunds accumulate against one charge, and
the order only moves to `refunded` once the whole charge is covered. A partial
refund leaves fulfilment alone.

### Restocking

Stock is returned when an order is refunded in full or cancelled from the admin.
The restock is guarded by a `restocked_at` stamp claimed with a conditional update,
so several refund webhooks, or a merchant re-saving a cancelled order, cannot
inflate the catalogue.

**A partial refund does not restock.** It says nothing about which line came back.
If a partial refund is a returned item, adjust the variant's stock in the editor.

Digital lines have no stock and restock nothing. Revoking access is your
receiver's job; see [Sell a digital product](/tutorials/digital-product/).

### Outbound

`order.refunded` fires on every refund, partial or full. Compare `refundedCents`
with `totalCents` to tell them apart.

### Testing

Refund a test order from the admin with `stripe listen` running. The
`charge.refunded` event arrives, the status changes, stock moves once. Resend the
event and confirm stock does not move again.


---

# Order CSV export
<https://belugajs.com/money/order-export/>

_One row per order line, integer-cent columns, and formula-safe cells._

`GET /api/admin/orders.csv` streams orders as CSV, one row per order **line**, so the
file pivots: order-level fields repeat across an order's rows. `?status=`, `?from=`
and `?to=` (epoch milliseconds) narrow it, and there is a hard cap of 50,000 rows,
which is what the date range is for. The button is on the Orders screen.

### Money columns

Every money column is named `*_cents` and holds an integer, because a column of
dollars in a spreadsheet is how floating-point money gets back in. Divide by 100 in
the spreadsheet if you must, in a column you add.

### Spreadsheet safety

Fields whose first character is `=`, `+`, `-` or `@` are prefixed with an
apostrophe. A product named `=HYPERLINK(...)` is a live formula the moment the file
opens in Excel, and product names are merchant- and buyer-supplied. The file starts
with a UTF-8 BOM so Excel reads accented names correctly.

### Columns

Order id and reference, status, email, currency, the five money totals, refunded,
the shipping address fields, carrier, tracking, oversold, created at, and per line
the product and variant ids, names as bought, SKU, unit price, quantity, and the
buyer's options.


---

# Staff accounts
<https://belugajs.com/accounts/staff/>

_Single-use invitations, immediate session destruction on removal, password reset by email, and a role column that gates nothing yet._

**Staff** in the admin lists everyone who can sign in, invites colleagues, and
removes them. The admin was a single shared account until this existed: one
password for a two-person shop, and no way to revoke access when someone left.

### Invitations

Access is granted by a **single-use invitation**. Only a hash of the token is
stored, exactly as a password would be, and the raw token exists only in the
emailed link; it works once and expires after 72 hours. When SMTP is not
configured, the link is returned to the inviting admin to pass on, so a
self-hosted store without email can still add a colleague.

### Removal

Removing someone **destroys their sessions immediately** rather than waiting for a
cookie to expire, which is most of the point. You cannot remove your own account,
and you cannot remove the last owner; a store with no owner has nobody who can add
one back.

### Password reset

An administrator who forgets their password can reset it by email from
`/admin/login`: a single-use, hour-long token, and every session the account had is
destroyed on reset. When SMTP is not configured the link is logged rather than
sent, so a self-hosted store can still recover an account by reading the API's log.

Registration, login and reset answer identically for a known and an unknown email.

### Roles

`role` is recorded (`owner` or `staff`) but does not gate anything: every
administrator can do everything, and the UI says so. Gating it would multiply the
permission surface across every route and needs its own security-test matrix,
which is a separate decision. The column exists now so that decision is not also
a migration.

### Passwords

argon2id hashes in the database, never in a file the server rewrites at runtime.


---

# Customer accounts
<https://belugajs.com/accounts/customers/>

_A second, public-facing login for order history and an address book, held to the same posture as the admin's, with one gate that cannot be skipped._

Orders were guest-only until this existed: retrieved by an unguessable Stripe
session id, with no way back for a buyer who lost the confirmation email.
`customers` and `customer_addresses` add a second login under `/account` on the
storefront and `/api/account/*` on the API: sign in, register, order history, an
address book, password reset.

### A different surface, the same posture

A customer session sets `req.session.customerId`, never `adminId`, so `requireAdmin`
refuses it exactly like an anonymous request. `server/security.test.ts` asserts a
signed-in customer gets 401 on every admin route.

Registration, login and a password-reset request all answer identically for a
known and an unknown email. `createCustomer` hashes the password before it
discovers the email is taken, so the two branches cost about the same, not just
look the same.

### The gate that cannot be skipped

**Orders are only ever linked to an account after the email is verified.**
Registering creates the account immediately, so a new customer can sign in right
away, but claiming past guest orders under that address, and the address book that
comes with it, waits for the emailed verification link.

Skipping that gate would let anyone register with a stranger's email and read their
order history and shipping address. It is the sharpest edge in this feature. The
same gate applies when a guest checkout completes under an email that already
belongs to a verified account: the webhook links it there, never to an unverified
one.

### Guest checkout stays the default

Nothing in the cart forces an account. The cart page only *offers* signing in, and
prefills the shipping country from a signed-in buyer's default address.

### Tokens

Reset and verification tokens follow the staff-invite pattern: only a hash is
stored, single-use, and short-lived. An hour for a reset link, since it is a live
credential; a day for verification, since it is an onboarding step.

### In the API

`POST /api/account/register`, `/verify`, `/session`, `DELETE /session`,
`/password/forgot`, `/password/reset`, and under `/api/account/me` the profile,
orders, and addresses. Every write carries the session's CSRF token.


---

# Abandoned cart recovery
<https://belugajs.com/accounts/abandoned-carts/>

_Off by default, one reminder per cart, only for verified customers, sent under your own SMTP reputation._

**Off by default.** A merchant opts in under Settings, and the reminder goes out
under their own SMTP sending reputation. Beluga sends nothing on its own until this
is turned on.

### What can be reminded about

The cart is client-side identifiers only, so there is nothing server-side to
remind anyone about until a signed-in customer's cart is mirrored to the `carts`
table, debounced from the browser, only ever for a customer with an account. A
guest's cart never reaches the server before checkout; there is no address to
contact and nothing worth storing.

A customer with nothing touched in their cart for the configured delay (default
four hours) gets **exactly one** reminder, with a single-use, seven-day
`/cart?recover=<token>` link that repopulates the cart from the stored identifiers
and re-resolves every line against the live catalogue. Dropped, discontinued or
unpublished lines are simply not in the recovered cart, the same way an ordinary
cart hides them.

### The highest-intent signal

A `checkout.session.expired` webhook, a buyer who reached Stripe and did not pay,
salvages into the same machinery immediately rather than waiting for the delay. It
is behind the same opt-in: on an opted-out store the webhook stores no cart and
sends no mail.

### How it runs

There is no job runner. The reminder is sent by a `setInterval` in the API process,
the same shape as the session store's prune timer. What keeps two API instances
from sending two emails is not that timer but a conditional
`UPDATE … WHERE reminder_sent_at IS NULL` when claiming a cart: only the first of
two racing claims can win, so a duplicate tick costs a wasted query, never a
duplicate email.

Every reminder needs a verified, non-suppressed email. Unverified per the
[customer accounts](/accounts/customers/) gate, and suppressed the moment a customer
clicks the unsubscribe link every reminder carries.

### Deploy consequence

The timer lives in the process. A platform that stops the machine between requests
stops the reminders too; see [Deploy your store](/tutorials/deploy/).


---

# Outbound webhooks
<https://belugajs.com/integrating/webhooks/>

_Connect something. Beluga POSTs a signed event to a URL you own when something happens; this is the feature that stands in for an app ecosystem._

Beluga can POST to your own endpoints when something happens in the store. Wire up
a fulfilment provider, an accounting ledger, a Slack channel, a Zapier-style
connector, or internal alerting, without either side shipping code into the other's
process. There is no SDK, no app to register, nothing of yours running inside
Beluga. If you can serve an HTTPS endpoint, you can integrate.

### The events

| Event | Fires when | `data` |
| --- | --- | --- |
| `order.paid` | The Stripe webhook confirmed payment. Not a success page: the money is real. | Order |
| `order.updated` | Fulfilment status, carrier or tracking number changed | Order |
| `order.refunded` | A refund settled, partial or full. Compare `refundedCents` with `totalCents`. | Order |
| `order.cancelled` | An order was cancelled from the admin. Not also `order.updated`. | Order |
| `product.published` | A product was published to Stripe. Carries `kind`. | Product |
| `inventory.low` | A sale left a finite variant at five or fewer. Per variant, per sale, not latched. | Variant |

### Add an endpoint

**Admin → Webhooks → Add an endpoint.** A URL, the events you want, save.

You are shown the **signing secret exactly once**, on that screen. Copy it into
your receiver's environment there and then. Nothing reads it back. If you lose it,
**Roll secret** mints a new one, and the old one stops working the moment you roll,
so expect failed deliveries until the receiver has the new one.

The URL must be `https://` and its hostname must resolve to a **public** address,
checked at creation and again before every send, redirects included. Without that,
an endpoint pointed at `169.254.169.254` would turn admin access into a way to read
the host's cloud metadata out of the delivery log. For a receiver that genuinely
lives on localhost, `WEBHOOK_ALLOW_INSECURE_TARGETS=true` lifts both rules; leave it
off anywhere public.

### Delivery

Nothing is sent from a request handler. Events are queued and delivered by a
background pass every ten seconds, which keeps a slow subscriber from delaying
Beluga's response to Stripe; a delay there would trip Stripe's own retry and
re-enter the payment handler.

Delivery is **at-least-once**, retried on failure at 1 minute, 5, 25, 2 hours and
10 hours, then given up. An endpoint whose deliveries have given up five times
running is switched off, and the admin says so; re-enabling clears the count and
sends what is still queued. The endpoint's row expands into its recent deliveries,
with **Redeliver** on each.

### The request

An envelope, `{ id, type, created, data }`, with the id repeated in a
`beluga-event-id` header and a `beluga-signature` header of `t=<unix
seconds>,v1=<hmac>` over `${t}.${rawBody}`. The same shape as Stripe's, so if you
already verify Stripe's webhooks this is that code with a different header name.

Everything a receiver needs, with payload examples per event, verification in Node
and Python, the raw-body trap, local testing and a troubleshooting table, is on
[Build a receiver](/integrating/webhook-receiver/).
[Connect a fulfilment webhook](/tutorials/fulfilment-webhook/) is the tutorial.


---

# Build a receiver
<https://belugajs.com/integrating/webhook-receiver/>

_Every payload, signature verification in Node and Python, the raw-body gotcha, delivery semantics, local testing, and a troubleshooting table._

Beluga POSTs a signed JSON body to a URL you own whenever something happens in the
store. This is the reference for the receiving end. [Outbound webhooks](/integrating/webhooks/)
is the overview.

### 1. The request

```http
POST /your/endpoint HTTP/1.1
content-type: application/json
user-agent: Beluga-Webhooks/1
beluga-event-id: evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833
beluga-signature: t=1757336400,v1=6f2a…
```

Every body is the same envelope:

```json
{
  "id": "evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833",
  "type": "order.paid",
  "created": 1757336400,
  "data": { }
}
```

`id` matches the `beluga-event-id` header, so you can deduplicate without parsing
the body. `created` is unix **seconds**.

#### `order.paid`, `order.updated`, `order.refunded`, `order.cancelled`

```json
{
  "id": "evt_9a3f1c02-5d7e-4b18-9f2a-7c1e6b40d833",
  "type": "order.paid",
  "created": 1757336400,
  "data": {
    "id": "5f8c1e2a-9d43-4c7b-8a15-2e6f0b93d471",
    "reference": "5F8C1E2A",
    "email": "buyer@example.com",
    "status": "paid",
    "currency": "USD",
    "subtotalCents": 3400,
    "shippingCents": 600,
    "taxCents": 280,
    "discountCents": 0,
    "totalCents": 4280,
    "refundedCents": 0,
    "carrier": null,
    "trackingNumber": null,
    "oversold": false,
    "createdAt": 1757336388000,
    "shipping": {
      "name": "A Buyer",
      "line1": "1 Test Street",
      "line2": null,
      "city": "Marfa",
      "state": "TX",
      "postalCode": "79843",
      "country": "US"
    },
    "items": [
      {
        "productId": "demo-tote",
        "variantId": "demo-tote-s",
        "productName": "Canvas Tote",
        "variantLabel": "Small",
        "sku": "TOTE-S",
        "unitPriceCents": 3400,
        "quantity": 1,
        "options": { "monogram": "AB" }
      }
    ]
  }
}
```

**Every money field is an integer number of cents** and is named `*Cents` to say
so. `4280` is $42.80. `createdAt` is unix **milliseconds** (the order's timestamp);
the envelope's `created` is unix **seconds** (the event's). `reference` is what a
customer sees; `id` is what you key on. `productId` and `variantId` are nullable,
since the catalogue can change after a sale; the names and price are snapshots and
always present.

#### `product.published`

```json
{
  "id": "evt_1c4b…",
  "type": "product.published",
  "created": 1757336500,
  "data": {
    "id": "demo-tote",
    "slug": "canvas-tote",
    "name": "Canvas Tote",
    "kind": "physical",
    "isLive": true,
    "stripeProductId": "prod_QxYz123",
    "variants": [
      { "id": "demo-tote-s", "label": "Small", "priceCents": 3400 },
      { "id": "demo-tote-l", "label": "Large", "priceCents": 3900 }
    ]
  }
}
```

#### `inventory.low`

```json
{
  "id": "evt_7e91…",
  "type": "inventory.low",
  "created": 1757336400,
  "data": {
    "productId": "demo-tote",
    "productName": "Canvas Tote",
    "variantId": "demo-tote-s",
    "variantLabel": "Small",
    "remaining": 3,
    "threshold": 5
  }
}
```

#### What is never in a payload

No Stripe keys, no payment intent id, no session data, no password hashes, no
customer record. Payloads are built field by field rather than spread from a
database row, and a test asserts the serialised body contains none of those. If
you need something that is not here, fetch it through the admin API with your own
credentials.

#### Truncation

Bodies are capped at 64 KB. When an order exceeds that, `items` is emptied and a
top-level `"truncated": true` is added. Every identifier is still present, so treat
it as a signal to fetch the order rather than as data loss.

### 2. Verify the signature

**Before you trust anything in the body.** An unverified endpoint is a URL anyone
on the internet can POST fake orders to.

The `beluga-signature` header is `t=<unix seconds>,v1=<hex>`, where the hex is an
HMAC-SHA256 of `${t}.${rawBody}` under that endpoint's secret.

#### Node / Express

```js

const app = express();
const SECRET = process.env.BELUGA_WEBHOOK_SECRET;
const TOLERANCE_SECONDS = 5 * 60;

// express.raw, NOT express.json — see the gotcha below.
app.post("/hooks/beluga", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("beluga-signature") ?? "";
  const [tPart, vPart] = header.split(",");
  const timestamp = tPart?.slice(2);
  const provided = vPart?.slice(3);
  if (!timestamp || !provided) return res.status(400).end();

  // The timestamp is inside the signed material, so a captured body cannot be re-stamped.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
    return res.status(400).end();
  }

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest("hex");

  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).end();
  }

  const event = JSON.parse(req.body.toString("utf8"));

  // Answer first, work afterwards — see §3.
  res.json({ received: true });
  void handle(event);
});
```

:::danger[The gotcha that catches everyone]
You must HMAC the **raw bytes**. If a body parser has already turned the request
into an object, re-serialising it will not reproduce them, because key order and
whitespace differ, and every signature will fail. In Express that means
`express.raw({ type: "application/json" })` on this route, mounted *before* any
global `express.json()`. An assistant asked to "verify this webhook" will happily
produce code that verifies the re-serialised body; it passes in testing and fails
on the first payload with different key ordering.
:::

#### Python / Flask

```python
import hashlib, hmac, os, time
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["BELUGA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 5 * 60

@app.post("/hooks/beluga")
def beluga():
    header = request.headers.get("Beluga-Signature", "")
    try:
        t_part, v_part = header.split(",")
        timestamp, provided = t_part[2:], v_part[3:]
    except ValueError:
        return "", 400

    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
        return "", 400

    # request.get_data() is the raw body; request.json is not.
    expected = hmac.new(
        SECRET, f"{timestamp}.".encode() + request.get_data(), hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(provided, expected):
        return "", 400

    handle(request.get_json())
    return {"received": True}
```

### 3. Delivery semantics

**Answer fast, then do the work.** Beluga waits 10 seconds, then treats the
delivery as failed. Any `2xx` means accepted; anything else, a timeout, or a
connection error gets the retry schedule. Hand the event to a queue; do not ship a
parcel while Beluga is holding the socket open. Redirects are followed up to 3 hops,
each re-checked against the address rules.

**At-least-once, so make your handler idempotent.** You will see the same event
twice; a lost response looks identical to one that never arrived. Deduplicate on
the event id, which is stable across retries. Two subscribers to the same event
receive the same id, so key your dedup store per receiver. Ordering is not
guaranteed either; reconcile on the order's own `status`, not arrival order.

| Attempt | Sent after |
| --- | --- |
| 1 | immediately (within ~10 s of the event) |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 25 minutes |
| 5 | 2 hours |
| 6 | 10 hours |

After the sixth, the delivery is marked **Gave up** in the admin and can be
replayed with **Redeliver**. Five give-ups in a row disable the endpoint; fix the
receiver and flip it back on. Any success resets the count.

### 4. Testing locally

Your machine is not publicly reachable, which the address rules correctly refuse.

**A tunnel (recommended):** `ngrok http 3000`, and register its `https://` URL.
Real DNS, real TLS, real signatures, no configuration change in Beluga.

**The opt-out:** `WEBHOOK_ALLOW_INSECURE_TARGETS=true` allows plain `http://` and
private addresses. Off by default; leave it off anywhere public.

**Triggering a real event:** complete a test payment with `4242 4242 4242 4242`.
That produces a genuine `checkout.session.completed`, which emits `order.paid`.
Marking the order shipped gives you `order.updated`. **Redeliver** in the admin is
the fastest loop while debugging.

### 5. Troubleshooting

| Symptom | Almost always |
| --- | --- |
| Every signature fails | You hashed a re-serialised body. HMAC the raw bytes. |
| Signatures failed suddenly, after working | Someone rolled the secret. |
| Deliveries show *no response* | Your endpoint took longer than 10 s, or the connection failed. Answer first, work after. |
| Endpoint rejected on save | The hostname resolves to a private address, or the URL is not `https://`. |
| Nothing arrives at all | The endpoint is disabled or not subscribed to that event type. Both are on its row. |
| The same order processed twice | Your handler is not idempotent. Deduplicate on the event id. |
| Totals are 100× too big | You read `*Cents` as a currency amount. `4280` is $42.80. |
| `items` is empty on a large order | The 64 KB cap; check for `"truncated": true` and fetch the order. |

### 6. What this is not

There is no write API; webhooks tell you what happened, and the admin API with your
own session is how you push changes back. There is no app install flow, no OAuth,
no per-app scopes. Delivery is neither ordered nor exactly-once.


---

# Stripe webhooks (inbound)
<https://belugajs.com/integrating/stripe-webhooks/>

_The three events Beluga consumes, why the success redirect proves nothing, and how idempotency is kept._

`POST /api/webhooks/stripe` is mounted **above** the JSON body parser and the
storefront gate, receives the raw body, and verifies it against
`STRIPE_WEBHOOK_SECRET` before anything else. Without the secret the route refuses
everything, and no order is ever marked paid.

### The events

| Event | What Beluga does |
| --- | --- |
| `checkout.session.completed` | Records the order as `paid`, decrements inventory (flagging `oversold` if it ran out), links it to a verified customer account if the email matches one, sends the Ordered email, queues `order.paid`. |
| `checkout.session.expired` | If abandoned-cart recovery is on, salvages the cart for a signed-in customer and sends the reminder immediately. Otherwise nothing. |
| `charge.refunded` | Adds to `refundedCents`, moves the order to `refunded` once the whole charge is covered, restocks on a full refund (once), sends the Refunded email, queues `order.refunded`. |

Subscribe to exactly those three when you create the endpoint in the Stripe
dashboard.

### Why the redirect proves nothing

A buyer can close the tab between paying and being redirected. The success URL can
be typed in by hand. So the confirmation page does not assume anything; it polls
`GET /api/checkout/:sessionId` until the webhook has done its work. This is
[invariant 3](/start/invariants/), and it is why testing without `stripe listen`
looks like a broken checkout.

### Idempotency

Stripe delivers at-least-once. Every event id is recorded before its handler runs;
a duplicate is acknowledged and ignored. If a handler fails, the record is released
so Stripe's retry is actually processed rather than dismissed as a duplicate. That
is `recordWebhookEvent` and `forgetWebhookEvent` in `db/orders-repository.ts`, and
[invariant 4](/start/invariants/).

`stripe events resend <id>` is the test: stock must not move a second time.

### Adding a handler

Add it in `server/routes/webhook.ts`, inside the same verified, deduplicated path.
Do not write a payment state anywhere else. If the new handler needs to notify
something outside Beluga, queue an [outbound event](/integrating/webhooks/) rather
than calling out inline; a slow call from inside the Stripe handler trips Stripe's
own retry.

### Locally

```bash
stripe listen --forward-to localhost:4000/api/webhooks/stripe
```

Put the printed `whsec_…` in `.env` and restart the API. The restart is the step
people skip.


---

# Store settings
<https://belugajs.com/operating/settings/>

_Identity, language and formatting, the landing page copy, the theme, and what each falls back to when left empty._

**Settings** in the admin is grouped by what it affects. The store's settings row is
one object, `settingsInputSchema` in `shared/api.ts`, and every field has a
fallback so a store that sets nothing renders exactly as it did before the field
existed.

### Identity

The store name, the currency (ISO 4217, set once at setup), and the **language**.

Language is the store's BCP 47 tag, and it decides how money, dates and country
names are written everywhere: the storefront, the admin, and order email. It
defaults to `en-US`. A euro shop that leaves it there prints `€1,234.56`; set to
`de-DE` it prints `1.234,56 €`. It also sets `<html lang>`, so screen readers and
translation prompts get the right answer. Prices are stored as integer cents
regardless, and the CSV exports are unaffected. It is the store's locale, never
the buyer's: a shop's prices should read the same in every screenshot of it.

### Landing page

The opening block of the landing page: a heading, a line of text, a button label
and target, and an optional background image. Leave any empty and the storefront
falls back to the store name, no paragraph, and a **Shop everything** button
pointing at `/shop`. The button's target is held to a same-origin path or an
`https://` address on both sides of the wire, so it cannot be made to point at
`javascript:` or a plain-`http://` downgrade.

Plain text, deliberately not Markdown. A hero is one sentence, and a bold word
inside it is a decision the theme should be making.

### Look

The theme: colours, scheme, radius, logo, font and font stylesheet URL.
[Theming](/building/theming/) has all of it.

### Tax, Shipping, Email, Visibility, Webhooks

Each has its own page: [Tax](/money/tax/), [Shipping](/money/shipping/),
[Email](/operating/email/), [Storefront visibility](/operating/visibility/),
[Outbound webhooks](/integrating/webhooks/).

### Abandoned carts

The opt-in and the delay. [Abandoned cart recovery](/accounts/abandoned-carts/).

### The Overview

The admin's first screen says the things a fresh store does not otherwise say: no
Stripe key, a test key on a public store, no shipping rates for live physical
products, a zone no rate can price, tax off, email not configured, products live
but not published, products published under stale tax settings. Each links to the
place it is fixed. It reports live state rather than marching through a wizard,
because most of those are environment variables that a wizard could not click.


---

# Storefront visibility
<https://belugajs.com/operating/visibility/>

_A password on a store that is deployed but not open, share links that can be revoked, and the checklist behind opening it._

A deploy is not the same as a launch. Between the two, Stripe gets connected, SMTP
gets a real sender, shipping zones get argued about, all of which need a
*deployed* store since webhooks cannot reach localhost, while the catalogue is
still three placeholder products on a test key. **Settings → Visibility** puts a
password on the storefront for exactly that stretch: `public` or `password`,
defaulting to `public` so every existing store comes through the migration as open
as it was.

### Not a second test/live switch

Beluga already has one: the Stripe secret key, `sk_test_` or `sk_live_`. A
database-backed mode beside it would be a second source of truth for the same
question, with the interesting states being the contradictions. What the key
cannot express is *who is allowed to look*, which is what this adds.

### What the gate covers

The gate is positional. The Stripe webhook, the health check, the admin sign-in and
setup APIs, the crawler files and the built client bundle are mounted above it and
so are exempt by construction; the storefront API, checkout, shipping, cart and
uploaded imagery are mounted below it and gated by the same construction. An
administrator's own session always passes.

Locked, `robots.txt` disallows everything with no `Sitemap:` line, `sitemap.xml` is
404, and an anonymous `GET /product/<slug>` carries no product name, price, image
or JSON-LD in its `<head>`, because the production HTML handler skips the metadata
for that request entirely rather than trusting it to omit the sensitive fields.

### Share links

A shared password is a bad thing to send a client, so what gets sent is a link.
`POST /api/admin/storefront/share-link` mints a token and returns the full URL
exactly once, the same handling as a webhook signing secret. The client posts the
token to `POST /api/storefront/unlock` and strips it from the URL immediately,
which keeps it out of every access log between here and there.

Changing the password or rotating the share link bumps a version number; a
session's grant is only honoured while it matches, which is what makes revocation
real against a 24-hour rolling cookie. Every existing viewer is out on their next
request, and the admin session is unaffected.

### Opening the store

Visibility carries the checklist behind **Open the store**: Stripe connected, a live
key, webhooks connected, the public URL not localhost, shipping rates that cover
where the store ships, an email provider, and something live to buy. Nothing on it
blocks the switch, but flipping to public with rows still failing asks for
confirmation first and names what is outstanding. The one state that gets a
persistent error-level notice is **public and holding a test key**: checkout
completes, the buyer sees a confirmation, and no money moved.

[Go live](/tutorials/going-live/) is the walkthrough.


---

# Email
<https://belugajs.com/operating/email/>

_One SMTP URL, a logged no-op until it is set, Handlebars templates with pre-formatted locals, and a test button._

Any SMTP provider, through two variables:

```
SMTP_URL=smtps://user:pass@smtp.example.com:465
EMAIL_FROM="My Store <orders@example.com>"
```

### Until it is set

Sending is a **logged no-op**, not an error. Orders complete, invitations return
their link to the inviting admin, password resets log their link, and nothing
fails. That is deliberate, so a store can take orders before its email is wired
up, but it is silent, and the Overview says so.

### What gets sent

| Template | When |
| --- | --- |
| `Ordered` | The webhook confirmed payment |
| `Processing` | The admin moved the order to processing |
| `Shipped` | The admin marked it shipped, with carrier and tracking |
| `Refunded` | A refund settled |
| `VerifyEmail` | A customer registered |
| `ResetPassword` | A customer or administrator asked for a reset |
| `AbandonedCart` | Recovery is on and a cart went quiet |

`templateForStatus` in `server/email.ts` maps an order status to a template. Add a
status and an `.hbs` pair and it is picked up on the next transition.

### Templates

`emails/<Name>/body.hbs` and `subject.hbs`, a shared `layout.hbs`, and an
`items.hbs` partial for order lines. The locals are pre-formatted display strings,
built by `toLocals`, so a template does no arithmetic and cannot get money wrong by
rounding it twice. Every link starts with `PUBLIC_URL`. Restyle them as HTML email
allows; the language setting decides how the amounts and dates inside are written.

### Proving it works

**Settings → Email → Send a test** sends one to the signed-in administrator through
the configured transport and reports the SMTP response, rate-limited. Do it before
opening the store.

### Sending reputation

Abandoned-cart reminders go out under your SMTP sender, and every one carries an
unsubscribe link that suppresses the address. Beluga sends nothing on its own
until recovery is turned on.


---

# SEO and link previews
<https://belugajs.com/operating/seo/>

_A client-rendered storefront whose head is rewritten per path in production, a sitemap, and per-product overrides._

The storefront is client-rendered, so without help a crawler or a link unfurler
fetching `/product/anything` would get the generic shell: no product name, no
price, no image. Rather than migrating to server-side rendering, the production
HTML handler rewrites the `<head>` for the path being requested: title,
description, canonical, Open Graph and Twitter tags, and JSON-LD `Product` with an
`Offer` on product pages. Only the head is touched; React still boots and renders
the body exactly as before.

### Overrides

**Search appearance** in the product editor holds a title (70 characters) and a
description (160), which is where Google truncates. Blank means the tag is
generated from the name and description. A collection's introduction becomes its
search-result description.

### Sitemap and robots

`/sitemap.xml` lists live products and collections, and `/robots.txt` points at it.
Both are built from `PUBLIC_URL`, so a wrong value there produces a sitemap of
links to the wrong host. When the storefront is [locked](/operating/visibility/),
robots disallows everything and the sitemap is 404.

### It only runs in production

Under `npm run dev`, Vite serves `index.html` untouched, so none of this is
visible. To check it:

```bash
npm run build && npm start
curl -s localhost:4000/product/canvas-tote | grep '<title>'
```

That is also the quickest proof, after any deploy, that the production handler is
what is serving.

### Images in previews

`og:image` is built from the served `/assets/` URL, not the stored path, so it
resolves under both image drivers. Upload at least one image per product; a
preview with no image is the most common reason a shared link looks broken.


---

# Examples
<https://belugajs.com/examples/>

_Working code you can copy into a fork. Each one is small, follows the conventions, and says which invariant it is careful about._

This section is code rather than a gallery of stores, because Beluga is a
framework. Each example is a complete file or a complete configuration, and
each says which rule it is being careful about, because that is the part you
cannot get from a generic assistant.

| Example | What it shows |
| --- | --- |
| [A custom product card](/examples/product-card/) | Replacing a presentational component while keeping it on the theme tokens and off the catalogue. |
| [A custom landing page](/examples/landing-page/) | A rewritten landing page that keeps the hero fallbacks and adds a section of its own. |
| [A webhook receiver](/examples/webhook-receiver/) | A complete Node receiver: raw body, signature, timestamp, dedup, answer-then-work. |
| [A catalogue CSV](/examples/catalogue-csv/) | A small importable file with a two-axis product, a digital product, and a draft. |
| [A Dockerfile](/examples/dockerfile/) | The two-stage build for a Fly, Railway or plain Docker deployment. |
| [A reverse proxy](/examples/reverse-proxy/) | nginx and Caddy in front of the API on a VM. |
| [Scripting the admin API](/examples/admin-api-script/) | Sign in, take the CSRF token, publish every draft. |
| [A CLAUDE.md for your fork](/examples/claude-md/) | The invariants and conventions in a file an assistant reads first. |

If you build a store on Beluga and want it listed, open an issue on
[GitHub](https://github.com/binx/beluga-v2) with a link. A gallery of real stores
would be a better page than this one.


---

# A custom product card
<https://belugajs.com/examples/product-card/>

_A replacement ProductCard with a hover-swap second image, styled entirely from theme tokens, taking exactly the props the list already passes._

`ProductCard` takes a name, a preformatted price, an image and two flags, and
nothing from `shared/`. That is on purpose: the theme editor renders one next to
the colour pickers with no catalogue behind it. A replacement should keep the same
props so `ProductList` and the theme editor keep working, and should read every
colour from a `--beluga-*` token so it keeps matching after a merchant changes the
palette.

This version adds a second image on hover. The list only passes one image today,
so it takes an optional `hoverImage` and `ProductList` gains one line to pass
`product.images[1]`.

### `src/components/product/ProductCard.tsx`

```tsx

interface ProductCardProps {
  href: string;
  name: string;
  /** Preformatted — callers own currency and range formatting. */
  price: string | null;
  soldOut?: boolean;
  onSale?: boolean;
  image?: Image | null;
  /** Shown on hover when present. Falls back to the first image. */
  hoverImage?: Image | null;
  sizes?: string | undefined;
  collection?: string | undefined;
}

export function ProductCard({
  href,
  name,
  price,
  soldOut = false,
  onSale = false,
  image = null,
  hoverImage = null,
  sizes,
  collection,
}: ProductCardProps) {
  return (
    <Link to={href} state={collection ? { collection } : null} className={styles.card}>
      <div className={styles.frame}>
        <ProductImage image={image} ratio={1} {...(sizes ? { sizes } : {})} />
        {hoverImage && (
          <div className={styles.hover} aria-hidden="true">
            <ProductImage image={hoverImage} ratio={1} {...(sizes ? { sizes } : {})} />
          </div>
        )}
        {soldOut ? (
          <span className={styles.badge}>Sold out</span>
        ) : onSale ? (
          <span className={`${styles.badge} ${styles.sale}`}>Sale</span>
        ) : null}
      </div>
      <div className={styles.meta}>
        <span className={styles.name}>{name}</span>
        {price && <span className={styles.price}>{price}</span>}
      </div>
    </Link>
  );
}
```

### `src/components/product/ProductCard.module.css`

```css
.card {
  display: block;
  color: var(--beluga-ink);
  text-decoration: none;
}
.frame {
  position: relative;
  overflow: hidden;
  border: 1px solid var(--beluga-line);
  border-radius: var(--beluga-radius);
  background: var(--beluga-surface);
}
.hover {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 200ms ease;
}
.card:hover .hover,
.card:focus-visible .hover {
  opacity: 1;
}
.badge {
  position: absolute;
  top: 0.5rem;
  left: 0.5rem;
  padding: 0.2rem 0.5rem;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  background: var(--beluga-primary);
  color: var(--beluga-on-primary);
  border-radius: var(--beluga-radius);
}
.sale {
  background: var(--beluga-accent);
  color: var(--beluga-on-accent);
}
.meta {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.6rem 0.1rem 0;
}
.price {
  color: var(--beluga-muted);
}
@media (prefers-reduced-motion: reduce) {
  .hover { transition: none; }
}
```

### The one line in `ProductList.tsx`

```tsx
image={product.images[0] ?? null}
hoverImage={product.images[1] ?? null}
```

### What this is careful about

- **Price and sold-out state still come from the list**, which derives them from
  the catalogue on every render via `formatPriceRange` and `isSoldOut`. The card
  never caches either. That is the cart's identifier-only rule applied one layer
  up: a cached price is the value a stale tab shows.
- **Every colour is a token.** Change the palette in Settings → Look and the badge,
  border and text follow.
- **`ProductImage` is kept**, because it is what builds the `srcset` from the
  recorded widths using the naming rule in `shared/images.ts`. A plain `<img>`
  would download the 1600 px original for a 300 px card.
- **Hover is ignored on touch** by nature, and the second image is `aria-hidden`
  so a screen reader is not told about the same product twice.


---

# A custom landing page
<https://belugajs.com/examples/landing-page/>

_A rewritten LandingPage that keeps the hero's admin-edited fields and fallbacks, and adds a 'new this week' section computed from the catalogue._

`src/pages/LandingPage.tsx` is presentation and yours to replace. The only things
worth keeping are the hero fallbacks, because a merchant edits those fields under
**Settings → Landing page** and expects them to show up, and the two catalogue
helpers, because they already know which collection is the featured one and which
collections are visible.

This version drops the collection tiles, adds a "new this week" row of the most
recently added live products, and a short story block under the hero.

### `src/pages/LandingPage.tsx`

```tsx

export function LandingPage() {
  const store = useStore();
  const { hero } = store;
  const featured = getFeaturedProducts(store);
  const collections = getVisibleCollections(store);

  // The snapshot lists live products in display order; the merchant's order is
  // the best available "newest" without adding a column for it.
  const latest = store.products.slice(0, 4);

  const href = hero.buttonHref ?? "/shop";
  const cta = (
    <Button type="primary" size="large">
      {hero.buttonLabel ?? "Shop everything"}
    </Button>
  );

  return (
    <>
      <section
        className={styles.hero}
        style={hero.image ? { backgroundImage: `url(${assetUrl(hero.image.path)})` } : undefined}
      >
        <div className={styles.heroInner}>
          <h1>{hero.heading ?? store.name}</h1>
          {hero.text && <p>{hero.text}</p>}
          {href.startsWith("/") ? (
            <Link to={href}>{cta}</Link>
          ) : (
            <a href={href} target="_blank" rel="noopener noreferrer">{cta}</a>
          )}
        </div>
      </section>

      <PageWrapper width="wide">
        <section className={styles.story}>
          <h2>Made in small batches</h2>
          <p>
            Everything here is made to order in the studio. Most things ship within a
            week; the <Link to="/shipping">shipping page</Link> has the details.
          </p>
        </section>

        {latest.length > 0 && (
          <section className={styles.section}>
            <header className={styles.sectionHead}>
              <h2>New this week</h2>
              <Link to="/shop?sort=newest">View all →</Link>
            </header>
            <ProductList products={latest} currency={store.currency} locale={store.locale} />
          </section>
        )}

        {featured.length > 0 && (
          <section className={styles.section}>
            <header className={styles.sectionHead}>
              <h2>Featured</h2>
            </header>
            <ProductList
              products={featured}
              collection="Featured"
              currency={store.currency}
              locale={store.locale}
            />
          </section>
        )}

        {collections.length > 0 && (
          <nav className={styles.collections} aria-label="Collections">
            {collections.map((c) => (
              <Link key={c.id} to={`/collection/${c.slug}`}>{c.name}</Link>
            ))}
          </nav>
        )}
      </PageWrapper>
    </>
  );
}
```

### What this is careful about

- **The hero fallbacks are preserved.** Heading to the store name, no paragraph
  when there is none, "Shop everything" to `/shop`. A merchant who sets nothing
  gets what they had.
- **The button branches on path versus URL.** Handing `https://…` to a router
  `Link` makes react-router try to resolve it as an in-app route and lands on the
  404 page. `heroHrefSchema` has already refused anything that is neither, so this
  is a two-way branch, not validation.
- **Prices are never touched here.** `ProductList` formats them from the catalogue.
- **The shipping link points at a page**, which the merchant writes under
  **Pages**, so the copy can change without a deploy. If no page has that slug the
  link 404s, which is a reason to prefer `store.pages` when you know the slug at
  render time.
- **`assetUrl` builds the image URL**, so it resolves under either image driver.


---

# A webhook receiver
<https://belugajs.com/examples/webhook-receiver/>

_A complete Node receiver for Beluga's outbound webhooks. Raw body, signature, timestamp tolerance, dedup in SQLite, answer before work._

Sixty lines, no framework beyond Express, and a SQLite file for deduplication so
it survives a restart. Run it, tunnel it, register the URL under
**Admin → Webhooks**, and put the secret you are shown in `BELUGA_WEBHOOK_SECRET`.

```bash
mkdir receiver && cd receiver
npm init -y && npm install express better-sqlite3
BELUGA_WEBHOOK_SECRET=whsec_from_the_admin node receiver.js
```

### `receiver.js`

```js

const SECRET = process.env.BELUGA_WEBHOOK_SECRET;
const PORT = process.env.PORT ?? 3000;
const TOLERANCE_SECONDS = 5 * 60;

if (!SECRET) throw new Error("Set BELUGA_WEBHOOK_SECRET.");

const db = new Database("receiver.sqlite");
db.exec("CREATE TABLE IF NOT EXISTS seen (id TEXT PRIMARY KEY, at INTEGER NOT NULL)");
const claim = db.prepare("INSERT OR IGNORE INTO seen (id, at) VALUES (?, ?)");

function verify(req) {
  const header = req.get("beluga-signature") ?? "";
  const [tPart, vPart] = header.split(",");
  const timestamp = tPart?.slice(2);
  const provided = vPart?.slice(3);
  if (!timestamp || !provided) return false;
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.${req.body}`) // req.body is a Buffer: the raw bytes
    .digest("hex");

  const a = Buffer.from(provided);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

async function handle(event) {
  switch (event.type) {
    case "order.paid": {
      const o = event.data;
      const lines = o.truncated
        ? ["(large order — fetch it from the admin)"]
        : o.items.map((i) => `${i.quantity} × ${i.productName}${i.variantLabel ? ` (${i.variantLabel})` : ""}${i.sku ? ` [${i.sku}]` : ""}`);
      console.log(`PICK ${o.reference} for ${o.shipping.name ?? o.email}, ${o.shipping.country}`);
      for (const line of lines) console.log(`   ${line}`);
      console.log(`   total ${(o.totalCents / 100).toFixed(2)} ${o.currency}`);
      break;
    }
    case "order.refunded":
      console.log(`REFUND ${event.data.reference}: ${event.data.refundedCents} of ${event.data.totalCents}`);
      break;
    case "inventory.low":
      console.log(`LOW ${event.data.productName} / ${event.data.variantLabel}: ${event.data.remaining} left`);
      break;
    default:
      console.log(`${event.type} ${event.id}`);
  }
}

const app = express();

// express.raw, and only on this route. A global express.json() mounted first
// would consume the body and the signature could never be checked.
app.post("/hooks/beluga", express.raw({ type: "application/json" }), (req, res) => {
  if (!verify(req)) return res.status(400).end();

  const event = JSON.parse(req.body.toString("utf8"));

  // At-least-once delivery: the first claim on this id wins, later ones are
  // acknowledged and dropped. Deliveries can also arrive out of order.
  if (claim.run(event.id, Date.now()).changes === 0) {
    return res.json({ received: true, duplicate: true });
  }

  // Answer within Beluga's 10 second window, then do the work.
  res.json({ received: true });
  handle(event).catch((error) => console.error(`failed ${event.id}:`, error));
});

app.listen(PORT, () => console.log(`receiver on :${PORT}`));
```

### Notes

- Add `"type": "module"` to `package.json` for the `import` syntax.
- The division by 100 is for the console only. Do not store money as a float
  anywhere downstream; pass `totalCents` on as the integer it is.
- If `handle` fails after the id was claimed, the event is not retried, because
  Beluga saw a `200`. That is the right trade for a pick list. For something that
  must not be lost, insert the raw event into a table inside the same claim and
  process it from there.
- The [reference page](/integrating/webhook-receiver/) has the Python version and
  the troubleshooting table.


---

# A catalogue CSV
<https://belugajs.com/examples/catalogue-csv/>

_A small file the importer accepts: a two-axis product, a single-variant product with a compare-at price, a digital download, and a draft._

Columns are read by name, so you may reorder or omit them. Omitted columns leave
stored values alone on an update; present-but-empty columns clear them. Money is
integer cents. Lists inside a cell are `|`-separated.

```csv
slug,name,kind,description,bullet_points,tax_code,option1_name,option1_value,option2_name,option2_value,variant_sku,variant_price_cents,variant_compare_at_price_cents,variant_inventory_type,variant_inventory_quantity,variant_weight_grams,is_live
canvas-tote,Canvas Tote,physical,Heavyweight cotton canvas with a boxed base.,16 oz cotton canvas|Machine washable,,Size,Small,Colour,Natural,TOTE-S-NAT,3400,,finite,12,320,true
canvas-tote,Canvas Tote,physical,Heavyweight cotton canvas with a boxed base.,16 oz cotton canvas|Machine washable,,Size,Small,Colour,Black,TOTE-S-BLK,3400,,finite,8,320,true
canvas-tote,Canvas Tote,physical,Heavyweight cotton canvas with a boxed base.,16 oz cotton canvas|Machine washable,,Size,Large,Colour,Natural,TOTE-L-NAT,3900,,finite,5,410,true
canvas-tote,Canvas Tote,physical,Heavyweight cotton canvas with a boxed base.,16 oz cotton canvas|Machine washable,,Size,Large,Colour,Black,TOTE-L-BLK,3900,,finite,0,410,true
enamel-pin,Enamel Pin,physical,Hard enamel on brass with a rubber clutch.,,,,,,,PIN-01,1200,1500,infinite,,15,true
studio-wallpaper,Studio Wallpaper Pack,digital,Six desktop and phone wallpapers as a zip.,,txcd_10301000,,,,,WALL-01,500,,infinite,,,true
linen-apron,Linen Apron,physical,Coming soon.,,,,,,,APRON-01,6800,,finite,20,280,false
```

### Reading it

- **`canvas-tote`** is one product with two axes and four variants. Every row
  repeats the product fields; the importer takes them from the first row and
  refuses the file if a later row disagrees. The Large / Black variant has zero
  stock, so it shows as sold out.
- **`enamel-pin`** has no options and one variant, with a compare-at price above
  its price, so it shows a Sale badge. Compare-at is never sent to Stripe.
- **`studio-wallpaper`** is digital: inventory must be `infinite`, weight is
  ignored, and it carries a digital-goods tax code.
- **`linen-apron`** has `is_live` false, so it lands as a draft.

### Importing

**Products → Import CSV** previews it: how many rows, creates and updates, and
every error at once with its row and column. Nothing is written until you commit.
An import never publishes; each live product still needs **Publish** before it can
be bought.

By the API:

```bash
curl -b cookies -H "x-csrf-token: $CSRF" -H "content-type: text/csv" \
  --data-binary @catalogue.csv \
  https://your-store/api/admin/products/import/validate
```

[Scripting the admin API](/examples/admin-api-script/) shows where the cookie and
the token come from. [Catalogue CSV](/catalogue/csv/) has the rules.


---

# A Dockerfile
<https://belugajs.com/examples/dockerfile/>

_A two-stage build that compiles the server and client, prunes to production dependencies, and keeps the migrations in the image._

Beluga's runtime needs `dist/`, `dist-server/`, `db/migrations/`, `emails/`,
`public/` (for the bundled demo images), `package.json`, and production
`node_modules`. Paths resolve from the working directory, so the image starts from
the repository root. `better-sqlite3` and `sharp` want a toolchain in the build
stage, which is why this uses the Debian-based Node image rather than Alpine.

### `Dockerfile`

```dockerfile
## syntax=docker/dockerfile:1

FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ \
  && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/dist-server ./dist-server
COPY --from=build /app/db/migrations ./db/migrations
COPY --from=build /app/emails ./emails
COPY --from=build /app/public ./public
## Run as the unprivileged user the base image provides, and make sure the
## default data and assets locations are writable by it. A mounted volume
## replaces these paths in production.
RUN mkdir -p data public/assets && chown -R node:node /app
USER node
EXPOSE 4000
CMD ["node", "dist-server/server/index.js"]
```

### `.dockerignore`

```
node_modules
dist
dist-server
data
public/assets
.env
.env.*
test-results
legacy
.git
```

`public/assets` is excluded because uploads belong on a volume or in a bucket, not
baked into an image. The bundled demo images live elsewhere under `public/`.

### Running it

```bash
docker build -t my-store .
docker run --rm -p 4000:4000 \
  -v beluga-data:/app/data -v beluga-assets:/app/public/assets \
  -e SESSION_SECRET=$(openssl rand -base64 32) \
  -e PUBLIC_URL=http://localhost:4000 \
  my-store
```

The log prints the setup token. Open <http://localhost:4000/setup>.

With a bucket for images and Postgres for the database, drop both volumes:

```bash
docker run --rm -p 4000:4000 \
  -e DATABASE_URL=postgres://... \
  -e ASSETS_S3_BUCKET=... -e ASSETS_S3_REGION=auto \
  -e ASSETS_S3_ENDPOINT=https://... \
  -e ASSETS_S3_ACCESS_KEY_ID=... -e ASSETS_S3_SECRET_ACCESS_KEY=... \
  -e ASSETS_PUBLIC_URL=https://images.example.com \
  -e SESSION_SECRET=... -e PUBLIC_URL=https://shop.example.com \
  my-store
```

### What this is careful about

- **`npm run build` before `npm prune`**, because the build needs devDependencies
  and the runtime must not ship them.
- **`db/migrations/` is copied.** Migrations run at boot; an image without them
  fails to start, not just to upload.
- **`emails/` is copied.** Templates are read at send time.
- **One process.** No supervisor, no nginx inside the container; the platform's
  edge or a proxy outside handles TLS.


---

# A reverse proxy
<https://belugajs.com/examples/reverse-proxy/>

_Caddy and nginx in front of the API on a VM, with the headers TRUST_PROXY expects._

On a VM the API binds to `127.0.0.1:4000` (`API_HOST=127.0.0.1`) and a proxy on the
same box terminates TLS. `TRUST_PROXY` defaults to one hop in production, which is
this proxy, so it must send `X-Forwarded-For` and `X-Forwarded-Proto`; the login
rate limit and the `Secure` session cookie both read them.

### Caddy

Obtains and renews the certificate itself, and sets the forwarded headers by
default.

```
shop.example.com {
    encode gzip
    reverse_proxy 127.0.0.1:4000
}
```

### nginx

```nginx
server {
    listen 80;
    server_name shop.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name shop.example.com;

    ssl_certificate     /etc/letsencrypt/live/shop.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/shop.example.com/privkey.pem;

    # Uploads are capped at MAX_UPLOAD_BYTES (20 MB) by Beluga; match it here or
    # nginx answers 413 before Beluga sees the request.
    client_max_body_size 21m;

    gzip on;
    gzip_types text/css application/javascript application/json image/svg+xml;

    location / {
        proxy_pass         http://127.0.0.1:4000;
        proxy_http_version 1.1;
        proxy_set_header   Host              $host;
        proxy_set_header   X-Real-IP         $remote_addr;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto $scheme;
        proxy_read_timeout 60s;
    }
}
```

`certbot --nginx -d shop.example.com` writes the certificate paths above.

### What this is careful about

- **One hop.** If you put a CDN or load balancer in front of the proxy as well,
  set `TRUST_PROXY=2`, or to the CDN's address ranges. Trusting more hops than
  exist lets a caller choose their own IP and defeat the login rate limit.
- **The Stripe webhook goes through the same proxy.** Nothing special is needed;
  the raw body is preserved by a plain `proxy_pass`.
- **No caching layer** in front of `/api`. `/assets/` is safe to cache and already
  carries a 30-day header in production.


---

# Scripting the admin API
<https://belugajs.com/examples/admin-api-script/>

_There is no API key. A script signs in like a person, keeps the cookie, sends the CSRF token, and does what the admin does. This one publishes every unpublished live product._

The admin API is what the admin UI uses, behind a session and a CSRF token. A
script drives it the same way. Keep the credentials in the environment; this
signs in as a real administrator and can do everything one can.

```bash
BELUGA_URL=https://shop.example.com \
BELUGA_EMAIL=you@example.com BELUGA_PASSWORD='...' \
node publish-all.mjs
```

### `publish-all.mjs`

```js
const BASE = process.env.BELUGA_URL;
const EMAIL = process.env.BELUGA_EMAIL;
const PASSWORD = process.env.BELUGA_PASSWORD;

let cookie = "";
let csrf = "";

function remember(response) {
  const set = response.headers.getSetCookie?.() ?? [];
  if (set.length) cookie = set.map((c) => c.split(";")[0]).join("; ");
}

async function api(method, path, body) {
  const response = await fetch(`${BASE}/api${path}`, {
    method,
    headers: {
      accept: "application/json",
      ...(body ? { "content-type": "application/json" } : {}),
      ...(cookie ? { cookie } : {}),
      ...(csrf ? { "x-csrf-token": csrf } : {}),
    },
    body: body ? JSON.stringify(body) : undefined,
  });
  remember(response);
  if (!response.ok) {
    const text = await response.text();
    throw new Error(`${method} ${path} → ${response.status}: ${text}`);
  }
  return response.json();
}

// 1. A session, and with it the CSRF token every write needs.
const session = await api("GET", "/session");
csrf = session.csrfToken;

// 2. Sign in. The response is identical for a wrong email and a wrong password.
await api("POST", "/session", { email: EMAIL, password: PASSWORD });
csrf = (await api("GET", "/session")).csrfToken;

// 3. Everything, drafts included.
const { products } = await api("GET", "/products/full");
const pending = products.filter(
  (p) => p.isLive && p.variants.some((v) => !v.stripePriceId),
);

console.log(`${pending.length} live product(s) not fully published.`);

// 4. Publish each one. This is the only call in this script that writes to Stripe.
for (const product of pending) {
  await api("POST", `/products/${product.id}/publish`);
  console.log(`published ${product.slug}`);
}

await api("DELETE", "/session");
```

### What this is careful about

- **Publish is deliberate.** This script's whole purpose is to write to Stripe, so
  it does it through the one route that may, and prints each product it touches.
  A script that *saves* products should never expect them to reach Stripe.
- **The CSRF token is re-read after login**, because the session it belongs to
  changed.
- **The password is a live credential.** The session is destroyed at the end, and
  nothing is written to disk.
- **The login route is rate-limited.** A script that retries in a tight loop locks
  itself out for a while, which is the feature working.

### Other useful one-offs

- `GET /admin/orders?status=paid` and `PUT /admin/orders/:id` with `{ status,
  carrier, trackingNumber }` to bulk-mark a batch as shipped from a carrier's
  manifest.
- `GET /admin/orders.csv?from=&to=` nightly into your accounting.
- `POST /admin/products/import/validate` with a CSV body to lint a catalogue file
  in CI before anyone commits it.


---

# Roadmap and changelog
<https://belugajs.com/reference/roadmap/>

_Where the roadmap lives, how it is generated, and what is known but not yet decided._

The roadmap is generated, not maintained. Every unit of work in the repository is
a brief under `docs/tasks/` with frontmatter that is the single source of truth for
its status, and `npm run roadmap` renders `docs/roadmap.html` from it, changelog
included, built from the briefs' `completed` dates. There is no second list to keep
in step, and this site does not keep one either.

**[The roadmap page in the repository](https://github.com/binx/beluga-v2/blob/main/docs/roadmap.html)**
is the current state. **[The task briefs](https://github.com/binx/beluga-v2/tree/main/docs/tasks)**
are the detail.

### What has landed

Everything in these docs. In rough order: the rebuild in five phases (storefront,
database and API, Stripe Checkout and orders, accessibility, the admin and setup
wizard), then the numbered briefs: refunds and restocking, discount codes, order
CSV, SEO metadata, search, staff accounts, pages, tax, multi-axis variants,
customer accounts, abandoned-cart recovery, digital products (the model), outbound
webhooks, catalogue CSV, the first-run fixes, landing and collection copy, web
fonts and locale, SKUs and compare-at prices and per-variant images, admin
password reset, object storage for images, the building-on-Beluga map, and
storefront preview mode.

### Known gaps

Understood but undecided work lives in `docs/gaps/`, deliberately outside the
numbering so listing it does not claim a plan that does not exist.

- **Digital delivery.** Entitlements, a non-served file location, a download route,
  a `delivered` status. [Digital products](/catalogue/digital-products/).

### Deliberately not planned

- Live carrier rates, labels, tracking. [Shipping](/money/shipping/) has the trade.
- Role-based permissions in the admin. The column exists; the gating is a separate
  decision with its own security matrix.
- Cart-condition discounts. Stripe's promotion codes cover the common case.
- Subscriptions.

### Contributing

[Conventions and contributing](/building/conventions/).


---

# For AI assistants
<https://belugajs.com/reference/ai/>

_The docs ship as one Markdown file and a discovery file, so an assistant can answer against the real Beluga rather than a guessed one._

The reader of these docs is assumed to be working with an assistant, so that is a
supported path rather than something tolerated.

### `/docs/all.md`

[Every page, concatenated](/docs/all.md), in sidebar order, with stable headings
and each page's URL under its title. Generated at build time from the same sources
as the site by a script in the docs repository, never hand-maintained, so it cannot
drift. Drop it into a context window and ask questions against it.

### `/llms.txt`

[The conventional discovery file](/llms.txt): what Beluga is, the key pages, a
link to `all.md`, and the invariants in brief.

### The invariants page

[The invariants](/start/invariants/) is written to be pasted verbatim into an
assistant's instructions or a fork's `CLAUDE.md`. Those eight rules are the things
an assistant cannot know from general knowledge, and the things that produce code
which compiles, passes review, and is wrong when violated.
[A CLAUDE.md for your fork](/examples/claude-md/) is a ready-made file.

### What an assistant gets wrong without this

From experience, in order of cost:

- Verifying an outbound webhook against a re-serialised body. Passes in testing,
  fails on the first payload with different key ordering.
- Marking an order paid on the success redirect.
- Sending a price from the client "to save a query".
- Adding a column to one dialect file.
- Mounting an admin route outside the admin router, or forgetting the security
  test.
- Writing to Stripe on save.
- Giving a digital product a weight of zero instead of excluding it.

Each is on [the invariants page](/start/invariants/) or in
[Gotchas](/reference/gotchas/).

### In the repository itself

`docs/tasks/README.md` holds the invariants and conventions;
`docs/building-on-beluga.md` is the map of which files are yours to change; and
the task briefs are written to be handed to an agent cold. An assistant pointed at
the repository should read those three before anything else.


---

# The invariants
<https://belugajs.com/start/invariants/>

_The load-bearing rules. A change that breaks one is wrong even if every test passes. Written to be pasted into an assistant's instructions._

These rules are the reason Beluga is shaped the way it is. They are not style. A
change that violates one will usually compile, pass review, and be wrong in a way
that shows up as money. Copy this list into your fork's `CLAUDE.md`, `AGENTS.md`, or
whatever your assistant reads.

:::note
This page is written to be pasted into an assistant's context, not read start to
finish. It's more useful to an agent than to a human skimming for an overview.
:::

1. **Money never comes from the request.** Prices and totals are read from the
   database on every path. A request carries product and variant ids and quantities
   only. The loop in `server/routes/checkout.ts` is where this is enforced.
2. **The webhook is the only thing that marks an order paid.** The success redirect
   proves nothing: a buyer can close the tab, and the URL can be visited directly.
   Never add a payment state transition anywhere else.
3. **Webhook handling is idempotent.** Events are deduplicated by id, and a failed
   handler releases the dedup record so Stripe's retry is actually processed rather
   than dismissed as a duplicate. See `recordWebhookEvent` and `forgetWebhookEvent`
   in `db/orders-repository.ts`.
4. **Every admin route is behind `requireAdmin` and `verifyCsrf`**, applied once to
   the whole router in `server/routes/admin.ts`. Never mount an admin endpoint
   outside that router, and register every new route in `server/security.test.ts`.
5. **Every schema change lands in both dialects.** `db/schema.sqlite.ts` and
   `db/schema.pg.ts` are edited together, then `npm run db:generate` emits a
   migration for each. Commit all of it.
6. **Products reach Stripe only through explicit publish**
   (`POST /api/admin/products/:id/publish`). Saving never writes to Stripe.
7. **Stripe Prices are immutable.** Changing an amount creates a new Price and
   archives the old one, which is why historic orders still resolve. Do not "fix"
   this by mutating.

### What follows from them

- The cart stores identifiers, never prices or image URLs, so a price change cannot
  leave a stale amount in someone's open tab (rule 1).
- The confirmation page polls the order's status rather than assuming success (2).
- A refund is requested from Stripe and recorded by the `charge.refunded` webhook,
  never written directly (2 and 3).
- Changing how a store quotes tax reaches Stripe only when each product is
  republished, and nothing republishes itself (6 and 7).
- A test key and a live key have separate catalogues; publishing under one puts
  nothing in the other (6).

### Conventions that sit beside them

Not invariants, but the codebase holds to them and a contribution should too.

- Validation lives in `shared/` as zod schemas, never inline in a route.
- Routes stay thin: parse, call a repository function, respond. SQL lives in
  `db/*-repository.ts`.
- Errors use `httpError(status, message)` with a user-facing message.
- Client mutations go through `csrfPost`, `csrfPut` and `csrfDelete`, wrapped in a
  hook in `src/admin/queries.ts` that invalidates the public store key.
- Comments explain why, not what.

[Conventions and contributing](/building/conventions/) has the longer version, and
[Gotchas](/reference/gotchas/) the things that look like bugs and are not.


---

# Gotchas
<https://belugajs.com/reference/gotchas/>

_Everything that will cost someone an hour, sorted by when it bites. Most have a longer explanation on their own page._

### Environment and dev loop

1. **Node 22 required.** Node 18 fails in ways that read as "command not found".
   `nvm use`.
2. **`npm run setup` writes `.env` once and never touches it again.** Re-running it
   will not fix a value you changed by hand.
3. **`SESSION_SECRET` is optional in development**, and without it a fresh one is
   generated per boot, so restarting the API signs you out. Required in production.
4. Ports: Vite 5173, API 4000. **Avoid 5000 on macOS**; AirPlay Receiver binds it.
5. `VITE_BELUGA_API=false` renders the bundled fixture with no database. Right for
   UI work, confusing if you set it and forget.
6. **A fresh checkout has no store in it.** `npm test` seeds per suite; `npm run
   test:e2e` drives the real app against `data/beluga.sqlite` and fails on a
   missing heading in a new clone. `npm run db:migrate && npm run db:seed` once
   per checkout.
7. **`.env` is irrelevant to e2e.** Playwright points `ENV_FILE` at a file that does
   not exist, on purpose.
8. **Postgres skips silently** in `db/dialect.test.ts` if `embedded-postgres`
   cannot start. Confirm with `--reporter=verbose` and look for `repository on
   postgres`.

### Stripe

9. **The success redirect proves nothing.** Only the webhook marks an order paid.
   Anyone testing without `stripe listen` concludes checkout is broken.
10. Local testing needs `stripe listen --forward-to
    localhost:4000/api/webhooks/stripe`, the printed `whsec_…` in `.env`, **and an
    API restart.** The restart is the step people skip.
11. **Stripe Prices are immutable.** Changing an amount mints a new Price and
    archives the old one. That is why historic orders still resolve.
12. Same for `tax_behavior`, so changing how a store quotes prices reaches Stripe
    only when each product is **republished**, and **nothing republishes itself**.
13. **A product not published to Stripe cannot be bought.** Saving does not publish.
14. **Test and live keys have separate catalogues.** Publishing under test keys puts
    nothing in the live account.

### Deployment

15. **SQLite is a file and uploaded images are files.** On any platform with an
    ephemeral filesystem, both vanish on redeploy. Attach a volume, or move to
    Postgres *and* a bucket. [The shape of a deployment](/deploying/shape/).
16. `PUBLIC_URL` builds Stripe's success and cancel URLs and every emailed link.
    Wrong value: buyers redirected somewhere wrong after paying.
17. **SEO head rewriting runs only in the production branch**, invisible under
    `npm run dev`. Verify with `npm run build && npm start` and curl the title.
18. A half-configured `ASSETS_S3_*` group, or a stray one with no bucket, is
    refused at boot. That is the feature; read the message.
19. The Stripe webhook endpoint has to be created again for live keys, with a new
    secret. Until then the live store records no orders.

### Silent until it matters

20. **Email is a logged no-op until `SMTP_URL` is set.** Orders complete, no mail
    sent, nothing errors.
21. **Tax is off by default and under-collection is silent.** Three things must be
    true in Stripe first, none doable from Beluga.
22. **Shipping fails silently three ways.** A coverage gap ships free; no recorded
    weights means everything matches the lightest band; subtotal bounds ignore
    digital lines. [Shipping](/money/shipping/).
23. **Abandoned cart recovery is off by default**, and a guest's cart is never
    stored server-side.
24. **Customer orders link to an account only after email verification.** Not an
    oversight.
25. **A partial refund does not restock.** Full refunds and cancellations do, once.
26. **`role` is recorded but gates nothing.** Every administrator can do everything.
27. **Removing a staff member destroys their sessions immediately.**
28. **The webhook signing secret is shown once.** Rolling it invalidates the old one
    immediately.

### Building on it

29. **Every schema change lands in both dialects**, then `npm run db:generate`.
30. **New routes go in `MUTATIONS` or `READS` in `server/security.test.ts`.** Not
    optional.
31. **Money is integer cents everywhere.** CSV columns are `*_cents` for the same
    reason.
32. Storefront search is **client-side** against the `/api/store` snapshot, capped
    at 200 products. Past that, swap to `GET /api/products?search=` behind
    `src/lib/store-source.ts`.
33. Image derivative naming lives in `shared/images.ts` because both sides use it,
    and **nothing type-checks that they agree**.
34. Reserved page slugs (`shop`, `cart`, `confirm`, `product`, `collection`,
    `about`, `admin`, `setup`, `account`) are refused.
35. `variantName` and `aboutText` are **deprecated but still present**, kept one
    release for rollback. Do not build on them.
36. **Verify outbound webhooks against the raw body.** A re-serialised body fails
    on the first payload with different key ordering.


---

# A CLAUDE.md for your fork
<https://belugajs.com/examples/claude-md/>

_A ready-made instructions file for a fork of Beluga. The invariants, the conventions, and the checks, in the shape an assistant reads first._

Put this at the root of your fork as `CLAUDE.md`, `AGENTS.md`, or whatever your
assistant reads. Edit the first section to describe your store; leave the rest.
The invariants are copied from [the invariants page](/start/invariants/), which is
the source; if the two ever differ, that page wins.

```markdown
## <Your store>

A store built on Beluga: React 19 storefront and admin, Express 5 API,
SQLite or Postgres, Stripe Checkout. Documentation: https://belugajs.com —
the whole set is one file at https://belugajs.com/docs/all.md.

Our changes from upstream live in: <list the files or directories>.

### Setup

Node 22 (`nvm use`). `npm install`, `npm run setup`, `npm run dev:all`.
Storefront on :5173, API on :4000. `stripe listen --forward-to
localhost:4000/api/webhooks/stripe`, put the whsec in .env, restart the API.

### The invariants

A change that breaks one is wrong even if every test passes.

1. Money is integer cents, everywhere. No floats, no `* 100`. See shared/money.ts.
2. Money never comes from the request. Prices and totals are read from the
   database on every path; a request carries ids and quantities only.
3. The webhook is the only thing that marks an order paid. The success
   redirect proves nothing. Never add a payment state transition elsewhere.
4. Webhook handling is idempotent: events are deduplicated by id, and a
   failed handler releases the dedup record so the retry is processed.
5. Every admin route is behind requireAdmin + verifyCsrf, applied once to the
   whole router in server/routes/admin.ts. Never mount an admin endpoint
   outside it. Register every new route in server/security.test.ts.
6. Every schema change lands in both dialects: db/schema.sqlite.ts and
   db/schema.pg.ts together, then `npm run db:generate`, commit both migrations.
7. Products reach Stripe only via explicit publish
   (POST /api/admin/products/:id/publish). Never write to Stripe on save.
8. Stripe Prices are immutable. Changing an amount creates a new Price and
   archives the old one. Do not mutate.

### Conventions

- Validation lives in shared/ as zod schemas (shared/api.ts for inputs),
  never inline in a route.
- Routes stay thin: parse, call a repository function in db/*-repository.ts,
  respond. Errors use httpError(status, message) with a user-facing message.
- Client mutations go through csrfPost/csrfPut/csrfDelete in src/lib/api.ts,
  wrapped in a hook in src/admin/queries.ts that invalidates the store key.
- Storefront components read colours and radius from --beluga-* tokens.
- Digital lines are excluded from shipping, never given a weight of zero.
- Outbound webhooks are verified against the raw body, never a re-serialised one.
- Comments explain why, not what.

### Before saying a change is done

npm run typecheck && npm run lint && npm test
`npm test -- --reporter=verbose | grep "repository on postgres"` — Postgres
skips silently if embedded-postgres cannot start, so a green run without that
line did not test both dialects.

A fresh checkout has no store: `npm run db:migrate && npm run db:seed` before
`npm run test:e2e`. `.env` is irrelevant to e2e.

### Files that hold a rule (read the invariant before editing the logic)

src/pages/CartPage.tsx, src/pages/ConfirmPage.tsx,
src/components/product/ProductDetails.tsx, server/routes/checkout.ts,
server/routes/webhook.ts, anything under db/.

### Branching

Branch from main for every task. Never commit onto a merged branch.
```

### Why each section is there

- **The link to `all.md`** lets an assistant with web access read the real docs
  instead of guessing from general ecommerce knowledge.
- **The invariants** are the things that produce wrong-but-passing code.
- **The Postgres line** is there because it is the single most common way a
  "tests pass" claim is untrue in this codebase.
- **The list of rule-holding files** is the short form of [The seams](/building/seams/).


---

# About beluga
<https://belugajs.com/about/>

_Why Beluga exists, who builds it, and how the project is shaped._

Beluga was built by [Rachel Binx](https://rachelbinx.com/) to run ecommerce for
experimental art projects: stores where the interesting part is the interface a
customer uses to make the thing, not the checkout behind it. A platform charges a
monthly fee for a storefront you cannot really change; Beluga is the opposite trade.
You host it, you can change every pixel, and the only recurring cost is Stripe's
per-transaction fee and whatever your server costs.

It is the white whale: the third time building an ecommerce site from scratch, and
the one that was open-sourced so it did not have to be built a fourth time.

### The stack

Vite and React 19, an Express 5 API in TypeScript, SQLite or Postgres behind Drizzle,
Stripe Checkout Sessions, and an admin that does not write to Stripe until you ask it
to. [Architecture](/start/architecture/) shows how the pieces fit, and
[What Beluga is](/start/what-beluga-is/) says what it deliberately leaves out.

### The shape of the project

Beluga is a framework, not a product with an admin bolted on. The admin is one
consumer of a data model and an API that you are expected to build on, and the
documentation is organised that way: each page says what the model is, what the API
does, and what you may change. Screenshots are rare on purpose. The rules that matter
are the ones that produce code which compiles, passes review, and is wrong, and those
are written down on [the invariants page](/start/invariants/).

### Contributing and saying hello

The code is on [GitHub](https://github.com/binx/beluga-v2). Task briefs, the roadmap
and the conventions are in the repository and summarised under
[Conventions and contributing](/building/conventions/).

If you build something on Beluga, please say so. Seeing what people make with it is
most of the fun. And if it has saved you a platform fee or two, there is a
[coffee link](https://www.buymeacoffee.com/binx). 🎷🐋
