# Camplax

> Vercel + Supabase. In one. Hosting, database, sign-in, storage, deploys, $20/month. AI is prepaid credits.

## Camplax docs
Source: https://camplax.dev/docs/index.md

Camplax is Vercel and Supabase in one product. One project gives you hosting and deploys, an always-on Postgres database, sign-in, file storage, secrets, preview branches, an AI gateway and billing — one login, one bill.

You do not stitch services together. You create a project, and it already has a live URL, a real database, user sign-in and a place for files. There is no second dashboard and no second invoice.

Platform access is $20/month, card required — there is no free tier. Hosting, the database, sign-in, storage, deploys, previews, domains and secrets are included in that price. AI and other variable-cost features draw from prepaid credits that stop at zero, so there is never a surprise bill. One line: **host your app for $20/month, buy credits when you use AI.**

## Where to go

The sidebar groups the docs the same way this table does.

| Group | Page | What it covers |
| --- | --- | --- |
| Start here | [Getting started](/docs/getting-started.html) | Install the CLI, sign in, and put an app on a live URL in about ten minutes |
| Start here | [How Camplax works](/docs/how-camplax-works.html) | What a project contains, the two stacks, and the two domains |
| Build | [Hosting and deploys](/docs/hosting-deploys.html) | Push to deploy, rollback, deploy hooks, build logs, the GitHub integration |
| Build | [Previews](/docs/previews.html) | A live URL per branch, isolated from production, with an optional password |
| Build | [Domains](/docs/domains.html) | Your free `camplax.app` address, custom domains, SSL, firewall and rate limits |
| Build | [Secrets](/docs/secrets.html) | Environment variables per environment, branch overlays, the keys Camplax injects |
| Platform | [Database](/docs/database.html) | One Postgres per app, the schema editor, branches, export and restore |
| Platform | [Sign-in](/docs/sign-in.html) | Email and social sign-in, sessions, the Users page, test users |
| Platform | [File storage](/docs/storage.html) | Uploads with signed, expiring links and a bucket per environment |
| Platform | [Payments](/docs/payments.html) | Connect your Stripe account and take checkout payments |
| Platform | [AI gateway](/docs/ai-gateway.html) | Every model through one OpenAI-compatible endpoint, paid from credits |
| Reference | [CLI reference](/docs/cli.html) | Every `camplax` command and flag |
| Reference | [AI agents and MCP](/docs/agents-mcp.html) | Connect Claude Code, Cursor, Codex or a chat client |

More pages sit under **Observe** (logs, errors, analytics, uptime) and **Manage** (teams, API keys, billing) in the sidebar as they land.

## Read by machines

Every page also exists as plain markdown — swap `.html` for `.md` in the URL, for example `/docs/getting-started.md`. The buttons at the top of each page copy the markdown or open it in ChatGPT or Claude.

For agents:

- [`/llms.txt`](/llms.txt) — the short briefing.
- [`/llms-full.txt`](/llms-full.txt) — every docs page in one file.
- `https://camplax.dev/mcp` — the MCP server: create projects, deploy and verify live features without scraping a page. See [AI agents and MCP](/docs/agents-mcp.html).

## Pricing, in one line

Host your app for $20/month. Buy credits when you use AI. There is no free tier, a card is required at sign-up, and usage stops when credits run out — so there is no surprise bill.

[Start for $20](/app/sign-up)

## Getting started
Source: https://camplax.dev/docs/getting-started.md

This is the shortest path from nothing to a live app: an account, the CLI, a project, a deploy. About ten minutes if nothing goes wrong. It is a tutorial, not a reference — every command here has a fuller page linked at the end.

## 1. Create your account

Sign up at [camplax.dev/app/sign-up](/app/sign-up). Camplax is $20/month, card required — there is no free tier. That one payment covers hosting, the database, sign-in, storage, deploys, previews, domains and secrets for every project you make. AI calls draw from prepaid credits, which you can load later under **Billing**.

## 2. Install the CLI

You need Node 24 or newer, then:

```bash
npm install -g camplax
```

Check it landed:

```bash
camplax help
```

## 3. Sign in

```bash
camplax login
```

A browser window opens so you can approve this machine. Approve it and the CLI stores a token in `~/.camplax/config.json`. You never paste the token into a file.

For a machine with no browser — CI, a server, an agent — paste a token instead:

```bash
camplax login <token>
```

or set `CAMPLAX_TOKEN` in the environment. Project API keys are created in the console under **Keys**, with scopes like `deploy` or `env:read` so a leak is limited to one project.

## 4. Create the project — and, if you want, a starter app

Two different things happen here, and the order confuses everyone once:

1. **The project lives on Camplax.** Open [camplax.dev/app](/app), choose **New project**, and pick a name. The name becomes the slug in the URL, and provisioning starts — hosting, a database, sign-in and storage under one slug like `my-app`.
2. **The code lives on your machine.** If you are starting from scratch, run `camplax create my-app`. It scaffolds the starter app locally — a Plax app with sign-in, a notes table, a server AI route and its first SQL migrations already wired. It only writes files to your disk; it does not create the Camplax project, which is why the console step comes first.

Already have a repo? Skip `create` entirely and link it below.

## 5. Link and deploy

From the folder that holds your code:

```bash
camplax link my-app
camplax deploy
```

`link` ties the folder to the project (it writes `.camplax/link.json`). `deploy` queues a production deploy of the connected repository. Watch it finish:

```bash
camplax logs
```

When the deploy reports ready, the app is live at `https://my-app.camplax.app`. That URL works from the first deploy — https included, nothing to configure.

If you connected a GitHub repo instead, a push to the default branch does the same thing — see [Hosting and deploys](/docs/hosting-deploys.html).

## 6. Pull the environment

```bash
camplax env pull
```

This writes the project's vault into a local `.env` — the keys your app reads, with development values where you set them. It never contains the production database string by default; that is deliberate.

## 7. Work on it locally

```bash
camplax dev
```

This pulls the project's environment into `.env.local` and runs your start command. The production database is blocked by default so a local experiment cannot touch live data; pass `--use-production-database` only when you mean it.

## 8. Put it on your own domain

One line of DNS: add the domain in the console under **Domains**, point a `CNAME` at `edge.camplax.app`, and press **Verify**. The certificate is provisioned for you. [Domains](/docs/domains.html) has the whole flow.

## When something looks wrong

- `camplax doctor` checks the folder, the login and the link, in plain English.
- `camplax probe` runs live write → read → delete checks against the project's real features and prints pass, skip or fail per check.
- `camplax rollback` puts the previous production build back without rebuilding.

## Where next

- [How Camplax works](/docs/how-camplax-works.html) — what a project contains and where each piece runs.
- [Hosting and deploys](/docs/hosting-deploys.html) — previews, deploy hooks, rollback.
- [Secrets](/docs/secrets.html) — what `env pull` and `env push` actually move.
- [Database](/docs/database.html) — tables, migrations, preview branches.
- [CLI reference](/docs/cli.html) — every command and flag.

## How Camplax works
Source: https://camplax.dev/docs/how-camplax-works.md

Camplax replaces the usual two-vendor setup — a Vercel for the frontend and a Supabase for the data — with a single project. This page is the map: what a project contains, where it runs, and which tool reaches which part.

## One project, nine parts

A project is the unit everything hangs off. Create one and all of this exists under one slug:

| Part | What it is | Where to read |
| --- | --- | --- |
| Code | Your repo — connected to GitHub, or deployed from the CLI | [Hosting and deploys](/docs/hosting-deploys.html) |
| Deploys | A queued build that ends with a running app; production plus a preview per branch | [Hosting and deploys](/docs/hosting-deploys.html), [Previews](/docs/previews.html) |
| Database | One real Postgres database per project, always on | [Database](/docs/database.html) |
| Sign-in | Accounts, sessions and social providers inside your app's own database | [Sign-in](/docs/sign-in.html) |
| Storage | File uploads on signed, expiring links | [File storage](/docs/storage.html) |
| Secrets | An environment-variable vault with per-environment overlays | [Secrets](/docs/secrets.html) |
| Domains | A free `*.camplax.app` address, plus your own domains | [Domains](/docs/domains.html) |
| AI | An OpenAI-compatible gateway, paid from prepaid credits | [AI gateway](/docs/ai-gateway.html) |
| Billing | One $20/month subscription plus a prepaid credit wallet | The console's **Billing** page |

Nothing here is a separate account. A user in your app is a row in your project's database, a file is in your project's bucket, and a deploy is your project's code — which is why "is this person subscribed?" is a query, not an integration.

## Two stacks

There are two systems and it helps to know which one you are looking at.

**The Camplax platform** — the marketing site, the console at `camplax.dev/app`, and the API your CLI and agents call at `camplax.dev/v1`. This is Camplax's own infrastructure: it stores your project's settings, secrets, deploy queue and domain config, and it orchestrates everything below. You never deploy code into it.

**Your app** — what your deploys produce. Each running app is its own isolated service: a small always-available compute instance, its own Postgres database, and its own slice of object storage, sitting behind Camplax's DNS and certificates. An idle app suspends and resumes in a few hundred milliseconds, so a quiet project costs nothing to keep warm.

The practical consequence: the console can be down and your app keeps serving; your app can be broken and the console still works to fix it.

*Fine print, for the curious: the control plane runs on Cloudflare Workers and D1; your app runs on Fly.io Machines with a Prisma Postgres database and Cloudflare R2 for files. For your app, Cloudflare is DNS, SSL and storage only — your code does not run on Workers.*

## Two domains, on purpose

The platform lives on `camplax.dev`. Your apps live on `camplax.app` — production at `https://your-app.camplax.app`, previews at `https://branch--your-app.camplax.app`, and your own custom domains pointed at the same place.

That split is a security boundary, not branding. A registrable domain is where cookies and browser storage stop: code running on `camplax.app` cannot set a cookie the console will read, cannot reach the platform's storage, and a problem on a customer domain can never touch `camplax.dev`. You will never be asked to do anything about it — just do not be surprised that the two never mix.

## Environments

Every project has two runtime environments, decided by the branch a deploy came from:

- **Production** — the default branch, serving `https://your-app.camplax.app` and any custom domains.
- **Preview** — every other branch, at `https://branch--your-app.camplax.app`, with its own compute, its own database branch, its own storage and its own secrets overlay. [Previews](/docs/previews.html) covers this in full.

Secrets have a third layer, **development**, which is what `camplax dev` pulls to your laptop. The merge order — production, then preview, then a per-branch override — is in [Secrets](/docs/secrets.html).

## Where each tool fits

| Tool | Where | What it is for |
| --- | --- | --- |
| Console | [camplax.dev/app](/app) | Creating projects, watching deploys, the database editor, domains, secrets, users, billing |
| CLI | `npm install -g camplax` | `login`, `link`, `dev`, `env pull`/`push`, `deploy`, `rollback`, `logs`, `db`, `probe` — see [CLI reference](/docs/cli.html) |
| API | `camplax.dev/v1` | Everything the console and CLI do, scriptable with a project API key |
| MCP | `https://camplax.dev/mcp` | The same verbs for AI agents — see [AI agents and MCP](/docs/agents-mcp.html) |
| These docs | `camplax.dev/docs` | What you are reading; also available as markdown mirrors and `llms.txt` |

The console and the CLI are two faces of the same system — a deploy started in one shows up in the other, and both sit on the same API the MCP server uses.

## Where to go next

- [Getting started](/docs/getting-started.html) — the ten-minute path to a live app.
- [Hosting and deploys](/docs/hosting-deploys.html) — the deploy lifecycle, hooks and rollback.
- [Secrets](/docs/secrets.html) — the vault, overlays, and the variables Camplax injects.

## Hosting and deploys
Source: https://camplax.dev/docs/hosting-deploys.md

Every Camplax project is a live app with its own URL from the first deploy: `https://<slug>.camplax.app`, with https handled for you. You get there by connecting a GitHub repo — or by shipping from the CLI.

## Connect a repo

In the console, open your project and go to **Deploys**, then connect the GitHub repo — this installs the Camplax GitHub App on the account or organisation that owns it. After that, a push to the default branch builds and deploys production. There is no pipeline file to write and no separate CDN to point at anything.

A few switches on the git settings change what a push does:

- **Auto-deploys** can be paused — pushes stop queueing deploys, while `camplax deploy` still works.
- **`[skip ci]`** in a commit message skips the deploy for that commit.
- **Verified commits** — when required, a push whose commit GitHub cannot verify is skipped rather than built.
- **Default branch**, **root directory** and **Git LFS** are set under **Settings → Git**.

Commit status is reported back to GitHub, so a deploy's progress is visible on the commit itself.

## Deploy from the terminal

```bash
camplax deploy
```

This queues a deploy of the connected repository and prints the deploy id. Useful flags:

```bash
camplax deploy --branch feature-x   # deploy a specific branch
camplax deploy --json               # machine-readable output for scripts and agents
```

The branch decides the environment: the project's default branch deploys to production, any other branch deploys to a preview. Add `--json` and the output is JSON with a `deployId`, `status`, `branch` and `environment`.

The same call exists on the API — `POST /v1/projects/:slug/deploys` with `{ "branch", "environment", "commitSha" }`, using a project key with the `deploy` scope or your CLI sign-in.

If the repo has a `camplax.json`, it is the source of truth for the project — name, region, build commands, env var names, database options, cron and domains. `camplax deploy` tells you when it is using one.

## Deploy a folder without GitHub

```bash
camplax deploy --local
```

Run from the project directory, this packs the folder into a tarball, uploads it, and queues a deploy that builds the upload instead of a repository. A repo never has to be connected — a fresh folder works, which is also how a coding agent can ship what it just wrote without setting up GitHub first.

What the upload carries is decided on your machine: `.gitignore` and `.camplaxignore` are honoured (`.camplaxignore` adds rules on top), and `.git`, `node_modules`, `.camplax` and `.env*` files are always left out — secrets live in the project's env vault, not in source uploads. The server secret-scans the archive anyway before storing it, caps it at 50 MB, and the builder fetches it through a token that only works for that one deploy.

`--local` follows the same branch rule: the default branch deploys to production, any other branch to a preview. The two API steps it performs are `PUT /v1/projects/:slug/source` (gzipped tar body, returns `sourceKey`) then `POST /v1/projects/:slug/deploys` with `{ "branch", "environment", "sourceKey" }`.

## What a deploy does

A deploy moves through `queued` → `building` → `deploying` → `ready` (or `failed`). The queue serialises production: only one production deploy runs at a time, and a second request gets a `production_deploy_busy` error to retry once it finishes. Pushes to the same preview branch cancel the older queued or building preview, so the newest commit is the one that ships.

A build produces a stored artifact; a rollout then points the app's compute at that artifact and waits for it to answer healthy — including its TLS certificate — before traffic moves and the deploy reports `ready`. A failed build leaves production untouched.

## Rollback

```bash
camplax rollback
```

With no argument this re-promotes the previous production build — the artifact already exists, so nothing is recompiled. Pass a deploy id to pick a specific one:

```bash
camplax rollback dep_abc123
```

The same rollback is a click on the deploy's row in the console, and `POST /v1/projects/:slug/deploys/:id/rollback` on the API. Either way, traffic moves back when the old build reports healthy — there is no rebuild and no downtime window.

> Rollback restores the build artifact, not the database. A very old deploy can also fail with `historical_rollback_unavailable` if its artifact was not retained — the error says so plainly.

## Deploy hooks

A deploy hook is a secret URL that queues a deploy — the way to trigger one from a CI job, a headless CMS publish button, or anything that can POST:

```bash
curl -X POST https://camplax.dev/v1/hooks/<token>
# → 202 { "ok": true, "deployId": "dep_…" }
```

Create one with `POST /v1/projects/:slug/deploys/hooks` — a project API key cannot mint hooks, so use a signed-in user token. The token (`cxhook_…`) is shown once — Camplax stores only its hash. A hook can be bound to a branch; unbound hooks deploy the default branch. Delete the hook to revoke it.

Anyone holding the URL can deploy, so treat it like a password. If you want more than a secret URL, sign the request body: send `X-Camplax-Signature: sha256=<hex>` (or `X-Hub-Signature-256`, the GitHub convention) where the hex is the HMAC-SHA256 of the raw request body, keyed by the hook token. A header that does not verify fails the request.

## Deploy logs

- In the console, every deploy row opens its full build log — `GET /v1/projects/:slug/deploys/:id/log` returns it as plain text, and `…/deploys/latest/log` always names the newest production attempt.
- `camplax logs` shows recent build, request and database logs; `camplax logs --tail` streams them.
- Failure emails carry a public link of the form `GET /v1/deploy-logs/:slug/:deployId?token=…` — a bearer token that reads exactly that one build log, so a teammate can see the failure without a console sign-in. The link is marked `noindex` and expires with its token.

Build, request and error logs sit on one timeline in the console, so "did it break in the build or at runtime" is one look, not two dashboards.

## The GitHub integration, in detail

GitHub calls `POST /v1/webhooks/github`. Each delivery is verified against the app's webhook secret (`X-Hub-Signature-256`, HMAC-SHA256) and deduplicated by delivery id, so retries cannot queue a deploy twice. Two event types matter:

- **`push`** — a push to the default branch queues a production deploy; a push to any other branch queues a preview. Re-deliveries of a commit that already has a production deploy are deduplicated, and a stale delivery that no longer matches the branch head is dropped.
- **`pull_request`** — opening, reopening or updating a PR creates the branch's database and deploys a preview; closing it tears the preview down — compute, DNS and the database branch together.

## Previews

Every branch that is not the default branch gets its own preview at its own URL. A preview is private to your project, and it never receives production credentials, database, files or secrets — a bad change on a branch cannot touch the live app. When you are happy with one, `POST /v1/projects/:slug/deploys/previews/:branch/promote` turns that branch into a production deploy.

Open a pull request, push the branch, and hand the preview link to someone before it is real. When the branch is deleted, the preview and its data go with it. The full picture — isolation, the optional password gate, and the database branch underneath — is in [Previews](/docs/previews.html).

## Domains and https

Every project starts on `<slug>.camplax.app` with a certificate already in place. Point your own domain at the project from **Domains** in the console and the certificate follows — see [Domains](/docs/domains.html). Customer apps live on `camplax.app`, which is deliberately separate from `camplax.dev`, where the console and API run — the reason is in [How Camplax works](/docs/how-camplax-works.html).

*Under the hood: your app runs on Fly.io Machines — it suspends when idle and resumes in a few hundred ms — behind Cloudflare DNS and SSL.*

## Previews
Source: https://camplax.dev/docs/previews.md

Push a branch that is not your default branch and Camplax deploys it to its own URL:

```text
https://<branch>--<slug>.camplax.app
```

`feature/login-redesign` on the project `my-app` becomes `https://feature-login-redesign--my-app.camplax.app`. The branch part is cleaned up to be a legal hostname — lowercased, punctuation turned to dashes — and a very long branch name is shortened with a small hash so two similar names cannot collide. The URL is in the deploy's output (`camplax deploy --json` prints it), on the **Deploys** page, and on the pull request.

## What a preview is

A preview is not a copy of the production site pointing at production data. It is a second, complete deployment of your app:

- **Its own compute.** The preview runs on its own instance, separate from the production app. It suspends when idle and wakes on the first request, so an unused preview does not burn anything.
- **Its own secrets.** A preview reads the production vault, then the preview layer, then any values you set for that specific branch — so `STRIPE_KEY` can be a test key on previews while production keeps the live one. The merge order is in [Secrets](/docs/secrets.html).
- **Its own database.** A preview runs against a database branch created for that git branch — never the production tables. More on this below.
- **Its own files.** Uploads on a preview go to a separate storage prefix, so a test upload cannot appear in the live app.

Because none of it is shared, a broken migration or a half-finished feature on a branch cannot touch the real thing.

## The database underneath

A preview deploy needs a database branch for its git branch, and it fails rather than fall back to production if none exists. Branches arrive two ways:

- **Automatically** — when a pull request opens (or is reopened or updated) on a connected repo, Camplax provisions the branch database before the preview deploy runs.
- **By hand** — on the **Database** page's branches tab, or `POST /v1/projects/:slug/database/branches`. Do this before `camplax deploy --branch <name>` on a project with no connected repo.

A branch database starts **empty** by default. Schema and seed data get there through your checked-in migrations and the seed tools on the [Database](/docs/database.html) page. When the pull request closes — merged or not — the preview's compute, DNS and database branch are torn down together, so the whole thing is disposable.

**Or start it with production's data.** If you want to test against something real, ask for a copy when the preview's environment is first created:

- `camplax deploy --branch <name> --with-data`, or `POST /v1/projects/:slug/deploys` with `{ "environment": "preview", "branch": "<name>", "withData": true }`.
- For a preview that already exists, **Recreate with production data** on the Database page's branches tab (or `POST /v1/projects/:slug/database/environments/<env-id>/recreate` with `{ "withData": true }`) tears it down and re-provisions it as a copy.

The copy is made from production's continuous backup, so it carries whatever was there moments ago — **raw, including rows, tokens and personal data; nothing is sanitized.** Ask for it only on previews that should have it, and remember anyone with the preview link can reach it. To inspect the copy directly, pick the environment in the selector at the top of the Database page.

## Locking previews behind a password

Previews are reachable by anyone who has the link. If you would rather they were not, open **Settings** and switch on **Password-protect previews**. Enabling generates a password, shown once — copy it and hand it to whoever reviews previews.

How it works:

- Every preview URL for the project shows a lock page before any of your code runs. A correct password sets an HttpOnly `cx_preview` cookie that lasts seven days, per preview host.
- Rotating the password invalidates everyone's cookies — the cookie's signature is keyed by the password hash.
- Production is never locked, and health-check paths stay open so the platform can still tell the app is alive.
- The lock takes effect on the next preview deploy — it is part of the app's environment, not a switch on live traffic.

The gate is also on the API: `GET`/`PUT /v1/projects/:slug/domains/preview-gate`, where `PUT` accepts `enabled`, `rotate` and `reveal`.

## Analytics and previews

Preview traffic is excluded from your analytics by default — a setting under **Analytics → settings** (`excludePreview`, on out of the box) — so a day of clicking through a branch does not bend your production graphs. Preview events that do arrive are kept separately and capped at 10,000 per month, against production's 250,000.

## Shipping a preview to production

Merging the PR is the normal path — the merge lands on the default branch and deploys production. To ship the branch as production without merging, promote it:

```text
POST /v1/projects/:slug/deploys/previews/<branch>/promote
```

## Good to know

- Preview URLs only exist on `*.camplax.app`. Custom domains always reach production — you cannot point a custom domain at a branch.
- The newest push wins: pushing again to the same branch cancels the preview still building and queues a fresh one.
- A preview is tied to the branch name, so a renamed branch is a new preview, not a moved one.

*Under the hood: each preview is its own cell environment — a Kubernetes namespace, its own Postgres database, its own R2 prefix and its own secrets scope — suspended when idle, deleted with the branch. A data copy is the database's WAL archive replayed into the preview's cluster, so the data never crosses a network boundary twice.*

## Domains
Source: https://camplax.dev/docs/domains.md

Every project is reachable from the first deploy at a free address:

```text
https://<slug>.camplax.app
```

That address is the project's primary domain — it always works, it already has a certificate, and it cannot be removed. Add your own domain when you want one.

## Add a custom domain

In the console, open **Domains** and add the hostname — `example.com`, `www.example.com` or `app.example.com`. Then at your DNS provider:

```text
CNAME  example.com  →  edge.camplax.app
```

`edge.camplax.app` is the Camplax edge your hostname points at. For an apex name (`example.com` with no subdomain) you need a DNS provider that supports CNAME flattening or an `ALIAS`/`ANAME` record — most do. A subdomain is a plain `CNAME` either way.

Back in the console, press **Verify**. Camplax asks Cloudflare for the hostname's live state and updates the row. A domain is `active` only when two things are both done: Cloudflare accepts the hostname **and** the certificate is issued. Until then the row reads `pending` — pressing Verify again is safe and is the normal way to watch it finish.

If Cloudflare needs proof you own the domain, it asks for a TXT record — the console shows the exact name and value to paste at your DNS provider, and Verify picks it up once it resolves.

## www and apex are a pair

Add `example.com` and Camplax creates both records — one canonical host that serves, and its twin that redirects to it. Adding `www.example.com` does the same in reverse. You choose which side is canonical when you add the domain; the default is `www` if you typed a `www.` hostname, otherwise the apex.

A subdomain like `app.example.com` is not paired with anything — it just serves.

Two rules worth knowing:

- **A hostname belongs to one project.** Adding a name another project already claims fails with `hostname_taken`.
- **Platform names are refused.** You cannot add a `camplax.app` or `camplax.dev` address, or a hostname that impersonates Camplax.

Adding, verifying and deleting domains is owner-only — a teammate role can look, but cannot move your traffic.

## The rest of the Domains page

The same page carries the edge controls that sit in front of every hostname on the project — `*.camplax.app` and custom domains alike.

### Firewall

Rules evaluated at the edge, before your app is woken. Start with the switches:

| Switch | What it does |
| --- | --- |
| Known bots and scrapers | Challenges clients with a Cloudflare threat score of 10 or more — on for new projects |
| High-risk IPs | Blocks requests with a threat score of 40 or more |
| SQL injection and XSS patterns | Blocks obvious attack patterns in the query string |
| Requests with no user agent | Blocks requests that present no `User-Agent` at all — on for new projects |
| Countries you do not serve | Blocks a list of ISO country codes you pick |
| Under Attack Mode | Challenges every visitor — the emergency switch |

Below the switches you can write custom rules — an expression like `http.request.uri.path matches "^/internal"` with an action (`block`, `challenge`, `managed_challenge`, `js_challenge`, `skip` or `log`). Rules run in order and the first match wins. Health checks are exempt, so the firewall cannot take down the monitor that would tell you it did.

### Rate limits

A limit is one sentence: *N requests per period from the same caller, then block for a while.* You pick the path, the count, the period, what counts as "the same caller" (IP address, an API-key header, a signed-in person, or IP plus path) and how long the block lasts — up to 10,000 requests per period and a day each for the period and the block. Because limits run at the edge, a limit on a sign-in route stops most credential stuffing before it costs you anything.

### Maintenance

One switch puts up a holding page — your own title and message, served as `503` (or `200` if you prefer). Three options matter: let **your own addresses** through so you can check the fix on the real site, keep **`/api/*` serving** so mobile apps and webhooks keep working, and set an **auto-off timer** (up to 24 hours) so the page cannot outlive the outage by being forgotten. Deploys, migrations and this console keep working while it is on, and every window is recorded in a history list.

### Preview gate

The switch that password-locks previews lives under **Settings → Password-protect previews**, because it is about the app, not a hostname. It is documented in [Previews](/docs/previews.html).

## The API

Everything above is on the API under `/v1/projects/:slug/domains`:

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/domains` | List the project's domains plus the CNAME target |
| `POST` | `/domains` | Add a hostname (owner only) |
| `POST` | `/domains/:id/verify` | Re-check hostname and certificate status |
| `DELETE` | `/domains/:id` | Remove a custom hostname (not the primary) |
| `GET`/`POST` | `/domains/firewall` | List and create firewall rules |
| `PUT` | `/domains/firewall/presets` | Toggle the preset switches |
| `GET`/`POST` | `/domains/rate-limits` | List and create rate limits |
| `GET`/`PUT` | `/domains/maintenance` | Read and set maintenance mode |
| `GET`/`PUT` | `/domains/preview-gate` | The preview password gate — see [Previews](/docs/previews.html) |

*Under the hood: custom domains are Cloudflare custom hostnames — Cloudflare issues the certificate and proves ownership, and `edge.camplax.app` is the fallback origin your CNAME points at.*

## Secrets
Source: https://camplax.dev/docs/secrets.md

Every project has a vault for environment variables: API keys, feature flags, anything your app reads from `process.env`. Values live in the vault, never in your repo, and reach the running app as environment variables — your code cannot tell the difference between a value you set and one Camplax injected.

## Three environments, plus branch overlays

Each key can exist once per environment — and, inside preview, once per git branch:

| Layer | Who reads it |
| --- | --- |
| `production` | The live app at `your-app.camplax.app` and your custom domains |
| `preview` | Every branch preview, overlaid on production |
| `preview` + branch | One specific branch's preview, overlaid on both |
| `development` | `camplax dev` and `camplax env pull` on your laptop, overlaid on production |

The merge order, with later layers winning:

- **Production:** production.
- **Preview:** production → preview → that branch's overlay.
- **Development:** production → development.

So one `STRIPE_KEY` in production, a test key under `preview`, and a different test key under `preview` + branch `feature/billing` gives you three apps on three sets of credentials without a single `if` in your code.

## Setting secrets

**In the console:** open **Settings → Secrets**. Add a key, pick the environments it applies to, optionally scope a preview value to a branch, and choose whether it is sensitive. Paste a whole `.env` file into the import box to load many at once — existing keys are updated, not duplicated.

**From the CLI:**

```bash
camplax env pull      # vault → local .env
camplax env push      # local .env → vault
```

`env pull` writes the development view — your development keys over any non-sensitive production keys, with managed names like `DATABASE_URL` left out. It is the one read that can reveal values, so it is owner-only; project keys cannot call it. `env push` uploads `KEY=value` lines to both the production and development layers, then restarts the app so the new values are live. It only accepts `UPPER_SNAKE_CASE` keys, skips empty values, and refuses keys Camplax manages — `DATABASE_URL`, `DIRECT_URL`, `BETTER_AUTH_SECRET`, `R2_PREFIX`, `AUTH_PROVIDERS`, `AUTH_CLAIMS`, `PORT` and anything starting `CAMPLAX_`. When `camplax.json` lists env names, push sends only those keys.

## Applying without a rebuild

Saving a secret does not bounce the app by itself. Ask for `apply` on the write, or run it explicitly:

```text
POST /v1/projects/:slug/settings/secrets/apply
```

This pushes the current vault into the running app's environment and restarts it — a few seconds of restart, not a rebuild. `camplax env push` does this for you at the end. If the app has not been deployed yet, the values are simply saved for the first deploy. One guarantee worth knowing: deleting a key in the vault really does remove it from the process — it does not linger as a stale value.

## The keys Camplax injects

Some environment variables are set by the platform on every deploy, and **they always win** — a vault entry with the same name is ignored. These are the ones your code will actually use:

| Variable | What it carries |
| --- | --- |
| `DATABASE_URL` | Pooled connection string for your project's database |
| `DIRECT_URL` | Direct (unpooled) connection string — for migrations and admin tools |
| `BETTER_AUTH_SECRET` | The sign-in secret your app's auth is built with |
| `CAMPLAX_URL` | This deployment's own URL — `https://your-app.camplax.app` or the preview host |
| `CAMPLAX_API_URL` | The Camplax API base URL |
| `CAMPLAX_PROJECT_ID` / `CAMPLAX_PROJECT_SLUG` | Which project this app is |
| `CAMPLAX_ENVIRONMENT` | `production` or `preview` |
| `CAMPLAX_GIT_COMMIT` / `CAMPLAX_GIT_BRANCH` / `CAMPLAX_DEPLOY_ID` | Exactly which code this is |
| `CAMPLAX_ANALYTICS_KEY` | The key your app sends analytics and errors with |
| `CAMPLAX_AI_API_KEY` | A project key scoped to `ai:invoke`, present once the AI gateway is enabled |
| `CAMPLAX_LOG_INGEST` / `CAMPLAX_LOG_INGEST_TOKEN` | Where and how the runtime ships its logs |
| `R2_PREFIX` | This environment's storage prefix |
| `PORT` | The port your server must listen on — `8080` |

A few more appear situationally — `CAMPLAX_REALTIME_TOKEN` on production, `CAMPLAX_PREVIEW_GATE_SHA256` on locked previews, `CAMPLAX_TURNSTILE_SITE_KEY` when bot checks are on, `AUTH_PROVIDERS` and `AUTH_CLAIMS` for sign-in configuration. Because the platform owns them, they cannot be set or edited in the vault — trying returns a `forbidden` error.

To match what hosted templates expect, Camplax also fills the usual framework aliases **only when you did not set them**: `POSTGRES_URL` from `DATABASE_URL`, `AUTH_SECRET` and `NEXTAUTH_SECRET` from `BETTER_AUTH_SECRET`, `AUTH_URL` and `NEXTAUTH_URL` from `CAMPLAX_URL`.

## Write-only, on purpose

A production or preview secret is sealed by default: the console shows it as dots, and it cannot be read back — not by the list endpoint, not by `env pull`, not by the reveal call. To change a sealed value you replace it. This is the same contract as Vercel's sensitive variables:

- **Sealing is one-way.** A sensitive variable cannot be made readable again; replace the value instead.
- **Development is the exception.** Development values are always readable, which is what lets `env pull` round-trip a working `.env`.
- **Reveal exists but is narrow.** `GET /settings/secrets/:key/reveal` returns a value only for a non-sensitive secret you set, only for owner and member roles, and every reveal is written to the project's activity log. Managed values can never be revealed.
- **MCP and project keys** can list secret names and presence — never values.

## The API

All under `/v1/projects/:slug/settings`, authenticated with a console session, a CLI token, or a project key holding `env:read` (reads) or `env:write` (writes):

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/secrets` | List keys — metadata only, never values |
| `GET` | `/secrets/for-build` | Which key names exist — for build and log surfaces |
| `POST` | `/secrets` | Set a key: `{ key, value, environments, branch, sensitive, apply }` |
| `POST` | `/secrets/import` | Bulk-upsert a pasted `.env` body |
| `POST` | `/secrets/apply` | Push the vault onto the running app and restart it |
| `POST` | `/secrets/redact` | Scrub known values out of a string — used on logs |
| `PATCH` | `/secrets/:key` | Replace a value or change sensitivity — `?environment=` / `?branch=` select the layer |
| `GET` | `/secrets/:key/reveal` | Read back a non-sensitive value (owner/member, logged) |
| `DELETE` | `/secrets/:key` | Delete a key from one layer |

*Under the hood: values live in a secrets KV namespace, one entry per key per layer; the platform database holds only names and flags.*

## Plax framework
Source: https://camplax.dev/docs/plax.md

Plax is the framework every `camplax create` app is built on — and the framework your coding agent writes in when it works on your repo. It is deliberately small: a Node HTTP server, owner-scoped resources, Better Auth sign-in, server-only AI, and in-app agents. What makes it different is not the feature list but the shape: there is one way to do each thing, the unsafe way fails closed, and the app can describe itself.

## Routes and auth

`createPlaxApp` takes a route table. Keys look like `GET /api/notes` or `GET /api/notes/:id` — `:param` segments capture into `ctx.params`. A route is either a plain handler (public) or `{ auth: true, handler }`, which resolves the Better Auth session before the handler runs and guarantees `ctx.user`.

```ts
const app = createPlaxApp({
  session: sessionLookup,
  staticDir: "public",
  routes: {
    "GET /probe": (_req, res) => sendOk(res, { ok: true }),
    "GET /api/me": {
      auth: true,
      handler: (_req, res, ctx) => sendOk(res, { user: ctx.user }),
    },
  },
});
```

An `auth: true` route without a session lookup throws at boot, not at request time. Errors thrown in handlers return a generic 500 — internal details are logged, never sent to the client.

## Resources — data with an owner

`defineResource` is the only way a Plax app touches a table. Every read, update and delete carries `ownerField = session.user.id` inside the query itself; the client cannot set `id` or the owner; only `fields` are writable; `required` and `validate` gate creates. A row you cannot see answers **404**, not 403.

`resource.routes()` mounts the whole REST surface — `GET`/`POST` on `/api/<name>`, `GET`/`PATCH`/`DELETE` on `/api/<name>/:id` — already auth-guarded, with `?limit`/`?offset` pagination capped by the store. `postgresStore` parameterizes every value and validates every configured identifier, so hand-built identifiers cannot inject SQL.

```ts
const notes = defineResource<Note>({
  name: "notes",
  fields: ["title"],
  required: ["title"],
  store: postgresStore<Note>({ client: db, table: "notes", columns: { /* … */ } }),
});

routes: { ...notes.routes() }
```

`camplax add resource <name>` writes the whole slice — the migration, the resource, the mount instructions — in one step.

## Agents inside your app

`agent()` defines an in-app agent: a name, a system prompt, tools, a thread store, and a budget. `agent.routes()` mounts `POST /api/<name>/chat`, auth-guarded like everything else.

```ts
const support = agent({
  name: "support",
  ai,
  system: "You help users with their orders.",
  tools: {
    lookupOrder: tool({
      args: { id: "string" },
      run: ({ id }, { user }) => orders.get(user, id),
    }),
  },
  threads: postgresThreads(db),
  budget: { capUsdPerDay: 0.10, store: postgresBudget(db) },
});
```

The loop is the standard one — model, tool calls, validated arguments, results, repeat — capped by `maxSteps`. Three properties come from the platform, not the framework:

- **Tools cannot cross users.** `lookupOrder` calls `orders.get(user, id)` — the resource layer scopes it, so user A's agent cannot return user B's rows.
- **Spend is capped per end-user.** The budget table is checked before every model call; `429 budget_exceeded` when spent, and your project's credit wallet still applies beneath it.
- **Dangerous tools pause.** `approval: { refundOrder: "user" }` returns `pendingApprovals`; the client POSTs `approve`/`deny` to resume. Threads persist, so the loop survives the round trip.

`camplax add agent <name>` writes the agent file and the threads/usage migration.

**Streaming.** POST `"stream": true` to `/api/<name>/chat` and the reply is server-sent events, token by token:

| Event | Carries |
| --- | --- |
| `thread` | the thread id, first |
| `reasoning_delta` | a chunk of the model's thinking, as it arrives |
| `message_delta` | a chunk of the reply text, as it arrives |
| `tool_call` / `tool_result` | a tool starting / finishing, with args and result |
| `approval_required` | the loop paused on an approval-gated tool |
| `message` | the assembled reply text |
| `error` / `done` | a typed failure / the end of the stream |

Reasoning models stream their thinking through `reasoning_delta`, and the reasoning is persisted on the thread and replayed on later turns so the model keeps its context. Without `"stream"`, the route answers one JSON reply — same loop, same envelope. Either way, closing the connection aborts the in-flight model call, so a disconnected client cannot keep burning credits.

**Any provider.** The `ai` in `agent({ ai })` is `createPlaxAi` — the Camplax gateway by default, but `chatUrl` + `token` point the same agent at any OpenAI-compatible endpoint (OpenAI, OpenRouter, Groq, Ollama, a LiteLLM proxy). The key stays server-side either way.

## The manifest

`app.manifest()` emits `plax.manifest.json` — routes with auth flags, resources with fields and tables, agents with tools and budgets, required and injected env vars, cron, plugins. It is the app's contract with everything around it:

- **Coding agents** read it instead of guessing the surface.
- **`camplax check`** validates the app against it — declared env present, resources wired, typecheck green — in one `--json` report.
- **The platform** can diff what an app says it needs against what the project provides.

The starter writes it at boot via `writeManifest(app.manifest(), root)`.

## Why it looks like this

Plax is built for the agent era in both directions. For the agents *writing* your app: one way per thing, a manifest to read, and `camplax check` as the feedback loop. For the agents *inside* your app: tools that cannot leak across owners, spend that stops at zero, and threads that are just rows in your own database. And it stays Camplax-shaped throughout — Better Auth on your own Postgres, AI through the gateway on prepaid credits, observability through the same-origin proxy.

## Database
Source: https://camplax.dev/docs/database.md

Every Camplax project gets its own Postgres database. It is real Postgres — not a proprietary layer with SQL painted on — so any Postgres client, ORM or migration tool you already know still works. It is always on, and it is yours alone: no other project can reach it.

## Two connection strings

Your app receives two:

| Variable | What it is for |
| --- | --- |
| `DATABASE_URL` | The pooled connection — what the app uses at runtime, built for many short server connections |
| `DIRECT_URL` | The direct connection — for migrations, admin tools and `psql`, which need a session of their own |

Both are injected by the platform and live in the vault, so you never paste either into code. The practical rule: the app reads `DATABASE_URL`; `prisma migrate`, `psql` and other long-lived clients use `DIRECT_URL`. See [Secrets](/docs/secrets.html) for how they are injected.

## The console database page

`camplax db studio` opens it, or find **Database** in the console sidebar. Five things live there:

- **Schema** — every table, column, type and relation, read live from the database.
- **Data** — a grid over any table: browse with sorting and pagination, add rows, edit cells, delete rows.
- **Migrations** — the apply history: each schema change recorded as a named snapshot with its SQL.
- **SQL** — a runner for hand-written queries, behind a guard that caps reads and asks before writes.
- **Branches** — the isolated databases that back previews, with create, reset and delete.

There is also a copilot: ask a question in English and it writes the SQL against your real schema — review the statement, then run it. And a **seed** button fills a table (and the empty parents it points at) with believable rows for testing.

## Migrations

Schema changes live in your repo, next to the code that needs them. `camplax db migrate` applies local SQL files to the project database. It looks in the usual places:

```text
prisma/migrations/<name>/migration.sql
drizzle/*.sql
sql/migrations/*.sql
migrations/*.sql
```

The app also runs its checked-in SQL at boot — applied on a fresh database, verified on a rollout — and a deploy only reports ready after a real read/write probe against the database has passed, so the schema and the app cannot drift apart silently.

### Editing the schema without SQL files

The schema tab doubles as an editor. Change the schema there and Camplax computes the diff, shows you the exact SQL it will run, and warns you about anything destructive or table-locking. Applying runs the statements as one transaction — a failure halfway leaves the schema exactly as it was. Anything that loses data needs a second, explicit confirmation, and each apply lands in the history list as a named snapshot (the last 50 are kept). One thing the editor cannot guess: renaming a column and dropping-plus-adding one look identical afterwards, so it asks you to say which you meant.

```bash
camplax gen types          # writes database.types.ts from the live schema
camplax gen types --out db-types.ts
```

## A database branch per preview

A preview never sees production data. Every preview runs against a **database branch** — a separate, isolated database on the same project. Branches are created automatically when a pull request opens on a connected repo, or by hand on the Database page / `POST /v1/projects/:slug/database/branches`. A preview deploy will not start without one; it fails rather than silently fall back to production.

Two things to know plainly:

- **A branch starts empty — unless you ask for production data.** Branches are empty by default; if you want real data in a preview, say so when you create it (`camplax deploy --branch <name> --with-data`, or **Recreate with production data** on the Database page's branches tab). It is a raw copy of production — rows, tokens and personal data come along verbatim, nothing is sanitized — so treat a data-carrying preview like production for access purposes.
- **A branch is disposable.** Reset recreates it empty; closing the PR deletes it along with the rest of the preview. See [Previews](/docs/previews.html).

## Point-in-time restore

Every database keeps a continuous backup (a WAL archive) for **7 days**. You can restore the production database to any moment inside that window:

- On the **Database** page's branches tab, pick a date and time under **Restore to a point in time**.
- Or `POST /v1/projects/:slug/database/pitr` with `{ "at": "<RFC 3339 timestamp>" }` — e.g. `{ "at": "2026-09-22T14:30:00Z" }`.

The restore lands as a **new, separate environment** — it never overwrites production. It appears under the branches list (branch name `restore-<timestamp>`), its database holds production's data as of that moment, and you can inspect it in the studio by picking it from the environment selector at the top of the Database page. When you are done with it, tear it down from the same list.

Two honest limits: the earliest restore point is the first completed daily base backup inside the window — a database younger than its first backup cannot rewind; and a restore of a large database can take a while, the environment shows as provisioning until the copy is queryable.

## Export and restore

You can take your data with you:

- **Export** — `GET /v1/projects/:slug/settings/export/database` downloads a `pg_dump` file of your database. Owner-only, and unavailable to project API keys: it is every row you have.
- **Restore a file** — `POST /v1/projects/:slug/settings/restore/database` accepts a `.dump` file up to 20 MB and restores it into a **new, isolated database** attached to the project. It does not overwrite your live database — you inspect the restored copy and decide what to do with it. For rewinding to a moment in time instead of a file, use point-in-time restore above.

## Rows stay with their owners

Access control lives in the app, not in a separate rules engine. In a Plax app — including the `camplax create` starter — every table is wrapped in `defineResource` with an `ownerField`, and each read, update and delete it serves is scoped to the signed-in user's id inside the query itself. The client cannot set that field; the server fills it from the session, and a row you cannot see answers 404. The starter's `notes` resource is the pattern to copy for new tables.

## One honest limit

There is no SQL-over-HTTP endpoint for app code: the SQL runner and copilot are console surfaces for you, not an API your app calls. Your app talks to its database over the normal Postgres protocol through `DATABASE_URL`.

*Under the hood: one Postgres database per environment on the app's cell, always on, behind the injected `DATABASE_URL` — with WAL continuously archived for the point-in-time window above.*

## Sign-in
Source: https://camplax.dev/docs/sign-in.md

Accounts are what turn a demo into an app, and the thing most first projects get wrong. On Camplax, sign-in arrives connected to the database: users land in a real table, sessions are handled properly, and the rules that keep people to their own rows are on from the start.

## What your app gets

- **Email and password** sign-in is on by default.
- **Passkeys** are on by default — a fingerprint, face or security key signs a user in, scoped to your app's domain.
- **Magic links and two-factor codes** are switches in the project settings.
- **Google, GitHub and Apple** sign-in when you add the provider's OAuth client credentials on the Providers tab.
- **A users table** you can query and join against — a user is a row, not a record in someone else's dashboard.

Your app serves the sign-in endpoints itself — under `/auth` on its own domain — using the sign-in library Camplax builds for it. Camplax supplies the pieces that make that safe: it injects `BETTER_AUTH_SECRET` (the signing secret), `AUTH_PROVIDERS` and `AUTH_CLAIMS` (which methods and session fields are on), plus `DATABASE_URL` and `CAMPLAX_URL`. You never set these by hand; they arrive with every deploy like the rest of the [Secrets](/docs/secrets.html).

## Where the data lives

Everything about your users — the `user`, `session`, `account` and verification tables — sits in **your project's own Postgres database**, the same one your tables are in. There is no third-party user pool to sync, and nothing about a user lives on a service you cannot query. When you delete the project, the identity data goes with it.

## The Users page

The console's **Users** page is five views of one subject — the people who signed in to your app (the people who *work on* the app are under Teams, a different list):

| Tab | What it shows |
| --- | --- |
| **People** | Every registered user — name, email, verified, plan, banned — with ban, unban and delete actions |
| **Sessions** | Who is signed in right now: device, IP, last seen, expiry — sessions that change address mid-stream are flagged |
| **Providers** | Which sign-in methods are on, and where OAuth client credentials go |
| **Test users** | Throwaway accounts for trying your own sign-in — see below |
| **Claims** | The fields stamped onto each session when it is created |

## Sessions

Sessions use HTTP-only cookies with rotation, so a stolen laptop is a revoked session in the console, not a breach. Session rows carry a snapshot of the user's claims — `role` and `plan` by default — taken when the session is created. Demote a user and the change applies when the session expires or is revoked; **Claims** is where you add your own (up to 32, `snake_case` keys, each reading a field off the user row).

## Test users

For previews and poking at your own app, **Test users** generates up to 25 accounts at once — names, `*@test.camplax.app` addresses (mail never delivers), a free/pro plan mix, and a password you can sign in with. Test users are marked `is_test` in the table and hidden from the People list unless you ask for them; one click clears them, their sessions and their accounts together.

## Managing users from code

Everything the page does is on the API under `/v1/projects/:slug/users`. Reads need a session or a project key with `users:read`; writes need `users:admin` (and destructive console actions are owner-only).

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/users` | List users (up to 100; `?include_test=1` includes test accounts) |
| `GET` | `/users/stats` | Total, banned and verified counts |
| `GET` | `/users/export.csv` | Download the user list as CSV |
| `GET` | `/users/sessions` | Live sessions with device, IP and flags |
| `DELETE` | `/users/sessions/:id` | Revoke one session |
| `POST` | `/users/sessions/revoke-all` | Revoke every session |
| `GET`/`PUT` | `/users/providers` | Read/set which sign-in methods are on |
| `GET`/`PUT` | `/users/claims` | Read/set session claim definitions |
| `GET`/`POST`/`DELETE` | `/users/test-users` | List, generate or clear test accounts |
| `POST` | `/users/:id/ban` / `/users/:id/unban` | Ban or restore a user |
| `POST` | `/users/:id/reset-password` | Reserved — returns not-implemented today; send the reset through your app's own `/auth` flow instead |
| `DELETE` | `/users/:id` | Delete the user, their sessions and their accounts |

Provider credentials are secrets, not settings — when you turn on Google, GitHub or Apple, the client id and secret are stored as `AUTH_GOOGLE_CLIENT_ID`/`AUTH_GOOGLE_CLIENT_SECRET` (and friends) in the vault, where [Secrets](/docs/secrets.html) rules apply.

## Rows stay with their owners

Sign-in is what makes "your data" mean something. In the starter — and any Plax app — `defineResource` takes an `ownerField`, and every read, update and delete it serves is scoped to the signed-in user's id inside the query itself, not checked afterwards. The client cannot write that field; the server sets it from the session, and a row you cannot see is a 404, not an error.

## The sign-in UI

You do not have to build the form. Paste-ready components ship with the CLI:

```bash
camplax add --list
camplax add sign-in-card sign-up-card magic-link-screen
```

Each `add` copies an editable component into your app — the markup is yours to keep.

## Where the users go next

Sign-in is the same project as the database, storage and payments, so "is this person subscribed?" and "which files are theirs?" are columns and rules, not API calls to another vendor. See [Payments](/docs/payments.html) and [File storage](/docs/storage.html).

*Under the hood: sign-in is Better Auth against your project's own database — no third-party user pool.*

## File storage
Source: https://camplax.dev/docs/storage.md

Profile pictures, receipts, PDFs — whatever your users send. Storage lives in the same project as sign-in, so a file can belong to a person without you wiring two services together.

## How an upload works

Your app asks the project API for an upload URL, then the file goes straight to storage — it does not pass through your server:

```text
POST /v1/projects/<slug>/storage/upload-url   →  { url, key, method: "PUT", expiresIn: 900 }
PUT  <the returned url>                       →  the file itself
```

The request body declares what you intend to receive — `filename`, `contentType`, `size` in bytes, and an optional `prefix` (default `uploads/`). The response's `url` is where the client PUTs the file, `key` is the storage key it lands at, and `expiresIn` is how many seconds the URL stays valid.

The URL is bound to what you declared, and the binding is enforced at the sink:

- It expires after 900 seconds and works once — a successful PUT burns the token.
- A declared `size` is enforced byte-for-byte; a different length is rejected.
- A declared `contentType` is enforced against the request and, for images and PDFs (JPEG, PNG, GIF, WebP, PDF), checked against the file's real bytes — not just the extension the caller claimed.
- Files larger than the folder's configured limit are refused before the URL is even issued.

So a leaked upload URL expires fast, and a renamed executable cannot pose as a photo.

## The storage endpoints

Everything lives under `/v1/projects/<slug>/storage`, with your console session or a [project API key](/docs/keys.html):

| Method | Path | Scope | What it does |
| --- | --- | --- | --- |
| `GET` | `/storage/stats` | `storage:read` | File count, total bytes, and the project prefix |
| `GET` | `/storage/files?prefix=…` | `storage:read` | List keys under a prefix — up to 200 per call, with a `truncated` flag |
| `POST` | `/storage/upload` | `storage:write` | Multipart upload (`file` field plus a `path`) through the API — for server-side files |
| `POST` | `/storage/upload-url` | `storage:write` | Mint the signed PUT URL described above |
| `DELETE` | `/storage/files/<path>` | `storage:write` | Delete one object |

Files are also listed, uploaded and deleted from the **Storage** page in the console, which uses these same routes.

## Where files live

Every file lands under your project's prefix on the object store — `R2_PREFIX` is injected into your app's environment at deploy time, so server code can always tell where its files are. Keys returned by the API (`uploads/photo.png`, say) are relative to that prefix.

## A bucket per environment

Previews write to their own bucket. A test upload on a branch cannot appear in your live app, and the preview's files are discarded with the branch.

## Private by default

There is no public URL for an object. Upload URLs are signed and expire; the same posture applies on the way out — your app decides what to serve and to whom, and shares links that stop working rather than a bucket anyone can browse.

## Limits

Storage is included in the $20 plan up to the launch cap, and heavier use draws prepaid credits — the same wallet as [AI](/docs/ai-gateway.html), so a spike stops at zero rather than billing your card.

*Under the hood: object storage on Cloudflare R2 — each environment and preview branch gets its own key prefix under the project's bucket.*

## AI gateway
Source: https://camplax.dev/docs/ai-gateway.md

One gateway, every major model, one key. Usage is priced in dollars you can read, and paid from a balance you loaded on purpose — so a runaway loop costs what is in the wallet and nothing more.

## Turn it on

In the console, open **Integrations** and enable the AI gateway. Then create a project API key under **Keys** with the `ai:invoke` scope. That key is the only credential your app needs.

Enabling also drops `CAMPLAX_AI_API_KEY` into your app's environment on the next deploy — a project key scoped to `ai:invoke` and nothing else — so a Plax app calls the gateway with no extra setup.

## Call a model

The gateway speaks the OpenAI shape, so existing code and SDKs work with a new base URL:

```bash
curl https://camplax.dev/v1/ai/chat/completions \
  -H "Authorization: Bearer $CAMPLAX_PROJECT_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openai/gpt-4.1-mini",
    "messages": [{ "role": "user", "content": "Say hi" }]
  }'
```

Endpoints:

| Method | Path | What it does |
| --- | --- | --- |
| `POST` | `/v1/ai/chat/completions` | Chat completions; `"stream": true` returns server-sent events |
| `POST` | `/v1/ai/embeddings` | Embeddings |
| `GET` | `/v1/ai/models` | Models this project may call |
| `GET` | `/v1/ai/providers` | Providers behind those models |

Model names are `provider/model` — swap `openai/gpt-4.1-mini` for another model with a string change. No new account, no new key, no new invoice.

## The SDK — `@camplax/ai`

For JavaScript and TypeScript apps, `@camplax/ai` wraps the gateway in two layers.

**A Vercel AI SDK provider**, so `streamText`, `generateText`, `Agent`, tool calls, approvals, embeddings and `useChat` all run against the gateway:

```ts
import { camplax } from "@camplax/ai";
import { streamText } from "ai";

const result = streamText({
  model: camplax("openai/gpt-4.1-mini"),
  messages,
});
```

**First-class support for Eve (by Vercel)** — Eve's `agent/agent.ts` accepts any AI SDK `LanguageModel`, so Eve agents run against Camplax AI Gateway on prepaid credits:

```ts
// agent/agent.ts
import { defineAgent } from "eve";
import { camplax } from "@camplax/ai";

export default defineAgent({
  model: camplax("openai/gpt-4.1-mini"),
});
```

Scaffold an Eve agent powered by `@camplax/ai` at any time with `camplax add agent <name> --eve`.

**A zero-dependency client** for places the AI SDK does not reach — an edge handler, a script, a non-TS service:

```ts
import { createCamplaxClient } from "@camplax/ai";

const ai = createCamplaxClient(); // reads CAMPLAX_AI_API_KEY

for await (const ev of ai.stream({ model: "openai/gpt-4.1-mini", messages })) {
  if (ev.type === "text") process.stdout.write(ev.text);
  if (ev.type === "done") console.log(ev.usage); // cost in dollars
}
```

Both read `CAMPLAX_AI_API_KEY` (injected when the gateway is enabled) and default to the gateway — and both take a `baseURL`/`endpoint` plus `apiKey` for any other OpenAI-compatible provider, so the same code shape follows the model wherever it lives. In a Plax app you do not need either — `plax.ai.chat` and `agent()` already stream through the gateway.

## The guardrails

Per project, in the console:

- **Allowed models** — the list a request may use; anything else is rejected.
- **Fallback models and retries** — if a provider is down or throttling, the gateway retries then walks the fallback list, and tells you it did with the `X-Camplax-Fallback-Index` response header.
- **A max-tokens cap** — applied server-side, so a client cannot ask for more.
- **A rate limit** — per project and per key; `429` responses carry `Retry-After`.
- **A hard spend cap** — the gateway's provider key stops answering at a dollar limit you set under **Policy**; requests past it fail rather than overrun.

Every request is logged with its model, latency and cost in dollars, so the console shows what a request cost in money rather than tokens-per-thousand.

## Reading the logs

Open a request row and you get the full picture: the model you asked for, the model that actually served (`servedModel` — it can differ when a fallback fired or the provider substituted), the provider, the key that called, and the token counts — prompt, completion, cached and reasoning tokens separately.

Timing is split so you can see where the wait went: total `latencyMs`, and for streamed requests `ttfbMs` (time to the first token) against `generationMs` (the rest of the stream). Cost is `costEstimateUsd` — real dollars per request, summed in the overview.

By default a log row is metadata only. Turn on **Record prompts and replies** in Policy (`logBodies`) to keep the prompt, the reply and the model's reasoning on each row — off by default, because those words are your users' data. Log rows are kept for the project's retention window and then purged by the retention sweep.

## When the money runs out

AI draws from prepaid credits — packs of $5, $20 or $50. At zero, AI calls fail cleanly with a typed error your app can catch, and everything else stays online. Nothing auto-charges, and the console warns you at 20% and again at 5% before it happens.

## In a Plax app

The starter's server helper reads `CAMPLAX_AI_API_KEY` and calls the same endpoint, keeping the key off the client:

```ts
const result = await plax.ai.chat({
  messages: [{ role: "user", content: "Summarise this note" }],
});
```

Call it only from a server route — spend uses your credits, and a browser key would hand them to anyone.

*Under the hood: the gateway routes through OpenRouter; you never see or manage that account.*

## Payments
Source: https://camplax.dev/docs/payments.md

The moment someone offers you money is where most side projects stall — payments usually means a new dashboard, new keys and new webhooks. On Camplax it is part of the project: you connect your own Stripe account, and checkout sessions are created against it.

## Connect your account

In the console, open **Payments** and choose **Connect**. Camplax hands you to Stripe's onboarding, where you tell Stripe who you are and where payouts go. The account is yours — charges, payouts and your customers' cards are on your Stripe account, not Camplax's.

You can refresh the connection state or disconnect entirely from the same page. Under the hood that is `POST /v1/projects/<slug>/payments/connect` (which returns a single-use onboarding URL), `/refresh` and `/disconnect` — all console-session calls.

## Create a checkout

From your app's server, ask the project API to create a hosted checkout session:

```text
POST /v1/projects/<slug>/payments/checkout
Authorization: Bearer <project key with payments:write>

{
  "currency": "usd",
  "lineItems": [{ "name": "Pro plan", "amountMinor": 900, "quantity": 1 }],
  "successUrl": "https://<slug>.camplax.app/welcome",
  "cancelUrl": "https://<slug>.camplax.app/pricing"
}
```

Amounts are in minor units — cents, not dollars. Line items take a `name`, `amountMinor` and optional `quantity`, up to 50 per checkout. `successUrl` and `cancelUrl` must be http(s) URLs Stripe can send the buyer back to, and `clientReferenceId` is an optional pointer back to your own user id.

The response is `{ "id", "url" }` — a Stripe Checkout URL: hosted, PCI-handled, with cards, wallets and local methods. You link to it; you do not build the page.

`GET /v1/projects/<slug>/payments/config` with a `payments:read` key returns the publishable key and the connected account id — exactly what Stripe.js needs on your frontend, and deliberately non-secret.

## Same project, same people

A paying customer is a signed-in user, so "is this person subscribed?" is a column you can query, not an API call to somewhere else. Pass the user's id as `clientReferenceId` on the checkout session and the payment lands attached to the right account.

## What your app hears back

When a checkout completes, Camplax enqueues an `order.paid` event on your [project webhooks](/docs/webhooks.html) with the session id, amount, currency and your `clientReferenceId`. A refund fires `order.refunded`. Stripe's own account events keep the connection state fresh in the console — including marking the account disconnected if it is ever deauthorized on Stripe's side.

## Test and live

A project's test-mode flag decides whether Stripe runs live or test. It is read from the project row on every call — never from the request — which is what keeps preview and branch deploys off real money.

## What it costs you

Your prices are yours, and Stripe's processing fees are between you and Stripe on your own account. Camplax takes a flat **2% platform fee** on payments your app takes through checkout — it appears on your Stripe balance transactions as the application fee, not as a separate invoice.

*Under the hood: Stripe Connect — onboarding, account status and Checkout Sessions on your connected account.*

## Email
Source: https://camplax.dev/docs/email.md

Every project has an Email page in the console: a template builder, a delivery log, stats, and a suppression list. Mail goes out through Amazon SES on the project's sender address — each project under its own SES tenant, so one bad sender cannot burn the shared reputation.

One honest limit up front: **templates and their test sends are console-only.** The one place your app's code (or a `manage`-scoped key) can send is the mailbox — `POST /v1/projects/<slug>/email/inboxes/<id>/send` takes freeform `to`/`subject`/`text`/`html`, but the `from` is always the inbox's own address, and sends are capped at 100 per inbox per day. It is a mailbox, not a bulk-mailer.

## Templates

The builder stores templates as blocks plus rendered HTML, with `{{merge_tags}}` filled in at send time. Four system templates are seeded the first time you open the page — **Welcome**, **Password reset**, **Magic link** and **Receipt** (off by default, since Stripe already sends one). Each template has an on/off switch, and the list shows its merge tags plus lifetime sent and open counts.

`POST /v1/projects/<slug>/email/preview` renders a template — or a draft subject and HTML — against sample data so you can check the merge tags before saving.

## Test sends

**Send test** mails one saved template to your own signed-in address. That is deliberate, and the API enforces it: `POST /v1/projects/<slug>/email/send` takes a `templateKey` or `templateId` and merge-tag `data`, and nothing else — a request that passes `subject`, `html` or `from` is rejected, and the recipient is always the signed-in user, never an address from the request. A test send exercises the real provider, so it lands in your real inbox and your real log.

## Delivery

Every send is a row in the log (`GET /v1/projects/<slug>/email/logs`, filterable by status) — `queued`, `sent`, `delivered`, `opened`, `deferred`, `bounced`, `complained`, `failed` or `suppressed`, with the provider id and any error attached. `GET /email/stats` rolls the last 30 days into sent, delivered, open and bounce numbers.

SES reports delivery events back through SNS to `POST /v1/webhooks/ses`, and they update the matching send row. A hard bounce or a spam complaint does more than mark the row — it puts the address on the **suppression list**, and later sends to it are refused before they reach the provider. Soft bounces only update the log. You can also add or check addresses yourself: `GET`/`POST /email/suppressions` and `GET /email/suppressions/check?address=…`.

## Inbound mail — mailboxes

`POST /email/inboxes` claims an address like `support@inbound.camplax.app` (or `support+tag@…` — `+tag` folds into the base inbox). Local parts follow the public-name rules, a project may hold at most **100**, and a local part that matches another project's slug is refused — that would hijack their legacy agent-mail channel.

Every inbox is **locked** by default: only DKIM-authenticated mail from project owners or the allowlist is delivered. Set `locked: false` to accept any sender — the blocklist still applies, and it matches the envelope `MAIL FROM` too, so a spoofed `From:` header can't slip past it. Per-inbox `GET`/`POST /lists` manage the allow/block entries (a full address, or `@domain`/`domain` including subdomains).

Delivered mail becomes thread + message rows — `GET /inboxes/:id/threads` and `GET /inboxes/:id/messages` are newest-first and page with `?limit=` (default 50, max 200) and `?before=<epoch ms>`; add `?summary=1` for snippet-only list payloads. Replies to a sender's `In-Reply-To`/`References` land in the existing thread. The full `.eml` — attachments, every header — sits in R2 and streams from `GET /messages/:id/raw`.

Mail the policy turns away is not invisible: `GET /inboxes/:id/rejections` lists each drop with its reason (`blocklist`, `unauthenticated`, `not_allowlisted`, `inbox_disabled`), and `GET /rejections/:id/raw` serves the original `.eml`.

An inbox's `webhookUrl` gets a signed `email.received` POST per delivered message — same Stripe-style `Camplax-Signature` scheme as [webhooks](/docs/webhooks.html), signed with the inbox's `whsec_…` secret (shown once at create or `POST /inboxes/:id/rotate-secret`). That endpoint is managed only through these routes — it never appears in the project webhook list.

`DELETE /inboxes/:id` removes the inbox, its threads, messages, rejections, lists and webhook endpoint, then sweeps its `.eml` objects — the address is released.

### Replies

`POST /inboxes/:id/send` sends freeform mail *from the inbox address only*. Pass `threadId` to reply inside a thread — the RFC `In-Reply-To`/`References` headers go out on the wire, so the recipient's client threads it too. With no `threadId`, a message to an existing counterpart joins that thread; otherwise it opens a new one. Merge tags are not expanded in mailbox sends — `{{thing}}` is literal text.

### SMTP logins

An inbox is also an SMTP account. `POST /email/inboxes/:id/smtp` creates a login on the mail server and answers `{smtp: {host, port, username, password}}` — the password is shown **once**, so put it straight into your app's secrets. The inbox's `smtp_enabled` flag flips on. Hand those four values to an app or an agent and it signs in on port 465 (TLS) or 587 (STARTTLS) and sends *as the inbox address* — the server refuses a `From:` that is not the login. `POST /email/inboxes/:id/smtp/rotate` mints a new password (shown once, same `smtp` shape) and kills the old one.

Mail sent this way still relays through SES and still lands in the log, the thread, and the same 100/day cap — it is the same send path with a different door.

### The legacy path

Mail to `{slug}@inbound.camplax.app` / `agent+{slug}@…` with no inbox claiming it falls back to the old behavior: the raw `.eml` lands under `projects/<slug>/email/` in storage — but only when the slug maps to a project; mail to unmapped addresses is dropped.

## Custom domains

A project can bring its own mail domain instead of sharing `inbound.camplax.app`. `POST /email/domains` registers a hostname like `mail.example.com` and answers the DNS records it needs: three DKIM CNAMEs (SES's Easy DKIM, so outbound is signed as your domain), an MX pointing at the Camplax mail host, and a `_camplax` TXT that proves you own the domain. `GET /email/domains` lists them with their `status` — `pending`, `verified` or `failed` — and which checks have passed so far.

`POST /email/domains/:id/verify` re-runs the checks: the `_camplax` TXT for ownership, the MX for receiving, the DKIM CNAMEs for sending. Verified means both directions are live — new inboxes can then pass `domainId` to take an address on your domain (`support@mail.example.com`), and mail to it arrives through the same hook into the same threads and the same Mailbox tab.

## The Mailbox tab

The console's Mailbox tab is the whole feature in one place: the inbox list (create, the locked switch, an SMTP login, delete), the thread list with a reading pane and a reply box, each inbox's allow/block lists, the rejections log with the raw `.eml` of everything policy turned away, and the domains list with its DNS records and verify button. All of it goes through the routes below — the tab adds nothing the API does not have.

## The endpoints

All of it is console-session routes under `/v1/projects/<slug>/email`:

| Method | Path | What it does |
| --- | --- | --- |
| `GET`/`POST` | `/email/templates` | List or create templates |
| `PATCH` | `/email/templates/:id` | Edit subject, body, blocks or the enabled switch |
| `POST` | `/email/preview` | Render merge tags against sample data |
| `POST` | `/email/send` | Test-send a saved template to yourself |
| `GET` | `/email/logs` | Delivery log, newest first |
| `GET` | `/email/stats` | 30-day totals and rates |
| `GET`/`POST` | `/email/suppressions` | List or add suppressed addresses |
| `GET` | `/email/suppressions/check` | Is this address suppressed? |
| `GET`/`POST` | `/email/inboxes` | List or create inboxes (max 100 per project; `domainId` for a custom domain) |
| `PATCH`/`DELETE` | `/email/inboxes/:id` | Rename, retarget the webhook, toggle locked/status, or delete |
| `POST` | `/email/inboxes/:id/rotate-secret` | New `whsec_…`, shown once — also revives an auto-disabled webhook |
| `POST` | `/email/inboxes/:id/smtp` | Create the SMTP login — `{smtp: {host, port, username, password}}` shown once |
| `POST` | `/email/inboxes/:id/smtp/rotate` | New SMTP password, shown once; the old one dies |
| `GET`/`POST` | `/email/domains` | List custom mail domains, or register one and get its DNS records |
| `POST` | `/email/domains/:id/verify` | Re-check the `_camplax` TXT, MX and DKIM records |
| `GET` | `/email/inboxes/:id/threads` | Threads, newest first (`?limit`, `?before`) |
| `GET` | `/email/inboxes/:id/messages` | Messages (`?limit`, `?before`, `?summary=1`) |
| `GET` | `/email/threads/:id/messages` | One thread's messages in order |
| `GET` | `/email/messages/:id/raw` | The stored `.eml`, as `message/rfc822` |
| `GET` | `/email/inboxes/:id/rejections` | Mail the inbox's policy dropped, and why |
| `GET` | `/email/rejections/:id/raw` | A rejected mail's raw `.eml` |
| `GET`/`POST` | `/email/inboxes/:id/lists` | List or add allow/block entries |
| `DELETE` | `/email/inboxes/:id/lists/:listId` | Remove an entry |
| `POST` | `/email/inboxes/:id/send` | Send freeform mail from the inbox address (≤100/day) |

*Under the hood: SES when the `SES_*` keys are set (each send under a per-project tenant), then Resend, then the Cloudflare Email Sending binding, and a stub that records without sending when none exists. Inbound has two doors — the Cloudflare Email Routing rule on the zone, and the Stalwart mail box on `mx1.camplax.app` posting to the same hook — and both feed the one store: D1 rows for inboxes/threads/messages, the raw `.eml` in R2.*

## Cron and workflows
Source: https://camplax.dev/docs/cron.md

A cron job is a timer that calls your app. You give it a schedule and a path; every minute the scheduler checks which jobs are due and POSTs the ones that are. No worker process, no extra service to keep alive.

## The schedule

Expressions are a classic five-field crontab, evaluated in UTC:

```text
┌───────────── minute        0–59
│ ┌───────────── hour        0–23
│ │ ┌───────────── day       1–31
│ │ │ ┌───────────── month   1–12
│ │ │ │ ┌───────────── weekday 0–6 (0 is Sunday)
* * * * *
```

Each field takes `*`, a number, a list (`1,15`), a range (`9-17`), or a step (`*/5`, `9-17/2`). `0 9 * * 1` is 09:00 UTC every Monday; `*/15 * * * *` is every fifteen minutes.

Unsure what an expression does? `POST /v1/projects/<slug>/cron/preview` with `{ "expression": "…" }` returns the next five fire times — the console's planner calls the same route as you type.

## What your endpoint receives

A job's endpoint is a path on your own app — `/jobs/digest`, not a URL. Absolute URLs are rejected, so a job can only ever call `https://<slug>.camplax.app`:

```text
POST https://<slug>.camplax.app/jobs/digest
User-Agent: Camplax-Cron/1.0
Camplax-Cron-Job: crn_…
Camplax-Cron-Run: run_…
Content-Type: application/json

{ "jobId": "…", "runId": "…", "name": "Daily digest", "expression": "0 9 * * *", "firedAt": "…" }
```

A scheduled run gives your endpoint **30 seconds** before it is marked failed; a `2xx` is a success and anything else is a failure with the HTTP status recorded.

One thing to know: the request is **not signed**. The headers tell you which job fired, but anyone on the internet can POST to your path. If the endpoint does something sensitive, protect it yourself — a secret path, or a token in the path or a header your code checks.

## Managing jobs

In the console's **Cron** page you can create, edit, pause and delete jobs. The same thing over the API, with your console session:

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/v1/projects/<slug>/cron/jobs` | List jobs with last and next run times |
| `POST` | `/v1/projects/<slug>/cron/jobs` | Create — `{ name, expression, endpoint, enabled? }` |
| `PATCH` | `/v1/projects/<slug>/cron/jobs/:id` | Edit, or pause with `{ "enabled": false }` |
| `DELETE` | `/v1/projects/<slug>/cron/jobs/:id` | Delete |
| `POST` | `/v1/projects/<slug>/cron/preview` | Next five fire times for an expression |
| `GET` | `/v1/projects/<slug>/cron/runs` | Run history — `?status=ok\|fail\|running`, up to 200 rows |

Every run is recorded with its status, duration and error, and the runs list rolls up today's counts and median duration. A failed cron run is not retried — the schedule fires it again next time. For retries you want a workflow.

## Workflows

A workflow is an ordered list of steps, each one an endpoint on your app with its own reliability settings:

- **Attempts** — up to 10 tries per step.
- **Backoff** — none, fixed 2s, exponential 2s→32s, or a longer exponential 5s→5m.
- **Timeout** — per step, 5s to 5m.
- **Dead-letter** — a step that exhausts its attempts can park on the dead-letter queue instead of vanishing.

Create one with `POST /v1/projects/<slug>/cron/workflows`, start a run with `POST /cron/workflows/:id/run`, and tune a step later with `PATCH /cron/workflows/steps/:id`. Workflow runs appear in the same runs list — and those can be retried with `POST /cron/runs/:id/retry`.

## Queues

The **Queues** tab shows named queues with waiting, in-flight and per-minute throughput. Messages that exhaust their retries land on the dead-letter queue; once you have fixed the cause, `POST /v1/projects/<slug>/cron/queues/dead-letter/replay` moves every waiting dead-letter message back onto a live queue. `GET /cron/queues` and `GET /cron/queues/:id/messages` let you inspect what is parked.

Runs also show up in the project's [log stream](/docs/logs.html), next to deploy output and runtime lines.

*Under the hood: a Durable Object ticks every minute, due jobs go through a Cloudflare Queue, and every run lands in the `cron_runs` table.*

## Realtime
Source: https://camplax.dev/docs/realtime.md

Realtime is a named channel your users can subscribe to. Changes fan out to every connected client in milliseconds — and there is no socket server for you to run, because the connection terminates at the platform and your app only publishes.

## Subscribe

Clients connect on **your app's own domain**, over server-sent events or a WebSocket:

```text
https://<slug>.camplax.app/v1/realtime/sse?channel=orders:insert
wss://<slug>.camplax.app/v1/realtime/ws?channel=orders:insert
```

A browser subscribes with one line:

```ts
const events = new EventSource(
  "https://<slug>.camplax.app/v1/realtime/sse?channel=orders:insert",
);
events.onmessage = (e) => console.log(JSON.parse(e.data));
```

Subscribers authenticate with a `Bearer cx_…` [project API key](/docs/keys.html), or with the console session cookie — which is how the console's own live viewer works. A browser `EventSource` cannot set headers, so for end users you either subscribe server-side or expose the stream through a route on your app.

## Publish

Anywhere you hold a credential — your server, a cron job, the console's publish button — POST to the notify endpoint:

```text
POST https://<slug>.camplax.app/v1/realtime/notify
Authorization: Bearer <cx_ project key or CAMPLAX_REALTIME_TOKEN>
Content-Type: application/json

{ "channel": "orders:insert", "type": "row", "event": "insert", "row": { "id": 42 } }
```

Your production app already carries `CAMPLAX_REALTIME_TOKEN` in its environment — a long-lived capability token that can publish to this project's channels and nothing else, so server code needs no extra key. Preview projects do not get one: previews cannot publish into production channels.

Publish accepts either a channel message (`channel` plus a `row` or `payload`) or a table-change shape (`table` and `op`) that fans out to every matching channel.

## Table-change triggers

This is the part that makes it feel like the database is live. In the console you create a channel bound to a table and the operations you care about — `insert`, `update`, `delete`, or `*`. Enabling it installs a trigger on that table in your app's database, and from then on every row change publishes to the channel automatically. Channel names match the binding: `orders:insert` gets inserts, `orders:*` gets everything. Disable or delete the channel and the trigger comes out again.

## What a client sees

On connect, a `{"type":"connected","channel":"…"}` hello. Then one JSON message per change:

```json
{ "type": "row", "channel": "orders:insert", "event": "insert", "table": "orders", "row": { "id": 42 }, "at": 1736… }
```

The stream sends a ping every 25 seconds to keep proxies quiet. If a client falls behind, it is not buffered — it gets the latest message and the intermediate ones are counted as dropped, which the console shows per channel.

## In the console

The **Realtime** page lists channels with live stats — connected clients, messages per minute, today's peak, drops, average fan-out time — and shows both URL templates. Channels are created, toggled and deleted there, or over the API at `GET`/`POST` `/v1/projects/<slug>/realtime`, `PATCH`/`DELETE` `/realtime/:id`, all with your console session.

*Under the hood: one Durable Object per channel holds the connections; row changes come from Postgres triggers on your own tables.*

## Webhooks and deploy hooks
Source: https://camplax.dev/docs/webhooks.md

"Webhooks" covers two different things here, and they face opposite directions:

- **Project webhooks** — Camplax calls *your* HTTPS endpoint when something happens in the project.
- **Deploy hooks** — *you* call a secret Camplax URL to trigger a deploy.

## Project webhooks (outbound)

Create an endpoint in the console, or with `POST /v1/projects/<slug>/webhooks`:

```json
{ "url": "https://your-app.example.com/hooks/camplax", "events": ["order.paid", "deploy.*"], "maxAttempts": 6 }
```

The URL must be `https`, and private or loopback hosts are refused. The response includes the endpoint's **secret** — `whsec_…`, shown once. Store it; you need it to verify deliveries. An empty `events` list means every event; a trailing `*` subscribes to a family.

The events a project can emit:

| Event | Fires when |
| --- | --- |
| `user.created`, `user.deleted` | An account is added or removed |
| `order.created`, `order.paid`, `order.refunded` | A payment moves — see [Payments](/docs/payments.html) |
| `db.row.created`, `db.row.updated`, `db.row.deleted` | A watched table changes |
| `deploy.succeeded`, `deploy.failed` | A deploy finishes either way |
| `email.received` | Mail lands in one of the project's [mailbox](/docs/email.html) inboxes |
| `webhook.test` | The test button or `POST /test` |

`email.received` is opt-in only: catch-all endpoints (`events: []`) never receive it — a project endpoint must name `email.received` or `email.*` explicitly. Its payload is `{ inbox, thread, message }` — the message carries `from`/`fromName`, `to`, `subject`, capped `text`/`html` (plus a `truncated` flag), `attachments` metadata, `senderAuthenticated` (aligned-DKIM verdict), `rfcMessageId`, and `rawKey` pointing at the stored `.eml`. An inbox's own `webhookUrl` rides this same pipeline and signing scheme, signed with the inbox's `whsec_…` secret — but that endpoint is managed through the [mailbox routes](/docs/email.html) only, and never appears in your project webhook list.

### Verifying a delivery

Deliveries are POSTs with a JSON body `{ "event", "data", "sentAt" }` and a signature header:

```text
Camplax-Signature: t=1736…,v1=9c86…
User-Agent: Camplax-Webhooks/1.0
```

The scheme is Stripe's: `v1` is the hex HMAC-SHA256 of `<t>.<raw body>` keyed with your endpoint secret. The timestamp is inside the signed string, so a captured delivery cannot be replayed with a fresh `t` — anything more than five minutes old fails anyway. While a secret is being rotated the header can carry several `v1` digests; accepting any one that verifies keeps you up through the overlap.

```ts
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
const expected = hmacSha256Hex(secret, `${t}.${rawBody}`);
// compare expected to each v1, then check t is within 5 minutes
```

### Retries, dead-letters, replay

Your endpoint gets **10 seconds** per attempt; only the status code matters — the response body is never read. Failures back off roughly 10s, 40s, 3m, 11m, 43m with jitter (up to `maxAttempts`, 1–10, default 6), and a minute-by-minute sweep picks up whatever fell due. After the last attempt the delivery is **dead-lettered**, not lost — it sits in the delivery log with its error.

`GET /v1/projects/<slug>/webhooks/:id/deliveries` shows the last 100 attempts per endpoint. `POST /deliveries/:id/replay` resets a delivery to pending with a fresh attempt budget. `POST /test` fires a `webhook.test` event — the one endpoint call a `webhooks:send` API key may make; everything else here is console-session only. `POST /:id/rotate-secret` mints a new `whsec_…`, shown once.

## Deploy hooks (inbound)

A deploy hook is a secret URL that queues a deploy — for CI systems, other hosts' build pipelines, or a button anywhere:

```text
POST https://camplax.dev/v1/hooks/cxhook_…
```

Create one in the console or with `POST /v1/projects/<slug>/deploys/hooks` (`GET` lists, `DELETE` removes). The `cxhook_…` token is shown once and only its hash is stored. Each hook is bound to a branch: matching the project's default branch queues a production deploy, any other branch a [preview](/docs/previews.html). The answer is `202` with the `deployId`, or `409` while a production deploy is already running — the URL is the secret, so anyone holding it can deploy.

If the caller can sign, send `X-Hub-Signature-256: sha256=<hex>` — an HMAC-SHA256 of the raw body keyed with the hook token itself (`X-Camplax-Signature` works too). With no header the token alone is the check.

## The platform's own inbound hooks

For completeness — these exist so external providers can reach Camplax, and are not yours to call: `POST /v1/webhooks/github` (GitHub events, signature-checked), `POST /v1/webhooks/stripe` and `/stripe/connect` (billing and connected-account events), and `POST /v1/webhooks/resend` (email delivery events — see [Email](/docs/email.html)). Each verifies its provider's signature; the signature is the auth.

## Feature flags
Source: https://camplax.dev/docs/feature-flags.md

A feature flag is a named switch with a rollout. Your app asks one endpoint which flags are on for this user, and the answer is a map of booleans — no SDK to install, no local state to sync.

## A flag

Each flag has a key (`new_checkout` — lowercase, 2–64 characters), an on/off switch, and three kinds of targeting:

- **Rollout percent** — 0 to 100. Users are bucketed deterministically on a hash of the project, the flag and the user's id, so the same person always lands on the same side of a rollout.
- **Environments** — `production`, `preview`, or both. A flag that is off for production returns `false` there no matter what the rollout says.
- **Allowed user ids** — up to 100 ids that always get the flag, ahead of the percentage.

A project can hold **50 flags**. Create and edit them in the console under **Flags**, or over the session API at `GET`/`POST` `/v1/projects/<slug>/flags`, `PATCH`/`DELETE` `/flags/:id`.

## Evaluating

```text
GET /v1/flags/i?userId=user_123&distinctId=anon_abc
x-camplax-analytics-key: cak_live_…
```

```json
{ "flags": { "new_checkout": true, "beta_nav": false }, "ttlSec": 30 }
```

Authenticate with the project's public ingest key (`cak_live_…`), sent either as `x-camplax-analytics-key` or `Authorization: Bearer` — the same key [analytics](/docs/analytics.html) and [errors](/docs/errors.html) use, and the one injected into your app as `CAMPLAX_ANALYTICS_KEY`. Because it is a publishable key, it is safe to call from a browser.

Pass `userId` when the user is signed in — evaluation prefers it, so signing in does not rebucket a person. Anonymous visitors are bucketed on `distinctId`. Send both when you have both.

Two things the endpoint guarantees: the environment is stamped server-side (a caller cannot claim to be `preview` or `production` — it comes from where the request actually ran), and the allowlist is never returned. The response is a plain key→boolean map, cacheable for `ttlSec` (30s). It is rate-limited to 120 evaluations per minute per key and IP.

## From cx.js

If your app already serves the [analytics snippet](/docs/analytics.html), flags are one call away — `cx.js` fetches `/v1/flags/i` for you and caches for the TTL:

```ts
if (await window.cx.flags.get("new_checkout")) { /* … */ }
const all = await window.cx.flags.all();
```

## What flags are not

They are booleans, not remote config — a flag answers on or off, never a value. If you need a string or a number per user, that belongs in your [database](/docs/database.html) or [secrets](/docs/secrets.html).

## Widgets
Source: https://camplax.dev/docs/widgets.md

Five small features that every app wants and nobody wants to build: a feedback box, a waitlist, a changelog, a per-user notification inbox, and a chat box. Each is a hosted script plus a public endpoint on the platform — the console's **Widgets** page hands you a ready-made snippet per widget.

## Adding one

```html
<script src="https://camplax.dev/cdn/feedback.js"
  data-project="<slug>" data-api="<api origin>"
  data-key="wx_…" data-turnstile-sitekey="…" data-screenshot></script>
```

Paste the snippet where the widget should live. Some widgets also need a mount point — `<div id="camplax-waitlist">`, `<div id="camplax-changelog">`, `<div id="camplax-inbox">` — and the snippet says so. The `data-key` is your project's **publication key**: deliberately public, it only binds the embed to your project. Anonymous writes are protected the real way — the Origin must be a hostname that is live for your project, every action is rate-limited per IP, and each mutation needs an invisible Turnstile token the script obtains itself.

Server-side calls skip the publication key entirely and use a [project API key](/docs/keys.html) with `widgets:read` or `widgets:write` scope.

## The five

**Feedback.** A bug / idea / praise box with an optional 1–5 rating, email and screenshot. Entries land on the Widgets page and emit a `$feedback` event into [analytics](/docs/analytics.html).

**Waitlist.** Email capture with a queue position. Optional double opt-in (the widget mails a confirm link that lands on a hosted confirm page), disposable-domain blocking, referral ids, and a `showPosition` switch. A confirmed join emits `$waitlist_join`.

**Changelog.** You write entries in Markdown on the Widgets page — publish and unpublish per entry — and the widget renders sanitized HTML from `GET /v1/w/<slug>/changelog`. Fifty entries per fetch.

**Inbox.** Per-user notifications. The sensitive part is done properly: your server mints a 15-minute per-user token with `POST /v1/w/<slug>/inbox/access-token` (a `widgets:write` key — which never reaches the browser), and the script reads only that user's items with `GET /v1/w/<slug>/inbox`. Create notifications server-side with `POST /v1/w/<slug>/inbox` (`user`, `title`, optional `body` and `href`).

**Chat.** A visitor message box that stores threads and replies politely — today the reply is a canned acknowledgment (escalation wording is a setting). Honest scope: it records the conversation; it is not yet a support agent.

There is also a **dropzone** script — file upload that uses the same signed upload-URL machinery as [storage](/docs/storage.html), bound to the size and type you allow, with active formats (SVG, HTML, JS) refused.

## Settings and limits

Per project, in the console: chat options (collect email, escalate wording, signed-in-only), waitlist options (double opt-in, show position, referrals, disposable block) and dropzone limits (max bytes up to 100 MB, max files, accept list).

Public endpoints are rate-limited per action — feedback and waitlist joins at 5/hour per IP, chat at 8/minute, inbox reads at 60/minute. The full surface:

| Method | Path | Caller |
| --- | --- | --- |
| `POST` | `/v1/w/<slug>/feedback` | Publication key or `widgets:write` |
| `POST` | `/v1/w/<slug>/waitlist` | Same — plus the hosted `/waitlist/confirm` link |
| `GET` | `/v1/w/<slug>/changelog` | Publication key or `widgets:read` |
| `POST` | `/v1/w/<slug>/chat` | Publication key or `widgets:write` |
| `GET`/`POST` | `/v1/w/<slug>/inbox` | Per-user token, or `widgets:read`/`widgets:write` |
| `POST` | `/v1/w/<slug>/inbox/access-token` | `widgets:write` only |
| `POST` | `/v1/w/<slug>/dropzone/upload-url` | Publication key or `widgets:write` |

*Under the hood: each script is a single-file bundle served from `camplax.dev/cdn`, and every write lands in platform tables owned by your project.*

## Analytics
Source: https://camplax.dev/docs/analytics.md

Analytics on Camplax is first-party: the tracker loads from your app's own domain, events post to your own domain, and the data lands in your project. There is no third-party script and nothing sets a cookie — visitor and session ids live in `localStorage`, and IPs are truncated before they are stored. For most apps that is enough to run without a consent banner, though that call is yours.

## It is already on

You do not install anything. The runtime injects a small script tag into your app's HTML and serves the tracker same-origin:

```html
<script src="/camplax/cx.js" data-proxy="same-origin" data-api="/camplax" defer></script>
```

The browser posts to `/camplax/v1/analytics/i` on your own domain; the runtime forwards it to the platform with your project's ingest key, which is also in your app's environment as `CAMPLAX_ANALYTICS_KEY`. The key never appears in your HTML — it lives on the server side of the proxy. Bots and headless browsers are dropped before they ever count as a visit.

## What you get without code

- **`$pageview`** on every navigation — including SPA route changes, deduplicated so the same URL does not count twice — with path, referrer, UTM tags, browser, device and screen size attached.
- **`$web_vital`** events for LCP, CLS, INP, TTFB and load time — the console's **Speed** tab.
- **Errors** — `window.onerror` and unhandled rejections are captured and sent to the [error tracker](/docs/errors.html), not here.
- **A live stream** — the **Live** tab shows events as they arrive.

## Track your own events

The snippet exposes `window.cx.analytics`:

```ts
window.cx.analytics.track("added_to_cart", { sku: "pro-plan", price: 20 });

window.cx.analytics.page("Pricing");            // a named pageview
window.cx.analytics.identify("user_123", { plan: "pro" });  // attach the account
window.cx.analytics.reset();                  // on sign-out — new visitor ids
window.cx.analytics.flush();                  // force-send the queued batch
```

`identify` ties the anonymous visitor to a user id — one person keeps counting as one across sign-in — and emits a `$identify` event with the traits. Events batch in the browser and flush every two seconds or at ten events, whichever comes first.

## The raw endpoint

Anything that can POST can send events — a backend, a script, another app:

```bash
curl https://camplax.dev/v1/analytics/i \
  -H "x-camplax-analytics-key: $CAMPLAX_ANALYTICS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sentAt": 1736400000000,
    "batch": [{
      "event": "added_to_cart",
      "distinctId": "anon_9f2…",
      "userId": "user_123",
      "properties": { "sku": "pro-plan" },
      "path": "/pricing"
    }]
  }'
```

Send the `cak_live_…` ingest key as `x-camplax-analytics-key` or `Authorization: Bearer`. Each event needs `event` (letters, `$`, `_`, `.`, `/`, `:` or `-`, up to 200 chars) and `distinctId`; `id`, `timestamp`, `sessionId`, `path`, `environment` and `userId` are optional. A batch holds 1–50 events in a 256 KB body; replays are deduplicated on the client `id`. Event properties are scrubbed before storage — keys that look like secrets (`password`, `token`, `authorization`, …) are dropped and secret-looking values are redacted.

The endpoint allows 60 posts a minute per IP and 500 per key. A `429` with `analytics_capped` means the monthly cap is spent — the app stays up, the events just stop counting.

## The console

The **Analytics** page breaks into tabs:

- **Overview** and **Web** — visitors, pageviews, top pages and referrers over a day range, for production, preview or both.
- **Speed** — the web-vitals percentiles per page.
- **Live** — the raw event stream.
- **People** — a row per visitor, and a per-person timeline once `identify` has run.
- **Funnels** — saved 2–5 step funnels with a conversion window (up to 90 days), or an ad-hoc run.
- **Retention** — cohort retention by week.
- **Export** — the event table as CSV.

## Settings

Under **Settings** on the same page:

| Setting | Default | What it does |
| --- | --- | --- |
| `autoPageviews` | on | Send `$pageview` on navigations |
| `excludePreview` | on | Drop events from preview deploys |
| `autocapture` | off | Autocapture interactions |
| `retentionDays` | 30 | How long events are queryable — 7 to 90 days |
| `paused` | off | Accept nothing until re-enabled |

Rotating the ingest key is one click — the new `cak_live_…` is shown once.

## Limits

- **250,000** production events per project per calendar month, plus a separate **10,000** for preview.
- Hot retention defaults to **30 days**; the window is 7–90, and a daily purge enforces it.
- `$probe` events — written by `camplax probe` when it checks your project — are never shown in the console.
- Preview traffic stamps its environment server-side; a client cannot claim to be production.

*Under the hood: events land in your project's own database; the tracker is a single-file bundle with no dependencies.*

## Logs
Source: https://camplax.dev/docs/logs.md

One stream, not four dashboards. Your app's request lines and stdout, platform and deploy events, [cron runs](/docs/cron.html) and grouped [errors](/docs/errors.html) all land on the same timeline. Open **Logs** in the console, or tail from the terminal:

```bash
camplax logs                    # recent entries
camplax logs --tail             # live stream until Ctrl+C
camplax logs --filter errors    # only error-level lines and 5xxs
camplax logs --search "timeout" # filter expression
```

## Runtime logs — nothing to configure

Your app does not need a logging client. The Camplax runtime wrapper around your process captures two things on its own:

- **Every HTTP request** — method, path, status and duration, with a `requestId` per request.
- **Everything your code writes to stdout and stderr** — `console.log`, stack traces, framework output.

The platform injects `CAMPLAX_LOG_INGEST` (the ingest URL) and `CAMPLAX_LOG_INGEST_TOKEN` (the bearer) into the Machine's environment and the wrapper ships batched entries to `POST /v1/logs/ingest`. Both are stripped before your code starts — they belong to the platform, so a dump of your app's env is not a credential leak. Writing to standard out is the whole integration.

The ingest endpoint itself accepts a platform bearer token and a JSON body:

```json
{
  "projectSlug": "my-app",
  "entries": [
    {
      "level": "info",
      "kind": "order.created",
      "message": "Order 1847 created",
      "method": "POST",
      "path": "/api/orders",
      "status": 201,
      "durationMs": 42,
      "requestId": "req_9f2…"
    }
  ]
}
```

- Up to **100 entries per call**. `message` is capped at 16 KB, `stack` at 32 KB.
- Fields an entry can carry: `id`, `at` or `timestamp`, `level` (`debug`, `info`, `warn`, `error`), `source`, `kind`, `environment` (`production` or `preview`), `method`, `path`, `status`, `durationMs`, `requestId`, `traceId`, `stack`.
- A `status` of 500 or higher is stored as error-level regardless of the stated `level`.
- Error-level entries are fingerprinted into [error issues](/docs/errors.html) on the way in, so a crashing route shows up in the inbox too.

## Deploy logs

Build output lives with the deploy it came from — open a deploy in the console to read the full builder log. Every deploy log also has a **shareable link**:

```text
GET /v1/deploy-logs/<slug>/<deployId>?token=<token>
```

The link returns plain text with no sign-in — it is the same URL that goes into deploy-failure emails, so you can paste it into a chat or an issue. Treat the link itself as a secret: anyone holding it can read that build's output.

## Reading the stream

| Surface | Endpoint or flag | What you get |
| --- | --- | --- |
| Recent + history | `GET /v1/projects/:slug/logs` | Merged entries; `?filter=errors`, `?search=<q>`, `?mode=filter\|regex`, `?limit=≤500`, `?cursor=` paging |
| Structured search | `POST /v1/projects/:slug/logs/search` | `{ "query", "mode": "filter" or "regex", "limit" ≤200, "cursor" }` |
| Natural language | `POST /v1/projects/:slug/logs/nl` | `{ "question": "show me 5xx on /api/orders yesterday" }` → a filter expression you can run or edit |
| Live tail | `GET /v1/projects/:slug/logs/stream` | Server-sent events; the console **Live** tab and `camplax logs --tail` both read this |
| Error groups | `GET /v1/projects/:slug/logs/errors` | Grouped issues; detail at `/logs/errors/:groupId` |

Search has two modes: the default **filter** expression (field matches and free text) and **regex** mode for patterns. Regex runs in a bounded engine — it reports a `regexLimitation` note rather than backtracking forever.

## Request ids

Every request line carries a `requestId`, and entries you emit can carry one too — put it in the entry and the same id correlates your own lines with the request that produced them. Searching a request id returns every line for that request.

> The dedicated trace view is not wired yet: `GET /v1/projects/:slug/logs/traces/:requestId` currently answers `traces_not_enabled`. Request-id search is the way to follow one request today.

## Retention and limits

- The live tail keeps the **last 2,000 entries or 7 days**, whichever ends first. It is a rolling buffer for debugging, not an archive.
- Error groups, activity events and cron history are durable records — they outlive the tail.
- Export is possible at the platform level: the control plane can mirror entries to an OTLP/HTTP collector when one is configured for the deployment.

## The same stream, three ways

- **Console** — the Logs page merges all sources with chips, search and the live tail.
- **CLI** — `camplax logs [--tail] [--filter errors] [--search <q>]`; see the [CLI reference](/docs/cli.html).
- **API** — the endpoints above accept a session, a `cx_cli_` token, or a project key with the `logs:read` scope — see [API keys](/docs/keys.html).

## Error tracking
Source: https://camplax.dev/docs/errors.md

Every thrown error becomes an **issue**: a group of identical occurrences built from a fingerprint of the error name, the message with numbers stripped, and the first in-app stack frame. `user 12345 failed` and `user 9 failed` are the same issue. The console **Errors** page is the inbox; the count, last-seen time and a kept ring of recent samples tell you whether it is noise or a fire.

## From the browser: cx.js

The `cx.js` snippet — the same one that powers [analytics](/docs/analytics.html) — captures `window.onerror` and unhandled rejections on its own, and exposes manual capture:

```ts
window.cx.errors.captureException(err, { path: "/checkout", extra: { plan: "pro" } });
window.cx.errors.captureMessage("Checkout abandoned twice", { extra: { cart: cartId } });
window.cx.errors.flush(); // force the queued batch out now
```

Calls queue client-side and post in batches — flushed every 2 seconds, at 10 queued events, and on `pagehide`. The script only runs in **same-origin mode**: it posts to `/camplax/v1/errors/i` on your own domain, and the Camplax runtime attaches the project's ingest key before forwarding. The `cak_live_` key never appears in your pages.

## From a server: POST /v1/errors/i

Send a batch directly when you are not using `cx.js`:

```bash
curl -X POST https://camplax.dev/v1/errors/i \
  -H "Authorization: Bearer $CAMPLAX_ANALYTICS_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "batch": [
      {
        "name": "TypeError",
        "message": "Cannot read properties of undefined",
        "stack": "TypeError: …\n    at createOrder (app/api/orders.ts:12:3)",
        "path": "/api/orders",
        "gitSha": "9f2ac1b",
        "environment": "production"
      }
    ]
  }'
```

- **Batch limits:** 1–20 events, body at most **64 KB**.
- Event fields: `name` (≤200 chars, default `Error`), `message` (required, ≤2000), `stack` (≤8192), `path`, `distinctId`, `userId`, `sessionId`, `deployId`, `gitSha`, `environment`, `extra` (a property bag), `timestamp`, `runtime` (`browser` or `node`).
- A 202 returns `{ "ingested": n }`. Stacks and `extra` are scrubbed for lines that look like secrets before they are stored.
- Rate limits: **30 requests/minute per key + IP** and **200/minute per key**; a 429 carries `Retry-After`.

> The environment is stamped server-side, never trusted from the body. With an `Origin` header it is resolved from your domains; without one, `deployId` then `gitSha` are matched against this project's deploys. Only *proven* production events can trigger alert email — a browser claiming `environment: "production"` does not get to page anyone.

## The inbox

Issues carry a status: **unresolved**, **resolved** or **ignored**. Resolve and ignore are one click in the console (or a POST); a fresh occurrence on a resolved issue **reopens it as a regression** and bumps a regression counter. Each issue also shows a short `doctor` suggestion — a first-thing-to-check hint generated from the message and stack, not a diagnosis.

| Endpoint | What it does |
| --- | --- |
| `GET /v1/projects/:slug/errors?status=&environment=` | List issues (default `unresolved`), sorted by count |
| `GET /v1/projects/:slug/errors/:groupId` | One issue with stack, recent samples and the suggestion |
| `POST /v1/projects/:slug/errors/:groupId/resolve` · `/ignore` · `/reopen` | Change status |
| `GET · PATCH /v1/projects/:slug/errors/settings` | Pause collection, manage ingest origins |

Setting `paused` makes ingest accept-and-drop: `POST /v1/errors/i` answers `204` and nothing is stored.

## Alert emails

New production issues and regressions email your alert recipients — the same recipient list [uptime alerts](/docs/uptime.html) use, so there is one "who gets paged" list per project. A given issue can send at most one email every **6 hours**; ignored issues never send.

| Endpoint | What it does |
| --- | --- |
| `GET · PATCH /v1/projects/:slug/error-alerts` | Read or toggle alert email (`{ "enabled": false }`) |
| `POST /v1/projects/:slug/error-alerts/test` | Send a real test alert (one per 15 minutes) |

## Restrict who can send

Because the ingest key is public by design, you can narrow it by origin. Under error settings, `ingestOrigins` is a list of up to **20 hostnames** (`docs.example.com`) allowed to POST events — your widget hosts are already allowed. A browser POST whose `Origin` is not on the list gets `403 origin_not_allowed`; server-to-server posts without an `Origin` are unaffected.

## Caps

- **50,000 production** and **5,000 preview** occurrences per project per month.
- **2,000 open groups** per project per environment — past that, new fingerprints are dropped (existing groups still count occurrences).
- Over-cap ingest answers `429 { "error": "errors_capped" }`, so your SDK call fails cleanly instead of silently filling a queue.

## Uptime monitoring
Source: https://camplax.dev/docs/uptime.md

You do not set up monitoring on Camplax — it is already running. The moment a project goes live, a monitor named **Production** starts checking `https://<slug>.camplax.app/` every 15 minutes. Custom monitors add paths and custom domains on top, up to **5 monitors per project**.

## What a monitor checks

Each monitor is an HTTPS request against one of your project's hosts — the `<slug>.camplax.app` URL or any custom domain with an active certificate:

| Setting | Default | Limits |
| --- | --- | --- |
| `name` | — | 1–80 characters |
| `path` | `/` | a path on the monitor's host |
| `method` | `GET` | `GET` or `HEAD` |
| `expectedStatusMin` – `expectedStatusMax` | `200`–`299` | any range inside 100–599 |
| `assertion` | none | `contains` or `not_contains` text (≤200 chars); needs `GET` |
| `timeoutMs` | `10000` | 1,000–10,000 ms |
| `followRedirects` | on | up to 3 redirects, and only to your own project hosts |
| `enabled` | on | pause a monitor instead of deleting it |

Two deliberate constraints: checks only ever target *your* hosts (a monitor cannot be aimed at an arbitrary URL), and the request is pinned to the Camplax edge rather than your DNS — so a hijacked nameserver cannot feed the monitor a forged answer. Response bodies are read only up to 256 KB, just enough to run the assertion.

The automatic **Production** monitor is special: it cannot be deleted (pause it instead) and it cannot be repointed — it must keep watching the project's production URL. New monitors get a 5-minute warm-up so deploy noise does not open incidents.

## Incidents and alerts

A failed check does not immediately page you. The platform re-checks **20 seconds later** and only opens an incident when the second check fails too — one bad packet is not an outage. Recovery works the same way: a success while `down` is confirmed once before the incident resolves.

When an incident opens or resolves, emails go to the project's alert recipients — any **owner or member** seat with a verified email. The project owner is added by default; edit the list under **Uptime → Alerts**. The same list receives [error alerts](/docs/errors.html).

**SSL expiry warnings** ride the same rail: once a day the platform reads the certificate on each active custom domain and emails when expiry is **30, 14, 7 or 1 day** out.

## The status page

Each project can publish a status page without any code. Enable it under **Uptime → Status page**, give it a title, and pick which monitors are public:

- **Page:** `https://camplax.dev/status/<slug>` — status banner, per-monitor 90-day uptime, recent incidents.
- **JSON:** `https://camplax.dev/v1/status/<slug>` — the same data for a status badge in your own UI.
- **Embeds:** a badge or card for your own site:

```text
<script src="https://camplax.dev/status.js" data-project="<slug>" data-layout="badge" async></script>
```

The public page only ever shows what you selected — monitor names, up/down status and incident summaries like "Service disruption", never internal error text.

## The API surface

All of the above is also scriptable — every route takes a console session, and nothing here is reachable with a project API key.

| Endpoint | What it does |
| --- | --- |
| `GET /v1/projects/:slug/uptime` | Monitors, recent incidents, domains, recipients, status-page config |
| `POST /v1/projects/:slug/uptime/monitors` | Create a monitor (409 `monitor_limit` past 5) |
| `PATCH /v1/projects/:slug/uptime/monitors/:id` | Edit a monitor |
| `DELETE /v1/projects/:slug/uptime/monitors/:id` | Delete (the automatic monitor refuses) |
| `POST /v1/projects/:slug/uptime/monitors/:id/test` | Run one check right now |
| `GET /v1/projects/:slug/uptime/monitors/:id/series?range=24h\|7d\|30d\|90d` | Latency/uptime series for charts |
| `GET /v1/projects/:slug/uptime/incidents` | Incident history (resolved entries age out after a year) |
| `PATCH /v1/projects/:slug/uptime/recipients` | Choose who gets alert email (owner only) |
| `PATCH /v1/projects/:slug/uptime/status-page` | Enable, title and select public monitors (owner only) |
| `GET /v1/status/:slug` | Public JSON for the status page — no sign-in |

## Teams and roles
Source: https://camplax.dev/docs/teams.md

A project is not a lonely account. Add people under **Teams** and each one gets a seat on that project with a role that decides what the seat can touch. Membership is per project: someone can be an owner on one app and a member on another, and the Teams screen shows the seat matrix across every project in the workspace.

## The three roles

| Role | What the seat can do |
| --- | --- |
| **owner** | Everything: deploy, database and secrets; manage domains, [API keys](/docs/keys.html) and deploy hooks; change team settings, roles and memberships; pick alert recipients; delete the project. |
| **member** | The build work: deploys, database, secrets and env, [logs](/docs/logs.html), monitors, errors. Can invite other members — but only members. |
| **billing** | Money, nothing else: plan, card, [credits](/docs/billing.html) and invoices. Cannot deploy or read the vault. |

In API terms the routes check three buckets: `owner` alone for destructive and team-admin actions, `owner + member` for build surfaces, and `owner + billing` for billing. Every seat can open the project and read its dashboard.

## Email invites

Invite by email address and role. The invite link lands in their inbox and is valid for **7 days**; accepting requires signing in with the invited address, so a forwarded link does not grant a seat to the wrong person.

- **Resend** mints a fresh token and restarts the 7-day clock — use it when the first mail expired or went to spam.
- **Revoke** kills a pending invite before it is accepted.
- Only an **owner** can grant the owner or billing roles; a member's invites are members-only by construction.

## Invite links and domain lock

For a team where inviting one address at a time does not scale, the project has an **invite link**: `https://camplax.dev/join/<token>`. Enable it in team settings and anyone signed in who opens it joins as a **member**. Rotate the token and every old link dies at once — that is the off-switch when a link leaks.

Two settings control who may join at all:

- **Allowed domains** — a list like `acme.com`; only matching email addresses can be invited or use the link.
- **Domain lock** — when on, the allow-list is enforced (invites to other domains come back `blocked`; the join link refuses them). With the lock on and an empty list, nothing gets in — the list is the gate, not a suggestion.

## The rules that protect you

- **The last owner cannot be demoted or removed.** Demoting or deleting the final `owner` seat returns `last_owner` — a project always keeps someone who can administer it.
- **You cannot remove yourself.** Another owner does it.
- Invites to domains outside an active lock are recorded as `blocked`, not sent — you can see who was turned away.
- All team endpoints are session-only: [project API keys](/docs/keys.html) cannot manage membership.

## The API surface

| Endpoint | What it does | Who |
| --- | --- | --- |
| `GET /v1/projects/:slug/teams` | Members, pending invites, role counts, invite-link state | any seat |
| `PATCH /v1/projects/:slug/teams/settings` | `domainLock`, `allowedDomains`, `inviteLinkEnabled` | owner |
| `POST /v1/projects/:slug/teams/invite-link/rotate` | New link token; old links stop working | owner |
| `POST /v1/projects/:slug/teams/invites` | `{ "emails": […], "role" }` — email invites | owner/member |
| `POST /v1/projects/:slug/teams/invites/accept` | Accept by token; session email must match | the invitee |
| `POST /v1/projects/:slug/teams/invites/:id/resend` | New token + fresh 7 days | owner/member |
| `DELETE /v1/projects/:slug/teams/invites/:id` | Revoke a pending invite | owner/member |
| `PATCH /v1/projects/:slug/teams/members/:id` | Change a role | owner |
| `DELETE /v1/projects/:slug/teams/members/:id` | Remove a seat | owner |
| `POST /v1/projects/:slug/teams/join` | Join via an invite-link token | any signed-in user the lock allows |

## API keys and credentials
Source: https://camplax.dev/docs/keys.md

Camplax hands out several kinds of credential, and mixing them up is the fastest way to a `403`. The rule of thumb: **`cx_live_`/`cx_test_` keys act on one project, `cx_cli_` acts as you, `cak_live_` only writes telemetry, `cxm_` is for AI chat clients.**

## Which token where

| Prefix | What it is | Where it goes |
| --- | --- | --- |
| *(none — cookie)* | Console session after sign-in | the browser only — reaches session-only routes like team admin, billing and key management |
| `cx_cli_…` | A CLI token for *your user* — every project you can reach | `CAMPLAX_TOKEN`, `Authorization: Bearer` on [CLI/API](/docs/api.html) and [MCP](/docs/agents-mcp.html) calls |
| `cx_live_…` / `cx_test_…` | Scoped **project API keys** | `Authorization: Bearer` on one project's data routes — database, storage, users, logs |
| `cak_live_…` | The ingest key behind `CAMPLAX_ANALYTICS_KEY` | [errors](/docs/errors.html), analytics and flags ingestion — write-only telemetry |
| `cxm_…` / `cxr_…` | OAuth access/refresh pair minted for an MCP client | `Authorization: Bearer` on `https://camplax.dev/mcp` |

If a bearer works at `https://camplax.dev` but not on your app's own domain, that is expected: `cx_cli_`/`cxm_` identify a user to the platform, while `cx_live_`/`cak_live_` identify a project.

### Minting a `cx_cli_` token without the CLI

`camplax login` creates one through the browser device flow. For a machine that cannot open a browser — a CI runner, a hosted agent — mint the same credential in the console under **Account → Access tokens → New token**, or call `POST /v1/cli/tokens` with a console session:

```bash
curl -X POST https://camplax.dev/v1/cli/tokens \
  -H "Cookie: <your console session>" \
  -H "Content-Type: application/json" \
  -d '{"name": "ci-runner", "expiresInDays": 90}'
```

The secret is shown once, hashed at rest like a project key. `GET /v1/cli/tokens` lists each token's name, prefix, created and last-used times; `DELETE /v1/cli/tokens/:id` revokes. All three routes are session-only on purpose: a leaked `cx_cli_` can act as you on the API, but it cannot mint or kill other tokens.

## Project API keys

Create them under **Project → API keys** or through `POST /v1/projects/:slug/keys` — any `owner` or `member` seat, console session only. Each key carries a subset of **16 scopes**:

| Scope | Unlocks | Scope | Unlocks |
| --- | --- | --- | --- |
| `db:read` / `db:write` | Postgres data API | `payments:read` / `payments:write` | store checkout APIs |
| `storage:read` / `storage:write` | bucket files | `ai:invoke` | the AI gateway |
| `users:read` | list app users | `deploy` | trigger deployments |
| `users:admin` | create/disable/reset users | `env:read` / `env:write` | secrets vault |
| `webhooks:send` | outbound event webhooks | `logs:read` | the log stream |
| `widgets:read` / `widgets:write` | hosted UI widgets | `manage` | console-parity routes — mailbox inboxes and sends, analytics, cron, MCP domain tools |

The console shows an "everything" quick toggle plus a Build preset (`db:read`, `db:write`, `storage:read`, `storage:write`, `users:read`, `users:admin`).

### What is actually stored

A key is a `cx_live_`/`cx_test_` prefix plus 256 bits of randomness, minted once. The platform stores its **SHA-256 hash** and an 8-char display prefix — never the secret itself. Two consequences:

- **The full key is shown exactly once**, in the create/roll response. Copy it then; it cannot be recovered.
- A leaked database row cannot be replayed: lookup hashes the presented key and compares digests.

### Expiry and lifecycle

| Preset | Behaviour |
| --- | --- |
| `0` | No expiry |
| `30` / `90` / `180` / `365` | Days until the key dies |

Skip `expiresInDays` (or pass something that is not a number) and the API defaults to **90 days**.

- **Roll** — mints a *new* key with the same name, scopes, mode and expiry, and revokes the old one in the same call. The new secret is shown once.
- **Revoke** — flips a flag; the key immediately fails. To restore access you roll a replacement — revoke is not undone.
- **Delete** — removes the record entirely.
- An expired key authenticates as nothing; requests fail closed. `lastUsedAt` on each key tells you which ones are dead weight.

### Using a key

```bash
# db:read scope
curl https://camplax.dev/v1/projects/my-app/database/schema \
  -H "Authorization: Bearer cx_live_…"

# any POST needs db:write — even a read-only query — and all SQL passes a safety guard
curl -X POST https://camplax.dev/v1/projects/my-app/database/run \
  -H "Authorization: Bearer cx_live_…" \
  -H "Content-Type: application/json" \
  -d '{"sql":"select count(*) from orders","allowWrites":false}'
```

A missing scope comes back `403 { "error": "forbidden", "message": "Missing required scope" }`; a revoked, expired or unknown key is a flat `401` — the response never says which, on purpose. Keys only authenticate `Authorization: Bearer` against project routes — they cannot mint other keys, manage the team, or touch deploy hooks.

## The other two project secrets

- **`CAMPLAX_ANALYTICS_KEY` (`cak_live_…`)** — the injectable SDK key for `cx.js`, error and analytics ingest. Write-only by design: it can record telemetry, never read your data, so it is safe to expose in client-side config.
- **Deploy-hook and public tokens** — deploy-hook secrets and share tokens are generated per feature; see [logs](/docs/logs.html) for share URLs and the CLI/API docs for hooks.

## Related docs

- [REST API](/docs/api.html) — where each credential is accepted.
- [Agents and MCP](/docs/agents-mcp.html) — `cx_cli_` bearer versus the OAuth `cxm_` flow.
- [Errors](/docs/errors.html) — what `cak_live_` ingestion looks like.

## Billing and credits
Source: https://camplax.dev/docs/billing.md

Camplax keeps money boring. There is one plan — **$20/month for platform access** — and one prepaid credit wallet per project for the metered features. There is no free tier and no per-seat pricing; a card is required at signup.

## The plan

$20/month covers what the platform does for every project: hosting, the database, sign-in, storage, deploys, previews, domains and secrets, inside the launch caps. The Billing page lists every project you can see, each one's monthly cost, and the workspace total. Seats in the `billing` [role](/docs/teams.html) can see this page without touching anything else.

## Credits

Variable-cost features — AI calls, Lee Pro turns, heavy usage — spend from a **prepaid wallet**, one per project, denominated in dollars:

- **Top up** on the Billing page through Stripe Checkout. The payment lands on a signature-verified webhook and posts a `stripe_checkout` credit to the ledger — deduplicated by Stripe payment id, so a retried webhook cannot double-credit. Live Stripe money can only credit a live project; test money only a test-mode project.
- **Spend** happens through a single-writer ledger. Every debit is one atomic statement that computes the running balance and refuses if the wallet cannot cover it.
- **At zero, it stops.** Debits fail closed — no overdraft, no surprise invoice at the end of the month. The error tells you to top up or switch back to the included model.
- **Refunds work the same way.** A reserved-but-unspent hold posts back as a positive delta on the same ledger.
- **Every row carries an idempotency key.** Replaying a call returns the original row instead of moving the balance twice.

The checkout offers preset top-up amounts — $5, $20 and $50 on the pricing page — but the wallet itself just holds a dollar balance; you are not buying a fixed bundle that expires.

## Reading the ledger

```text
GET /v1/billing/credits/:slug      → { "balance": 12.40, "ledger": [ …last 50 rows… ] }
GET /v1/billing/summary            → { "projects": [ … ], "totalMonthlyCost": 40 }
```

`POST /v1/billing/credits/:slug` is the support escape hatch, not a self-serve mint — it requires the `owner` role, a positive `delta`, `reason: "support_grant"`, and an `Idempotency-Key` header so a retried call cannot grant twice. The read routes above accept a console session in the `owner` or `billing` role; project API keys cannot read the wallet.

## What you will never see

- **No overdraft.** Balance hits zero, the billable feature stops, and the API says so plainly.
- **No surprise metering.** Top-ups are explicit; nothing silently charges the card mid-month.
- **No cross-project leakage.** Each wallet is scoped to one project, and the Stripe webhook ignores events it cannot tie to a real project.

## Related docs

- [Teams and roles](/docs/teams.html) — the `billing` seat.
- [AI gateway](/docs/ai-gateway.html) — where credits actually get spent.
- [API keys](/docs/keys.html) — which credentials can reach billing routes (none of the project-scoped ones).

## CLI reference
Source: https://camplax.dev/docs/cli.md

The Camplax CLI is `camplax`. Install it with `npm install -g camplax` (Node 24+), then `camplax help` prints this same list. The dashboard and the CLI use the same projects and the same deploys — nothing you can do in one is hidden from the other.

Most commands take the project slug from the folder's link (`camplax link <slug>` writes `.camplax/link.json`) or from `camplax.json`; pass the slug positionally — or `--project`/`-p` on `deploy` — to override.

## Setup

| Command | What it does |
| --- | --- |
| `camplax login` | Sign in — opens a browser to authorise this machine, stores `CAMPLAX_TOKEN` in the CLI config |
| `camplax login <token>` | Sign in by pasting a `cx_cli_…` token (mint one in the console — Account → Access tokens) or a project API key |
| `camplax whoami` | Print the current session as JSON |
| `camplax create [dir]` | Scaffold a Plax app (sign-in, notes, server AI); `camplax create .` writes into the current folder when it is empty or git-only; `--force` into a non-empty dir |
| `camplax init [name]` | Write a `camplax.json` for this repo (`--force` overwrites); `--app` scaffolds the Plax starter into this folder, same as `create .` |
| `camplax link <slug>` | Link this folder to an existing project |
| `camplax doctor` | Plain-English checks: env, secrets, Plax, sign-in, link |
| `camplax probe [slug]` | Live write → read → delete checks of this project's features — see below |

## Working locally

| Command | What it does |
| --- | --- |
| `camplax dev` | Pull the vault env into `.env.local` and run the start command; the production database is blocked by default |
| `camplax dev --use-production-database` | Explicitly allow the linked production database |
| `camplax dev --port <n>` / `--command <cmd>` / `-c <cmd>` | Override the port or the start command |
| `camplax env pull [--file .env.local]` | Write the project's vault into a local `.env` (`-f` to choose the file) |
| `camplax env push [--file .env]` | Upload a local `.env` into the vault and restart the app |
| `camplax secrets pull\|push` | Legacy alias for `env pull\|push` |
| `camplax add <id…>` | Install components or plugins into the app (`camplax add --list` to browse) |

`env push` only accepts `UPPER_SNAKE_CASE` keys, skips empty values, and never touches keys Camplax manages — `DATABASE_URL`, `DIRECT_URL`, `BETTER_AUTH_SECRET`, anything starting `CAMPLAX_`. When `camplax.json` lists env names, push sends only those keys.

## Shipping

| Command | What it does |
| --- | --- |
| `camplax deploy [--branch <name>\|-b] [--project <slug>\|-p] [--json]` | Queue a deploy; the default branch goes to production, any other branch to a preview |
| `camplax deploy --local` | Upload and deploy this folder without a connected repo — honours `.gitignore` + `.camplaxignore`, never ships `.git`, `node_modules` or `.env*` |
| `camplax rollback [deploy-id] [--json]` | Re-promote a previous production build without rebuilding |
| `camplax import --name <slug> [--site <url>] [--db <postgres-url>] [--repo owner/name] [--json]` | Create a project and queue a migration; needs at least one of `--site`, `--db`, `--repo` |
| `camplax logs [--tail\|-t] [--filter errors\|-f] [--search <q>\|-s]` | Recent logs, or stream with `--tail` |
| `camplax db studio` | Open the schema editor in the console |
| `camplax db migrate` | Apply pending local SQL migrations to the project database |
| `camplax gen types [--out database.types.ts\|-o]` | Write TypeScript types from the live schema |
| `camplax open [slug] [--page deploys]` | Print and open this project in the console |
| `camplax mcp [--check]` | Print how Claude Code, Cursor and other agents connect; `--check` verifies the stored token. One line also works: `npx add-mcp https://camplax.dev/mcp` |

## Checking a live app

```bash
camplax probe                      # write → read → delete checks against real features
camplax probe --only database,auth # a subset of checks
camplax probe --json               # machine-readable report; exit 1 if any check fails
camplax probe --strict             # exit 1 when a check skipped, not only when it failed
camplax probe --strict --allow-skip domains,webhooks
```

Each check prints `pass`, `skip` or `fail`. A skip means the feature is not set up on this project — under `--strict`, "not checked" counts the same as "not working" unless you allow it.

## In CI

```bash
export CAMPLAX_TOKEN="$CAMPLAX_TOKEN"   # a cx_cli_… token from camplax login
camplax deploy --json                    # parse the queued deploy, no browser involved
```

`--json` output and JSON errors with a `code` make `deploy`, `import` and `probe` safe to drive from a pipeline or a coding agent. The same verbs exist over MCP — see [AI agents and MCP](/docs/agents-mcp.html).

## Environment variables

| Variable | Meaning |
| --- | --- |
| `CAMPLAX_TOKEN` | Machine or session token (`cx_cli_…`) — the way to sign in on CI |
| `CAMPLAX_API_URL` | API base (default `https://camplax.dev`) |
| `CAMPLAX_CONSOLE_URL` | Console base (default `https://camplax.dev/app`) |

## camplax.json

When `camplax.json` is present it is the source of truth for the repo — `name`, `region`, `build` (install, command, start), `env` names, `database`, `cron` and `domains`:

```json
{
  "name": "my-app",
  "region": "iad",
  "build": {
    "install": "pnpm install",
    "command": "pnpm build",
    "start": "pnpm start"
  },
  "env": ["DATABASE_URL", "STRIPE_PRICE_ID"],
  "database": { "branchOnPreview": true },
  "cron": [],
  "domains": []
}
```

## REST API
Source: https://camplax.dev/docs/api.md

The console is a client of the same API you can call. The base is `https://camplax.dev/v1`; `GET /v1/` returns a small index. This page is the map — pick the right credential, find the route family, and the per-feature docs cover the shapes.

## Which credential

| Prefix | Carried as | What it reaches |
| --- | --- | --- |
| *(session cookie)* | browser sign-in | everything the console can do, including the session-only routes below |
| `cx_cli_…` | `Authorization: Bearer` | your user — projects, deploys, CLI flows, `/v1/mcp`; set via `camplax login` or `CAMPLAX_TOKEN` |
| `cx_live_…` / `cx_test_…` | `Authorization: Bearer` | one project's data surface, limited to the key's [scopes](/docs/keys.html) |
| `cak_live_…` | `Authorization: Bearer` or `x-camplax-analytics-key` | the write-only ingest routes only — analytics, [errors](/docs/errors.html), flags |
| `cxm_…` | `Authorization: Bearer` | the [MCP endpoint](/docs/agents-mcp.html), minted by OAuth |

Three things follow. Session-only routes — team admin, [billing](/docs/billing.html), deploy-hook management — refuse project API keys outright. `cx_live_` keys work on project routes and on the [MCP endpoint](/docs/agents-mcp.html) for project-scoped tools (they cannot `create_project`, `import_project` or `probe_project`, and `/v1/cli` refuses them). And public ingest keys cannot read anything back.

## The route map

Account-level:

| Family | Covers |
| --- | --- |
| `/v1/projects` · `/v1/imports` · `/v1/templates` | create, import and template projects |
| `/v1/cli` | CLI session — `whoami`, device login |
| `/v1/billing` | `summary` across projects, `credits/:slug` wallet + ledger |
| `/v1/uploads` · `/v1/mcp` | upload tokens · the MCP transport |
| `/v1/integrations/github` | connect repos |

Per-project, all under `/v1/projects/:slug/`:

| Family | Covers |
| --- | --- |
| `probe` · `deploys` · `import` · `deletion` | health checks, builds and rollback, moving in or out |
| `database` · `storage` · `users` · `settings` · `email` | the data layer and its knobs |
| `keys` · `teams` · `domains` | [API keys](/docs/keys.html), [seats](/docs/teams.html), custom domains |
| `logs` · `errors` · `error-alerts` · `uptime` | [the observe surface](/docs/logs.html) — streams, issues, monitors |
| `analytics` · `flags` · `widgets` · `realtime` | product telemetry, feature flags, hosted widgets, realtime channels |
| `webhooks` · `cron` · `payments` · `integrations` · `ai` · `lee` | outbound events, schedules, Stripe Connect, third-party keys, the AI gateway, the console assistant |

`GET /v1/projects/:slug` itself is the dashboard rollup.

## Public and inbound surfaces

Some routes answer without any of your credentials — they are public by design and rate limited:

| Route | Purpose |
| --- | --- |
| `POST /v1/analytics/i` · `POST /v1/errors/i` | telemetry ingest keyed by `cak_live_…` |
| `GET /v1/flags/i` | flag evaluation, keyed by `cak_live_…` |
| `GET /v1/status/:slug` | public [uptime](/docs/uptime.html) JSON for a project's status page |
| `GET /v1/deploy-logs/:slug/:deployId?token=…` | shareable [deploy log](/docs/logs.html) |
| `GET /v1/realtime` · `/v1/w/*` | realtime handshake · public widget assets |
| `POST /v1/hooks/:token` | deploy webhooks — `202` + `deployId`, `401` on a bad `x-camplax-signature`/`x-hub-signature-256`, `409` while production is busy |
| `POST /v1/webhooks/{github,stripe,stripe/connect,resend}` | inbound events — the signature header *is* the auth; replays are deduplicated |

There is also a small `POST /v1/logs/ingest` family the tenant runtime uses to ship runtime logs — see [Logs](/docs/logs.html). `/v1/ai` is the public AI gateway endpoint your app calls with a project key.

## Shape of a response

Everything is JSON. Successes return the resource; failures return a machine-readable code first, with a human string when it helps:

```json
{ "error": "not_found" }
{ "error": "invalid_signature" }
{ "error": "production_deploy_busy", "activeDeployId": "dep_…" }
{ "error": "delta_required" }
```

Treat `error` as the contract and `message` as display text. Public surfaces rate-limit by caller — an `429` means back off, not retry harder.

## Related docs

- [API keys](/docs/keys.html) — scopes, expiry, and which token goes where.
- [Agents and MCP](/docs/agents-mcp.html) — the OAuth-flavoured way in for AI clients.
- [CLI reference](/docs/cli.html) — the same verbs with `--json` for scripts.

## AI agents and MCP
Source: https://camplax.dev/docs/agents-mcp.md

Camplax is built for agents, not just people. An agent that can create a project in one call does not need a Vercel account and a Supabase account — it needs one endpoint: the Camplax MCP server.

```text
https://camplax.dev/mcp        (also mounted at /v1/mcp)
```

It speaks stateless streamable HTTP — one JSON-RPC 2.0 message per POST — and accepts MCP protocol `2025-06-18` or `2025-03-26`. Run `camplax mcp` to print the setup for your client, or wire most clients in one line:

```bash
npx add-mcp https://camplax.dev/mcp
```

`add-mcp` writes the config entry for Claude Code, Cursor, Codex, VS Code and the rest. Clients that know OAuth will sign you in through the browser; for token auth add `--header "Authorization: Bearer $CAMPLAX_TOKEN"`.

## Two ways to authenticate

Which path you use depends on who is driving:

| Path | Credential | For |
| --- | --- | --- |
| Bearer token | `cx_cli_…` from `camplax login`, or a `cx_live_…` [project key](/docs/keys.html) | coding agents, CI, anything non-interactive |
| OAuth 2.1 + PKCE | `cxm_…` access token minted by a browser consent flow | chat clients — Claude Desktop, ChatGPT, remote connectors |

### Bearer — coding agents

`camplax login` opens a browser once and writes a `cx_cli_…` token into the CLI config; in CI set `CAMPLAX_TOKEN` directly. No browser or CLI on the machine? Mint the same token in the console — **Account → Access tokens → New token** — which calls `POST /v1/cli/tokens` under a console session; the secret is shown once. Then point the agent at the endpoint with the token as a header:

```bash
# Claude Code
claude mcp add --transport http camplax https://camplax.dev/mcp \
  --header "Authorization: Bearer $CAMPLAX_TOKEN"
```

```json
// Cursor — .cursor/mcp.json
{
  "mcpServers": {
    "camplax": {
      "url": "https://camplax.dev/mcp",
      "headers": { "Authorization": "Bearer ${env:CAMPLAX_TOKEN}" }
    }
  }
}
```

A project API key also works as the bearer, scoped to one project: it can deploy and read that project — `deploy` needs the `deploy` scope — but it cannot `create_project`, `import_project` or `probe_project`. Those need a user credential.

Codex auto-discovers `.agents/skills/camplax/SKILL.md`; the same skill is published by the MCP endpoint as a resource.

### OAuth — chat clients

Chat clients sign in with your Camplax account instead of a pasted token. Add `https://camplax.dev/mcp` as a connector; the client discovers the OAuth server, opens a browser, you approve access, and it holds an `cxm_…` token. Same endpoint, same tools.

Discovery documents live at:

```text
https://camplax.dev/.well-known/oauth-authorization-server
https://camplax.dev/.well-known/oauth-authorization-server/mcp
https://camplax.dev/.well-known/oauth-protected-resource
https://camplax.dev/.well-known/oauth-protected-resource/mcp
https://camplax.dev/.well-known/mcp.json
```

The flow, if your client does not automate it:

1. **Register** (RFC 7591): `POST /oauth/register` with `redirect_uris`, optional `client_name`, `grant_types`, `scope`. Only `authorization_code`/`refresh_token`, response type `code`, auth method `none`, scope `mcp` are accepted; `redirect_uris` must be `https://` or a loopback `http://localhost`. Response `201` carries `client_id` — there is no client secret. Registration is also optional in the other direction: a `client_id` may simply be an `https://` URL hosting a **Client ID Metadata Document** — the server fetches it (one bounded request, cached briefly), requires the document's `client_id` to equal the URL, and treats its `redirect_uris` as the allowlist. Discovery advertises `client_id_metadata_document_supported`; a pre-registered client always wins over a URL-shaped one.
2. **Authorize**: `GET /oauth/authorize?client_id=…&redirect_uri=…&response_type=code&code_challenge=…&code_challenge_method=S256&scope=mcp` — S256 is the only PKCE method. Signed-out users are bounced through console sign-in first; then a consent page names the client and the redirect origin. Approving redirects back with a `code`.
3. **Token**: `POST /oauth/token` (JSON or form) with `grant_type=authorization_code`, `code`, `client_id`, `code_verifier`, `redirect_uri`. Response: `access_token` (`cxm_…`), `refresh_token` (`cxr_…`), `token_type: "Bearer"`, `expires_in`, `scope`.

The lifetimes and rules that matter:

| Item | Value |
| --- | --- |
| Authorization code TTL | 10 minutes, single use |
| Access token TTL | 1 hour |
| Refresh token TTL | 30 days |
| Refresh rotation | every use mints a new pair; replaying an old refresh token revokes the grant |
| Scope | `mcp` only — one scope, the whole surface |

Token responses are `Cache-Control: no-store`. The unauthenticated `/mcp` request returns `401` with a `WWW-Authenticate` pointer to the protected-resource document, which is how clients find all of this on their own.

## Scope the connection to one project

Two query params turn the shared endpoint into a per-project connector — the same pattern Supabase uses for its scoped MCP:

```text
https://camplax.dev/mcp?project=my-app
https://camplax.dev/mcp?project=my-app&read_only=1
```

- `project=<slug>` binds the connection. `list_projects` returns only that project, every tool that takes `slug` defaults to it, and any other slug is rejected with a `project_scope` error. The slug must resolve to a project the credential can already see — anything else is a flat `404`, so the parameter cannot probe for project names.
- `read_only` makes the connection a viewer — presence turns it on; only an explicit `read_only=0` (or `false`/`no`/`off`) turns it back off. Every tool not annotated read-only — `create_project`, `import_project`, `deploy`, `probe_project` — fails with a `read_only` error. The tools are not merely hidden; the calls fail closed.

Use it when an agent only ever works on one app, or when a third-party agent should see but never touch: a bound, read-only connector cannot reach your other projects and cannot change this one.

## The tools

Eight verbs cover context, shipping, migration and live verification. The catalog stays small on purpose — agents burn tokens on a 40-tool catalog and then call the wrong one.

| Tool | Arguments | What it does |
| --- | --- | --- |
| `whoami` | — | Which identity is connected: session, CLI token, OAuth token or API key, and its scopes |
| `list_projects` | — | The projects this identity can see |
| `project_context` | `slug` | Repo, build config, release evidence, permissions, secret names — never values |
| `create_project` | `name`, `slug`, optional `templateId` | Provision a new app; starts in `provisioning` |
| `import_project` | `name`, `slug`, optional `siteUrl`, `sourceDatabaseUrl`, `githubRepo` | Migrate a Vercel + Supabase app; the old site stays up |
| `deploy` | `slug`, optional `branch`, `sourceKey` | Queue a production deploy; `sourceKey` (from `PUT /v1/projects/:slug/source`) ships an uploaded folder instead of a repo. Poll `project_status` |
| `project_status` | `slug` | Provisioning, migration or deploy progress |
| `probe_project` | `slug`, optional `only` | Bounded live checks per feature — pass, fail or skip |

A `ready` deploy is production evidence only when `project_context.release.live` is true — `queued`, `building` and `deploying` mean work in progress, not deployed.

**No repo connected?** `project_context.nextAction` will say so. Two ways to ship anyway: the owner connects GitHub once, or the agent deploys the local folder directly — run `camplax deploy --local` from the project directory (it packs, uploads and queues in one step), or do it by hand: `PUT /v1/projects/:slug/source` with a gzipped tar body, then `deploy` with the returned `sourceKey`.

## Rate limits

Two buckets gate the endpoint, both **120 requests per minute**: one per client IP (before auth, so forged bearers are cheap to refuse) and one per identity after it. OAuth endpoints have their own — 20 client registrations per IP per hour, 60 token calls per IP per minute. A `429` carries `Retry-After`.

## What agents cannot do

Deliberately absent from the MCP: billing, creating API keys, deleting projects, and reading secret values. `project_context` returns secret names and presence only. There is no workaround, and that is the point — an agent can ship and verify an app without ever holding a secret or spending money.

## Reading the docs

- [`/llms.txt`](/llms.txt) — the short agent briefing.
- [`/llms-full.txt`](/llms-full.txt) — every docs page in one file.
- Every docs page has a markdown mirror: `/docs/getting-started.md` for `/docs/getting-started.html`, and so on.

When MCP is unavailable, the same verbs exist on the CLI with `--json` — `camplax deploy --json`, `camplax import --name <slug> --site <url> --db <postgres-url> --json`, `camplax probe --json`. Never scrape the console HTML.
