# Fiskmås REST API reference

For clients that talk HTTP directly — curl, a script, an integration without an
MCP client. The REST routes and the MCP tools call the **same handlers**
(`src/control/api.ts`), so the request/response bodies match. What differs is
**where the rate limits are attached**: the same action can have different
limits on the two surfaces (§4).

Source of truth for every number below: `src/lib/rate-limit.ts`
(`defaultConfig`) plus where each limiter is attached (`src/control/app.ts` for
REST, `src/server/mcp.ts` for MCP). If this file and that file disagree, that
file wins — please report it. `test/rest-api-docs.test.ts` fails when a limiter
config changes without this table being updated.

Related: [MCP tool schemas](/mcp-tool-schemas.md) · [agent skill](/skill.md) ·
[Docker contract](/docker-contract.md) · [llms.txt index](/llms.txt)

---

## 1. Base URLs

| Surface | Base | What lives there |
|---|---|---|
| Control plane (this API) | `https://fiskmas.dev` | `/api/*`, `/verify/:code`, `/stripe/webhook`, `/registry-token/service`, `/status`, `/health`, the landing/docs pages |
| MCP server | `https://mcp.fiskmas.dev/mcp` | Streamable HTTP MCP — **POST only** (no SSE stream, no session DELETE; other methods answer 405) |
| Docker registry | `https://registry.fiskmas.dev` | `docker push` / `docker pull` (v2 API); its auth handshake is `GET /registry-token/service` on the control plane |
| Your app | `https://<slug>.fiskmas.dev` (and `<slug>-app.fiskmas.dev`) | the deployed container, plus the public docs pages. Not the API. |

Same image serves both hosts, so the docs files (`/skill.md`,
`/mcp-tool-schemas.md`, `/rest-api.md`, `/llms.txt`) resolve on `fiskmas.dev`
**and** `mcp.fiskmas.dev`.

Bodies are JSON unless a route says otherwise (`Content-Type:
application/json`). A missing or malformed required field answers
`400 {"error": "…"}`; every error from a route is JSON, never an HTML page
(the one exception is `/verify/:code`, a human-facing page — §5.4).

## 2. Authentication

One credential does everything: the **API token** shown **once** on the verify
page after onboarding. It is the MCP `Authorization: Bearer` value, the REST
bearer, and the `docker login -u` username.

| Mode | Header / mechanism | Routes |
|---|---|---|
| None — public | — | `POST /api/register`, `POST /api/account-status`, `POST /api/reissue`, `GET /verify/:code`, `POST /api/visit`, `GET /status`, `GET /health`, the docs pages |
| Stripe signature | `stripe-signature: t=…,v1=…` (HMAC-SHA256 over the **raw** body) | `POST /stripe/webhook` |
| Presigned path | random token inside the path | `GET /api/dumps/:id/:token` |
| Basic | `Authorization: Basic base64(<API token>:…)` — API token as the **username** | `GET /registry-token/service` |
| Bearer | `Authorization: Bearer <API token>` (a.k.a. `FISKMAS_TOKEN`) | everything else under `/api/*` |

The bearer gate is a catch-all registered **after** the public routes, so a
protected route with no (or an invalid) token answers:

```
HTTP/1.1 401
{"error": "missing Authorization header"}
```

Tokens are revocable: a reissue (`/api/reissue`) invalidates all earlier
tokens immediately.

## 3. Rate limits: how they work

A **token bucket** per `(limiter name, identifier)` — `src/lib/rate-limit.ts`.
Refill rate is `max / windowSec`, capacity is `max`. Persisted in Postgres
(`rate_buckets`), with a bounded in-memory fallback if the DB is unreachable,
so limits survive a restart and are shared across replicas.

**Which identifier is used**

| Key | Derived from |
|---|---|
| `ip` | the **last** `X-Forwarded-For` hop — the one stamped by our load balancer. `CF-Connecting-IP` is deliberately **not** trusted: `fiskmas.dev` resolves straight to the LB, so that header is pure client input and trusting it lets a caller mint a fresh bucket per request. A request with no `X-Forwarded-For` lands in the shared `"unknown"` bucket. |
| `account` | the authenticated account id (bearer routes) or, for `/registry-token/service`, the **Basic username** (i.e. the API token). Missing Basic auth → shared `"unknown"` bucket. |
| `emailHour` / `emailDay` | the lower-cased email argument (`/api/reissue` only) |

**Every response through a rate-limited route carries the headers**, allowed or
denied:

| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | configured capacity of the limiter that ran (`max`) |
| `X-RateLimit-Remaining` | tokens left in your bucket **after** this request |
| `X-RateLimit-Reset` | **absolute Unix time (seconds)** when a token is back — not a duration |

```
HTTP/1.1 200 OK
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 3
X-RateLimit-Reset: 1761234000
```

**A 429 body** is always `{"error": "…"}` with a retry hint. Two shapes appear:

1. From a route's own limiter (`rateLimitMiddleware` in `src/control/app.ts`):

```
HTTP/1.1 429
{"error": "Rate limit exceeded. Try again later. (retry in ~723s)"}
```

   (`deploy` appends `restart_project is not rate-limited` to that hint.)

2. From a limiter inside a handler — `/api/reissue` checks per-email and
   per-IP buckets before it looks up the account:

```
HTTP/1.1 429
{"error": "too many re-issue requests for you@example.com. For your security, retry in ~1500s."}
```

   The target is the email, or `this network` when the per-IP bucket is what
   ran out.

Two practical notes:

- **A rejected request still spends a token.** The limiter runs before
  validation, so a `400` (bad email, missing field) counts against the bucket.
- On the **MCP** surface, limits are not HTTP status codes: the tool call
  returns a tool error (`isError: true`) whose text is the same sentence
  prefixed with `error: `. Relay it to the human instead of retrying in a loop.

### Limiter configs (verbatim from `src/lib/rate-limit.ts`)

| Limiter | Key | Max | Window (s) | Where it is applied |
|---|---|---|---|---|
| `register` | `ip` | 5 | 3600 | REST `POST /api/register` |
| `reissue` | `ip` | 5 | 3600 | REST `POST /api/reissue` (outer gate) |
| `reissueEmailHour` | `emailHour` | 3 | 3600 | `reissueToken` handler — REST **and** MCP |
| `reissueEmailDay` | `emailDay` | 10 | 86400 | `reissueToken` handler — REST **and** MCP |
| `reissueIp` | `ip` | 20 | 3600 | `reissueToken` handler — REST **and** MCP |
| `verify` | `ip` | 10 | 3600 | REST `GET /verify/:code` |
| `stripeWebhook` | `ip` | 100 | 60 | REST `POST /stripe/webhook` |
| `visit` | `ip` | 10 | 60 | REST `POST /api/visit` |
| `registryToken` | `account` | 30 | 60 | REST `GET /registry-token/service` |
| `createAccount` | `ip` | 3 | 3600 | MCP `create_account` (REST uses `register`) |
| `checkout` | `account` | 5 | 3600 | REST `POST /api/billing/checkout` |
| `deploy` | `account` | 1 | 300 | REST `POST /api/projects/:id/deploy` and MCP `deploy_project` |
| `dump` | `account` | 2 | 3600 | REST database dump and MCP `database_dump` |
| `admin` | `ip` | 30 | 60 | owner admin API (`/api/admin/*`) |
| `default` | `ip` | 60 | 60 | fallback for an unknown limiter name |

Buckets are swept every 10 minutes: idle ones older than 1 h are deleted,
except the `emailDay` reissue buckets, kept 48 h so a 10/day limit cannot
silently reset every hour.

## 4. MCP vs REST: the same action, different gates

Prefer MCP tools. Use REST when you have no MCP client, or for the pieces MCP
does not expose (the Stripe webhook, the registry token realm, the visit
beacon). Where both exist, the limits are **not** identical:

| Action | MCP | REST | Limits compared |
|---|---|---|---|
| Token recovery | `reissue_token` | `POST /api/reissue` | **Different.** Both run the handler's `reissueEmailHour` (3/hr per email), `reissueEmailDay` (10/day per email), `reissueIp` (20/hr per IP). The **REST route adds an outer `reissue` bucket: 5/hr per IP.** So a REST caller is cut off at 5/hr/IP where an MCP caller can reach 20/hr/IP. |
| Onboarding | `create_account` | `POST /api/register` | **Different.** MCP: `createAccount` 3/hr per IP. REST: `register` 5/hr per IP. Neither limits per email. |
| Account poll | `account_status` | `POST /api/account-status` | Same handler; no limiter on either today. |
| Deploy | `deploy_project` | `POST /api/projects/:id/deploy` | Same limiter (`deploy`, 1 per 300 s per account). `restart_project` / `POST …/restart` is not rate-limited on either surface. |
| Database dump | `database_dump` | `POST /api/projects/:id/dump` | Same limiter (`dump`, 2/hr per account). |

Why the `reissue` asymmetry is intentional: the MCP tool is one call from one
configured client, while `POST /api/reissue` is an open endpoint on the public
site — it is the one any script on the internet can hit, so it carries the
tighter outer bucket (FISK-221).

## 5. Public endpoints

### 5.1 `POST /api/register` — start onboarding

Public. Requires an email; creates a `pending` account and emails a one-time
verify link. **No token is returned** — a human opens the link and copies the
token shown there (§5.4).

```bash
curl -sS -X POST https://fiskmas.dev/api/register \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "display_name": "Optional name"}'
```

`201` (the handler also echoes its own status in the body):

```json
{
  "status": 201,
  "account_id": "01a1…",
  "account_status": "pending",
  "email": "you@example.com",
  "next": [
    "A verification email was sent. Ask the human to open it and click Verify.",
    "When they finish, they will see the API token once — they must paste it into the MCP client's Authorization: Bearer <token>.",
    "Poll account_status with this email until account_status is active, then call whoami with the token."
  ]
}
```

| Status | When |
|---|---|
| `400` | `email is required` · `invalid email` · `email too long` (> 254) |
| `409` | `account already active …` — re-registration never mints a new token; use `/api/reissue` |
| `429` | `register` bucket exhausted (5/hr per IP) |

### 5.2 `POST /api/account-status` — poll onboarding

Public, no limiter attached today.

```bash
curl -sS -X POST https://fiskmas.dev/api/account-status \
  -H 'Content-Type: application/json' -d '{"email": "you@example.com"}'
```

`200` — `account_status` is `unknown` | `pending` | `active`; `account_id` is
present for the latter two, and `next[]` says what to do:

```json
{"email": "you@example.com", "account_status": "pending", "account_id": "01a1…",
 "next": ["Waiting for the human to click the verify link in their email."]}
```

### 5.3 `POST /api/reissue` — recover a lost token

Public by design: the mailbox is the identity anchor. It emails a one-time
reissue link; **the new token is never in this response** — it appears once on
the verify page. Clicking that link revokes every older token immediately.

```bash
curl -sS -X POST https://fiskmas.dev/api/reissue \
  -H 'Content-Type: application/json' -d '{"email": "you@example.com"}'
```

`200`:

```json
{
  "email": "you@example.com",
  "status": "reissue_email_sent",
  "next": [
    "A reissue link was emailed to you@example.com. Tell the human to open it in a browser and click 'Reissue & get new API token'.",
    "The verify page shows the NEW token once — the human copies it back to you. Old tokens stop working immediately."
  ]
}
```

| Status | When |
|---|---|
| `400` | missing / invalid / too-long email |
| `404` | `no account for <email>` — never registered |
| `409` | the account is still `pending` — complete onboarding instead |
| `429` | either the outer `reissue` bucket (5/hr per IP, shape 1 in §3) or a handler bucket (3/hr · 10/day per email, 20/hr per IP, shape 2 in §3) |

### 5.4 `GET /verify/:code` — the human step

Not a JSON API: it returns the **HTML verify page** and must not be automated
(it is rate-limited per IP, 10/hr, to stop code brute-forcing). One link works
once; onboarding links activate the account and mint the first token, reissue
links revoke the old tokens and mint a new one. The page renders the token
exactly once. Its injected payload is:

```json
{"ok": true, "account_id": "01a1…", "email": "you@example.com",
 "token": "<shown once>", "purpose": "onboarding"}
```

`{"ok": false, "error": "…"}` for an unknown, expired, or already-used code.

### 5.5 `POST /stripe/webhook` — Stripe → us only

You do not call this; Stripe does. Documented because it is a public route.
The signature is verified against the **raw body** before parsing, and Stripe
sends no bearer token. The limit (100/min per IP) is generous because Stripe
retries non-2xx answers.

```
POST /stripe/webhook
stripe-signature: t=1761234000,v1=5257a7…
```

| Status | Body | When |
|---|---|---|
| `200` | `{"ok": true, "applied": true, "detail": "…"}` | event handled (plan flips are idempotent, so a retry cannot double-promote) |
| `200` | `{"ok": true, "applied": false, "detail": "unhandled"}` | verified but nothing to do |
| `400` | `{"error": "<signature/parse error>"}` | bad `stripe-signature` or malformed body |
| `503` | `{"ok": false, "error": "webhook not configured"}` | deployment without the webhook secret |
| `429` | `{"error": "Rate limit exceeded. …"}` | 100/min per IP exceeded |

A verified-but-failing dispatch still answers `200` on purpose: a 5xx makes
Stripe retry for days.

### 5.6 `GET /registry-token/service` — the registry's auth realm

This is what `docker login` talks to. `registry.fiskmas.dev` challenges with
its realm; the docker client calls this route with **Basic auth** (your API
token as the username) and gets back a ~10-minute scoped JWT.

```bash
docker login -u "$FISKMAS_TOKEN" registry.fiskmas.dev

# what docker actually sends (scopes repeat per repository/access):
curl -sS -u "$FISKMAS_TOKEN:" \
  'https://fiskmas.dev/registry-token/service?scope=repository:my-app:push&scope=repository:my-app:pull'
```

`200 {"token": "<JWT>"}` — the JWT's `access` mirrors the scopes requested.

| Status | When |
|---|---|
| `401` | `missing or malformed Basic auth`, or the credential is neither a valid API token nor one of our own registry JWTs |
| `429` | `registryToken` bucket exhausted (30/min, keyed by the Basic username; anonymous callers share one `"unknown"` bucket) |

### 5.7 `POST /api/visit` — anonymous page beacon

Sent by the site itself to draw the admin traffic chart. 10/min per IP. The
body carries a client-generated random visitor id (no cookies, no PII) and the
page path; the beacon ignores the response, so failures are never surfaced to
a visitor.

```bash
curl -sS -X POST https://fiskmas.dev/api/visit \
  -H 'Content-Type: application/json' -d '{"visitor":"7c1f…","path":"/docs"}'
# 200 {"ok": true}  ·  {"ok": false} if the insert failed (harmless)
```

### 5.8 `GET /api/dumps/:id/:token` — presigned dump download

The random token **in the path** is the authorization (no bearer needed), so a
plain browser can fetch the artifact a `database_dump` call handed back.
Unknown or expired links answer JSON `404` / `410` — never a directory
listing.

### 5.9 Other public reads

`GET /health` → `{"ok": true}`; `GET /status` → the infra-only status JSON
(loadbalancer / control / mcp / registry / sql), no request counters;
`GET /llms.txt`, `/llms-full.txt`, `/skill.md`, `/docker-contract.md`,
`/mcp-tool-schemas.md`, `/rest-api.md` (this file, `text/markdown`);
`GET /docs` (rendered tool reference), `GET /` (landing page, or the status
placeholder on app hosts). None of these carry a limiter today.

## 6. Auth-gated endpoints (bearer)

Same JSON style, same `{"error": "…"}` failures. Selected routes, with their
limiters:

| Method + path | MCP twin | Limiter |
|---|---|---|
| `POST /api/whoami` | `whoami` | — |
| `DELETE /api/account` | `delete_account` | — |
| `POST /api/billing/checkout`, `GET /api/billing`, `GET /api/billing/pk` | — | `checkout` 5/hr per account (minting a Plus checkout session) |
| `POST /api/projects`, `GET /api/projects`, `GET|DELETE /api/projects/:id` | `create_project`, `list_projects`, `get_project`, `delete_project` | — |
| `POST /api/projects/:id/env`, `GET /api/projects/:id/env` | `set_env`, `list_env` | — |
| `POST /api/projects/:id/deploy` | `deploy_project` | `deploy` 1 per 300 s per account |
| `POST /api/projects/:id/start|stop|restart` | `start_project`, `stop_project`, `restart_project` | — |
| `GET /api/projects/:id/status|logs`, `GET /api/stats` | `get_status`, `get_logs`, `get_stats` | — |
| `POST /api/projects/:id/dump`, `GET /api/dumps/:id` | `database_dump`, `dump_status` | `dump` 2/hr per account |
| `POST /api/feedback`, `GET /api/skills`, `GET /api/skills/:name` | `send_feedback`, `list_skills`, `get_skill` | — |

A plan/quota 429 is a **plan** limit, not a rate limit, and its text names the
price and the upgrade page — relay it to the human rather than retrying:

```json
{"error": "quota exceeded: the Free plan runs 1 project at a time (you have 1). Upgrade to Plus ($9.99/mo, 5 projects, 512 MB / 1 CPU per project) at https://fiskmas.dev/#pricing, or stop_project a running one to make room."}
```

## 7. Keeping this page honest

- The numbers come from `src/lib/rate-limit.ts` (`defaultConfig`); the
  attachment points are `src/control/app.ts` (REST) and `src/server/mcp.ts`
  (MCP).
- This file is **not** generated: `src/docs/generate.ts` owns
  `docs/skill.md`, `docs/llms.txt`, `docs/llms-full.txt`, and `llms.txt` links
  here under *Connect*. Hand-edit this one, run `npm run generate` if you touch
  the index.
- Served as `text/markdown` by both hosts; `test/rest-api-docs.test.ts` pins
  the limiter table against the config source, and
  `test/landing-auth.test.ts` pins the route.
