# HTTP API

All endpoints speak JSON over HTTP. Authentication is a bearer token:
`Authorization: Bearer <token>`.

**Roles:**

- *client* — the bootstrap `PRINTAPI_TOKEN`, any active issued key (see
  [API keys](server.md#api-keys)), **or** a session token from `POST /login`. An issued key and a
  session only ever see their own org; the bootstrap token spans all of them — see
  [Multi-tenancy](#multi-tenancy).
- *manage* — root **or** a session (an account login): the org's keys, users and settings. A
  machine key is deliberately excluded, so a leaked integration key cannot issue itself a
  successor — see [Accounts and login](#accounts-and-login).
- *root* — the bootstrap `PRINTAPI_TOKEN` only (orgs, and the first user of an org)
- *agent* — the per-agent key (bound to the agent name on first contact)

Token comparison is constant-time (`hmac.compare_digest`).

## Endpoints

| Method & path | Auth | Purpose |
|---|---|---|
| `GET /` | none | Web dashboard (static bundle from `app/web`; data fetched with the token) |
| `GET /health` | none | Liveness check, plus whether this server offers `signup` and `password_reset` |
| `GET /metrics` | client | Prometheus text: job counts by state, agent/printer liveness |
| `POST /jobs` | client | Submit a job → `{job_id}` |
| `POST /orders` | client | Render an order as a packing slip and print it → `{job_id}` ([e-commerce](ecommerce.md)) |
| `POST /integrations/shopify/orders` | HMAC | Shopify order webhook (key in the URL, signature in the header) |
| `GET /jobs` | client | Recent job history |
| `GET /jobs/{id}` | client | One job's state: `queued` \| `claimed` \| `done` \| `failed` \| `cancelled` |
| `DELETE /jobs/{id}` | client | Cancel a still-`queued` job (`409` once claimed, `404` if unknown) |
| `GET /printers` | client | Registered printers + online/offline + capabilities |
| `GET /computers` | client | Registered agents + online/offline + printer count |
| `POST /login` | none | E-mail + password → `{token, expires_at, org_id, user_id}` |
| `POST /signup` | none | Self-serve org + first account + session (`403` unless the operator opened it) |
| `POST /password/reset` | none | Mail a one-shot reset token — always `{"ok": true}` |
| `POST /password/reset/confirm` | none | `{token, password}` → sets it and logs every browser out |
| `POST /logout` | session | End this session server-side |
| `GET /me` | any | `{kind: root\|session\|key, org_id}` (+ `email`, `user_id` for a session) |
| `PUT /me/password` | session | `{current, new}` — changes it and logs every browser out |
| `POST /users` | manage | Add a user to the caller's own org → `{id, email, org_id}` |
| `GET /users` | manage | Users of the caller's org (root: all orgs) |
| `DELETE /users/{id}` | manage | Remove an account (never your own, never an org's last one) |
| `POST /orgs/{id}/users` | root | Create an org's first user |
| `POST /orgs` | root | Create an org → `{id, name}` |
| `GET /orgs` | root | List orgs (`event_url`, `shopify_secret_set` — never the secret itself) |
| `GET /orgs/{id}` | manage | One org's settings + `plan`, `job_quota` and `jobs_this_month` |
| `PUT /orgs/{id}` | manage | Set/clear `event_url`, `shopify_secret`; `job_quota` and `plan` are root-only |
| `DELETE /orgs/{id}` | root | Remove an org and everything in it (never the default org) |
| `GET /plans` | any | The [billing](billing.md) plan catalogue + the caller's current plan |
| `POST /billing/webhook` | HMAC | The payment provider's callback: this org is on that plan now |
| `POST /apikeys` | manage | Issue a client key → `{id, label, org_id, key}` (key shown once) |
| `GET /apikeys` | manage | List keys with their org (never the secret) |
| `DELETE /apikeys/{id}` | manage | Revoke a key |
| `POST /agent/register` | agent | Declare name + printers → `{computer_id, printer_ids}` |
| `GET /agent/jobs` | agent | Long-poll for a job (204 on timeout) |
| `GET /agent/jobs/{id}/payload` | agent | Download the job's bytes |
| `POST /agent/jobs/{id}/result` | agent | Report `{ok, error?}` |
| `POST\|GET\|DELETE /cloudprnt/{key}` | client key | [Star CloudPRNT](cloudprnt.md): the printer polls, downloads and confirms here itself |

Request bodies are capped at 32 MB.

An `Authorization: Basic` header instead of `Bearer` switches the same server to the
**[PrintNode-compatible shapes](printnode-compat.md)** (`/whoami`, `/printjobs`, and PrintNode's
JSON for `/printers` and `/computers`), so an existing client can be pointed here unchanged. Same
keys, same orgs; everything below describes the `Bearer` API.

## Submitting a job

```bash
curl -s -X POST localhost:3460/jobs \
     -H 'Authorization: Bearer <client-key>' -H 'Content-Type: application/json' \
     -d '{"printer_id":1,"type":"raw_base64","content":"<base64 ZPL>","title":"Label #4712","copies":2}'
# -> {"job_id": 1}
```

`title` is optional and shows up in the dashboard's history.

`copies` is optional (default `1`, integer `1`–`100`) — the agent prints the job that many times.
Out of range or non-integer → `400`.

`callback_url` is optional (`http(s)` only) — the server POSTs the job's outcome there once it
reaches a terminal state. See [Webhooks](#webhooks). A non-`http(s)` scheme → `400`.

`idempotency_key` is optional (string, ≤128 chars) — **retry-safe submits**. Resubmitting the same
key inside the same org returns the *original* `job_id` and prints nothing extra, so a timed-out or
retried `POST /jobs` (order webhook redelivery, flaky network) can never double-print. Keys are
scoped per org and never expire; use something stable like the order id.

`expire_after` is optional (integer seconds, `1`–`2592000`) — **a deadline for the job**. Past it
the job is failed (`state: failed`, `error: "expired"`) instead of printed, so a stale shipping
label doesn't come out hours later when an offline agent reconnects. Without it, jobs wait forever.

`options` is optional and **pdf jobs only** (raw ZPL/ESC-POS carries its own layout — `400` on a
raw job). The agent maps them onto its backend's native flags (SumatraPDF `-print-settings` on
Windows, `lp -o` on CUPS):

```json
{"printer_id": 2, "type": "pdf_base64", "content": "<base64 PDF>",
 "options": {"duplex": "long-edge", "paper": "A4", "bin": "Tray 1",
             "color": false, "pages": "1-3,5"}}
```

| Key | Values | Maps to (Windows / CUPS) |
|---|---|---|
| `duplex` | `long-edge` \| `short-edge` \| `one-sided` | `duplexlong`… / `sides=two-sided-long-edge`… |
| `paper` | paper name, e.g. `A4`, `Letter` | `paper=` / `media=` |
| `bin` | tray name (driver-specific) | `bin=` / `InputSlot=` |
| `color` | `true` \| `false` | `color`/`monochrome` / `print-color-mode=` |
| `pages` | ranges like `1-3,5` | print settings / `page-ranges=` |

Unknown keys or invalid values → `400`. Values are what the printer driver understands — check
the printer's `capabilities` in `GET /printers` (`papers`, `bins`, `duplex`, `color`; reported
best-effort by the agent at registration via the Windows driver or CUPS `lpoptions`, `null` when
unavailable) for what it supports. An option the driver doesn't support is silently ignored by
it. On CUPS, `bin`/`paper` values must not contain spaces.

## Content types

| `type` | Payload | The server… |
|---|---|---|
| `raw_base64` | `content`: base64 bytes (ZPL/ESC-POS) | decodes and queues them |
| `pdf_base64` | `content`: base64 PDF | decodes and queues; agent renders |
| `raw_uri` | `url` | GETs the URL, queues the response bytes |
| `pdf_uri` | `url` | GETs the URL, queues as PDF |
| `raw_uri_post` | `url` + `json` | POSTs `json` to the URL, queues the response bytes |
| `pdf_uri_post` | `url` + `json` | POSTs `json` to the URL, queues as PDF |

URL fetches are `http(s)`-only and sent with a browser User-Agent (WAF/CDN-fronted services
reject the default Python one).

## Webhooks

Set `callback_url` on a job and the server POSTs this JSON once the job reaches a terminal state:

```json
{"job_id": 1, "state": "done", "error": null, "title": "Label #4712", "printer_id": 3}
```

- Fires on `done`, `failed`, and `cancelled` (state-based, so agent-reported, cancelled, and
  reaper-failed jobs all deliver).
- **Best-effort with retries:** any non-2xx or connection error is retried a few times by a
  background dispatcher, then given up (logged). Delivery is not guaranteed — if it matters, treat
  `GET /jobs/{id}` as the source of truth.
- **At-least-once:** a delivery can arrive **more than once** (e.g. the POST succeeded but its ack
  was lost, so it retries). Make your handler idempotent — dedupe on `job_id` + `state`.
- The payload is **unsigned** and the URL is fetched server-side (`http(s)` only) — same trust model
  as the `*_uri` content types. Point it at a trusted endpoint.

## Computers (agents)

```bash
curl -s localhost:3460/computers -H 'Authorization: Bearer <client-key>'
# -> {"computers":[{"id":1,"name":"warehouse-pc","online":true,"last_seen_at":1753.., 
#                  "created_at":1750..,"printers":2}]}
```

`online` means the agent polled or registered within the liveness window (60 s). Scoped to the
key's org, like every other client list.

### Agent liveness events

An org can receive a POST whenever one of its agents crosses that window — the fleet-monitoring
counterpart to per-job `callback_url` webhooks:

```bash
curl -s -X PUT localhost:3460/orgs/2 -H 'Authorization: Bearer <PRINTAPI_TOKEN>' \
     -d '{"event_url":"https://ops.example/printpapi-events"}'    # null clears it
```

```json
{"event":"computer_offline","computer_id":1,"name":"warehouse-pc","org_id":2,"last_seen_at":1753..}
```

- `computer_offline` fires once when the agent stops being seen, `computer_online` once when it
  comes back. One POST per edge, never a repeat while the state holds.
- **At-most-once, unlike job webhooks:** a failed POST is logged and dropped, not retried — the next
  real transition fires again. Treat `GET /computers` as the source of truth.
- `http(s)` only, unsigned, same trust model as `callback_url`.
- An org that sets its `event_url` later starts from the *current* state; past transitions are not
  replayed.

## Multi-tenancy

Every agent, printer, job, and key belongs to exactly one **org**. A key *is* the org: whatever
key a request presents decides what it can see, and nothing else does.

```bash
# 1. root creates the org
curl -s -X POST localhost:3460/orgs -H 'Authorization: Bearer <PRINTAPI_TOKEN>' \
     -d '{"name":"acme"}'                       # -> {"id":2,"name":"acme"}

# 2. root issues that org a key (one per agent / per integration)
curl -s -X POST localhost:3460/apikeys -H 'Authorization: Bearer <PRINTAPI_TOKEN>' \
     -d '{"label":"acme-agent","org_id":2}'     # -> {"id":1,"label":"acme-agent","org_id":2,"key":"…"}

# 3. the agent puts that key in agent.ini and registers — it lands in org 2, and so do its
#    printers and every job printed on them.
```

Rules:

- **An org key never reaches another org.** `GET /jobs`, `GET /printers` and `GET /metrics` return
  only that org's rows; a foreign job id is **`404`**, not `403` (a `403` would confirm it exists);
  `DELETE /jobs/{id}` on a foreign job is `404` too, even if that job is claimed (which would
  otherwise be `409`). Printing to a foreign `printer_id` is `400 unknown printer` — the same answer
  a nonexistent printer gets.
- **Root spans orgs.** The bootstrap `PRINTAPI_TOKEN` reads, submits and cancels across every org
  (that's what the dashboard uses), and is the only credential that can manage orgs and keys.
- **Agents inherit their key's org.** A key issued for org N puts the agent in org N. Any other key
  (including the ones existing agents already use) enrolls into the default org `1`, so nothing
  about a single-org install changes. Agent names are unique *per org*, not globally; an agent key
  is still bound to its name on first contact.
- `org_id` is optional on `POST /apikeys` and defaults to `1`. An unknown org → `400`.

**Existing installs need no migration** — everything already lives in org `1`, root behaves as
before, and every issued key resolves to org `1`.

Deliberately out of scope for now (`# ponytail:` in the code): billing, quotas, and org deletion.
An agent key doubles as its org's client key, and revoking it stops client calls but not an
already-registered agent's polling.

## Accounts and login

An org's people sign in with an **e-mail and password** instead of pasting the root token. The
login mints a **session token** carried in the same `Authorization: Bearer` header as everything
else, so nothing else about the API changes.

```bash
# 1. root seeds the org's first user (or the org signs itself up — see Self-signup below)
curl -s -X POST localhost:3460/orgs/2/users -H 'Authorization: Bearer <PRINTAPI_TOKEN>' \
     -d '{"email":"ops@acme.example","password":"a long passphrase"}'

# 2. that user logs in and gets a session
curl -s -X POST localhost:3460/login \
     -d '{"email":"ops@acme.example","password":"a long passphrase"}'
# -> {"token":"sess_…","expires_at":1787…,"org_id":2,"user_id":2}

# 3. the session acts inside org 2 — and may manage it
curl -s -X POST localhost:3460/apikeys -H 'Authorization: Bearer sess_…' -d '{"label":"n8n"}'
curl -s -X POST localhost:3460/users   -H 'Authorization: Bearer sess_…' \
     -d '{"email":"packer@acme.example","password":"another passphrase"}'
```

What holds:

- **Three kinds of credential, one header.** Root manages every org; a session manages its own
  org; a machine key prints and reads and nothing more (`401` on `/apikeys`, `/users`,
  `PUT /orgs/{id}`). Which table the credential resolves in *is* the permission — there is no role
  column and no roles.
- **Sessions expire** after 30 days and can be ended early (`POST /logout`, or the reaper once
  expired). Tokens are stored sha256-hashed, like every other credential here.
- **Passwords** are `scrypt` hashes (stdlib, salted per user), minimum 8 characters. Changing a
  password deletes that user's sessions — a stolen session dies with the password it came from.
- **Login is throttled** per e-mail (10 failures / 15 minutes → `429`), and a wrong address costs
  the same time as a wrong password, so the endpoint does not enumerate accounts.
- **E-mails are unique across the whole server** (login carries no org), lower-cased and trimmed.
- **Removing an account** (`DELETE /users/{id}`) drops its sessions and any pending reset with it.
  You cannot remove yourself (`400`), an org cannot lose its last account (`400`), and another
  org's user is a `404`. Their jobs stay in the history; their API keys are untouched — revoke
  those separately.

Not built yet: per-user roles. The throttle counters are per process, in memory and bounded
(several server processes count separately, and a large enough spray of invented addresses evicts
counters instead of growing memory). Passwords over 1024 characters are rejected rather than
hashed.

### Self-signup

`POST /signup {email, password, org_name?}` creates an org, its first account and a session in one
call — the same body a login answers with. It is **off unless the operator turns it on**
(`PRINTAPI_SIGNUP=open`); otherwise it is a `403`, because a self-hosted box on the open internet
must not hand an org to whoever finds it. `GET /health` reports `"signup": "open"|"closed"` so the
dashboard only offers the door when it exists.

Signups are throttled by client address (the same counter a failed login uses), so one host cannot
spray orgs. Behind a reverse proxy every signup shares one address — put the rate limit in the
proxy there. `org_name` defaults to the e-mail's domain. A taken address is a `409` and leaves no
half-made org behind.

### Password reset

```bash
curl -s -X POST localhost:3460/password/reset -d '{"email":"ops@acme.example"}'
# -> {"ok": true}   (the same answer for an address with no account)

curl -s -X POST localhost:3460/password/reset/confirm \
     -d '{"token":"sess_…","password":"a new long passphrase"}'
```

- The token is **one-shot**, valid for an hour, stored hashed, and a second request supersedes the
  first. Spending it sets the password and logs every browser out.
- Requests are throttled per address, counted whether or not the account exists — a `429` leaks
  nothing either.
- Mail goes out over **SMTP** (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`,
  `SMTP_SSL`, `SMTP_STARTTLS`). With no `SMTP_HOST` the message is printed to stderr instead, so a
  self-hosted instance still works — the operator reads the token out of the log.
- The mail carries a **link only if `PUBLIC_URL` is set** (`https://…/?reset=<token>`). Without it
  it carries the bare token, because building the link from the `Host` header would let anyone who
  can reach this server mail a valid token pointing at a host of their choosing.
- `GET /health` reports `"password_reset": true` once SMTP is configured; the dashboard offers
  "Forgot password?" only then.

### Quotas

An org can be capped at a number of jobs per calendar month (UTC):

```bash
curl -s -X PUT localhost:3460/orgs/2 -H 'Authorization: Bearer <PRINTAPI_TOKEN>' \
     -d '{"job_quota": 500}'      # null clears it — unlimited is the default
```

- **Root sets it, nobody else** — a session that could raise its own cap has no cap, so
  `job_quota` on `PUT /orgs/{id}` is a `403` for anything but the bootstrap token. Every other
  field on that endpoint stays manageable by the org itself.
- A submit past the cap answers **`402`** and prints nothing. The guard sits in the enqueue path,
  so `POST /jobs`, `POST /orders`, the Shopify webhook and the PrintNode-compatible
  `POST /printjobs` (`{"code": "QuotaExceeded"}`) are all covered by it.
- An **idempotent resubmit spends nothing** — it returns the original job, it does not create one.
- `GET /orgs/{id}` reports `job_quota` and `jobs_this_month`, which is what the dashboard's
  Settings page shows.

### Plans and billing

A hosted deployment sells those quotas as **plans**: `PRINTAPI_PLANS` is the catalogue, a
plan's `jobs` becomes the org's `job_quota`, and a signed `POST /billing/webhook` from the payment
provider (via a small adapter) moves an org onto one. `GET /plans` lists the catalogue with each
checkout link already carrying the caller's org id. Root can also set `plan` on `PUT /orgs/{id}`
by hand, and `DELETE /orgs/{id}` removes a tenant that leaves. Unconfigured ⇒ off, which is the
self-hosted default. Full contract: **[billing.md](billing.md)**.

## Metrics

`GET /metrics` returns Prometheus text (`text/plain; version=0.0.4`) — `printpapi_jobs{state=…}`
(all five states, including zeros), `printpapi_agents_online`, `printpapi_agents_total`,
`printpapi_printers_total`. The numbers cover the presented key's org (all orgs for the bootstrap
token). It needs client auth, so point your scraper at it with a bearer token:

```yaml
scrape_configs:
  - job_name: printpapi
    authorization: { credentials: <client-key> }
    static_configs: [{ targets: ["yourserver:3460"] }]
```

## Job lifecycle

`queued` → agent claims it (`claimed`) → agent reports → `done` or `failed`.
If an agent claims a job and never reports (crash, network), the visibility-timeout reaper
requeues it — after a bounded number of retries the job is marked `failed`.

A job with `expire_after` whose deadline passes is never handed to an agent (the claim query skips
it) and is failed with `error: "expired"` by the same reaper — a terminal state, so its webhook
fires like any other outcome.

`DELETE /jobs/{id}` moves a job from `queued` to `cancelled` (a terminal state the agent never
claims). The cancel is state-guarded: once an agent has claimed the job it returns `409` — there
is no mid-print interrupt.
