Every quickform-server endpoint — the two public ones your form page uses, and the admin surface the PWA, the portal and your agents use.
Base path is /api. All bodies are JSON. Admin endpoints take
Authorization: Bearer <token>; with no QUICKFORM_ADMIN_TOKEN configured the
server runs open and the header is ignored.
Two token scopes exist:
QUICKFORM_ADMIN_TOKEN) — every org; this is a service
credential for a portal or an operator, never something a browser holds;Both are unauthenticated by design and gated by the form's allowedOrigins.
Both answer OPTIONS preflight. The CORS response echoes the caller's exact
Origin and sets Access-Control-Allow-Credentials: true (never *), because
the QuickForm runtime submits with credentialed fetch.
GET /api/orgs/{org}/forms/{form}/definitionReturns the stored definition with a submit block injected:
{
"intro": { "…": "…" },
"questions": { "…": "…" },
"submit": {
"submitUrl": "https://forms.example.com/api/orgs/acme/forms/contact/submissions",
"submitMethod": "POST"
}
}
submitUrl is built from PUBLIC_BASE_URL when set, and from the request host
otherwise. Set it whenever anything reaches the server through a proxy or from
another container — otherwise a container-to-container call produces an internal
host that a browser cannot reach.
POST /api/orgs/{org}/forms/{form}/submissionsIngest. The body is whatever the QuickForm runtime posts — a flat
logicalName → value map, plus a submitFields block when the definition has
one. The body is capped at 256 KiB; the raw bytes are stored untouched
alongside the parsed answers.
Returns 200 with {"status":"received","id":"…"}. Webhooks and Web Push fan out
after the answer is durable, so a dead webhook never costs a submitter their
answer.
| Method | Path | Notes |
|---|---|---|
GET | /api/orgs | List orgs. Token hashes are stripped |
POST | /api/orgs | {slug, name}. Admin token only |
GET | /api/orgs/{org} | One org |
PUT | /api/orgs/{org} | Update name |
POST | /api/orgs/{org}/tokens | Mint an org token: {label} |
Minting returns the plaintext exactly once. Only hex(sha256(secret)) is
persisted, so the server cannot show you the key again — that is the point of
storing it that way.
| Method | Path | Notes |
|---|---|---|
GET | /api/orgs/{org}/forms | List forms with counts and timestamps |
GET | /api/orgs/{org}/forms/{form} | {form, definition?, embedUrl} |
PUT | /api/orgs/{org}/forms/{form} | Create or replace metadata |
DELETE | /api/orgs/{org}/forms/{form} | Delete the form and its answers |
PUT | /api/orgs/{org}/forms/{form}/definition | Store a definition (2 MiB cap) |
PUT …/forms/{form} takes:
{
"name": "Contact",
"description": "",
"allowedOrigins": ["https://acme.example"],
"webhooks": [{ "url": "https://…", "secret": "whsec_…" }],
"definition": { "…": "…" }
}
Two behaviours worth knowing, because they differ:
name, description, allowedOrigins and webhooks are replaced
unconditionally — a PUT that omits webhooks clears them.definition is applied only when present and non-empty. Omit it and the
stored definition (and its version) is left alone. Sending an unchanged
definition would otherwise mint a byte-identical new version.The embedUrl on GET …/forms/{form} is the definition URL — the single thing
a customer's developer needs.
| Method | Path | Notes |
|---|---|---|
GET | /api/orgs/{org}/submissions?limit=N | Cross-form feed of summaries |
GET | /api/orgs/{org}/forms/{form}/submissions?limit=N | One form's summaries |
GET | /api/orgs/{org}/forms/{form}/submissions/{id} | The full answer set |
POST | /api/orgs/{org}/forms/{form}/submissions/{id}/read | Mark as read |
A summary carries {id, org, form, receivedAt, read, preview} — enough for a
feed row without shipping every answer of every submission. The full record adds
answers, submitFields, origin, referer, userAgent, ip and the raw
body.
read is a human-facing flag set by the PWA or the portal. Agents ignore it;
they are driven by the webhook, not by a queue they have to drain.
| Method | Path | Notes |
|---|---|---|
GET | /api/push/vapid | The public VAPID key for the PWA |
POST | /api/orgs/{org}/push/subscribe | Store a subscription |
POST | /api/orgs/{org}/push/unsubscribe | Drop one |
Keys are generated into USER_DATA_DIR on first boot. The notification payload
names the org and the form and nothing else — the answers are fetched when a
person opens the app, never carried through a push service.
| Method | Path | Notes |
|---|---|---|
GET | /api/health | Liveness |
GET | /api/version | Build version and uptime |
Everything not under /api is served from WEB_DIR — the PWA.