REST API reference for forms, versions, submissions and AI generation.

API Reference

Rate limits. All endpoints are throttled. Login is 10/min per IP; AI endpoints (/api/conversions, /api/forms/from-prompt, /api/forms/:id/jsonforms/refine) are 10/min per user; uploads 30/min; everything else 300/min. Exceeding a limit returns 429 with a Retry-After header. GET /api/health is exempt. See security/AUTH-AND-RBAC.

Base URL

http://localhost:3100/api

Authentication

All endpoints except /api/auth/login, /api/auth/google* and /api/public/* require a valid JWT in the Authorization: Bearer <token> header.

Endpoints

Auth

Method Path Description
POST /api/auth/exchange Trade the one-time code from an SSO redirect (either provider) for { accessToken, user }. Single-use, 60s TTL; expired/spent/unknown all return the same 401. The redirect never carries the token itself
POST /api/auth/login Login, returns JWT (audit-logged as auth.login)
GET /api/auth/microsoft Start the Microsoft (Entra ID) handshake. Same ?mode=signup&org=&country= contract as Google. 503 when unconfigured
GET /api/auth/microsoft/callback Microsoft callback; same one-time-code redirect as Google
GET /api/auth/google Start Google OAuth2 handshake (redirect). ?mode=signup&org=...&country=... provisions a new tenant on first sign-in (org + country mandatory); default login is invite-only
GET /api/auth/google/callback Google OAuth2 callback, redirects to web with JWT
GET /api/auth/me Current user profile

Workspace

Method Path Description
GET /api/me/workspace-status The caller's form-creation quota (used/limit/remaining/unlimited/reason) and which tier is currently serving their AI calls (ai.effectiveSource: tenant/global/env/none). Powers the dashboard's AI-setup notice; contactEmail is included for the "raise my limit" CTA.

Admin (SUPER_ADMIN only)

Method Path Description
GET /api/admin/stats Platform-wide analytics: totals, per-tenant (incl. country) and per-user breakdown (forms, submissions, last login, AI token usage), users by country, usage by provider, recent logins. Gated to SUPER_ADMIN by the global RolesGuard; other roles receive 403.
GET /api/admin/usage Token spend grouped along one dimension: ?groupBy=user|form|tenant|provider|operation (default user), optionally windowed with ?from=/?to= (ISO dates; 400 on unparseable). Returns platform totals plus rows of { key, label, calls, inputTokens, cachedInputTokens, outputTokens, totalTokens, lastUsedAt } (cachedInputTokens = input served from the provider's prompt cache) sorted by tokens desc. The operation dimension answers the AI-cost questions (issue #128): which pipeline dominates, and how big is a typical call — its rows additionally carry outputP50/outputP95 (output tokens per call, computed over the windowed rows). Rows with no key surface as Unattributed so the grouped rows always reconcile with the total; a key whose entity was deleted labels as (deleted).
PATCH /api/admin/users/:userId/form-limit Set a user's form creation quota (`{ formLimit: number

AI Settings

All four accept ?scope=tenant|global. tenant is the caller's own organization; global is the platform-wide fallback and is SUPER_ADMIN only (403 otherwise). Omitting scope keeps the legacy default (SUPER_ADMIN → global, everyone else → tenant); under that default a SUPER_ADMIN cannot reach their own tenant, which ?scope=tenant fixes.

Method Path Description
GET /api/settings/ai-providers List provider configurations in the requested scope
POST /api/settings/ai-providers Add a provider in the requested scope; keys are encrypted and never returned unmasked
PUT /api/settings/ai-providers/:id Update a provider only when it belongs to the requested scope
DELETE /api/settings/ai-providers/:id Delete a provider only when it belongs to the requested scope

Forms

Method Path Description
GET /api/forms List forms (paginated). Archived forms are excluded unless ?includeArchived=true; /api/forms/count counts the same set
GET /api/forms/count Total form count for the tenant
POST /api/forms Create form. Subject to the per-user creation quota (default 5; 403 with a contact-admin message when exceeded) — waived once the tenant has configured its own active AI provider (Settings → AI Providers), since it then pays for its own AI usage. SUPER_ADMIN is exempt. The same quota applies to every route that creates a form: /api/forms/from-prompt, /api/forms/:id/clone, /api/forms/import and POST /api/conversions. It is checked before any LLM call, so a user at their limit is refused without spending tokens
GET /api/forms/:id Get form with current version
PUT /api/forms/:id Update form metadata
POST /api/forms/:id/unarchive Restore an archived form to the status it had when archived (statusBeforeArchive, or DRAFT for forms archived before that was recorded). 400 if not archived. Audited as form.unarchive
DELETE /api/forms/:id Archive form (soft delete — sets status ARCHIVED)
GET /api/forms/:id/deletion-summary Counts of versions and submissions a permanent delete would destroy
DELETE /api/forms/:id/permanent Permanently delete the form and ALL related data (versions, submissions, AI messages) — irreversible
PUT /api/forms/:id/schema Save form schema (auto-save)
POST /api/forms/from-file Upload PDF/image, generate schema, and create draft form. name and category are required (400 otherwise)
POST /api/forms/from-pdf Compatibility alias for PDF/image generation (same required fields as from-file)
POST /api/forms/from-prompt JSON { name, prompt, category?, description?, formType?, provider? }: AI generates the separated Data/UI/Print schemas from the prompt and creates a draft form (subject to the creation quota). name and prompt are required (400 otherwise). category and formType are written to the form row — the same metadata POST /api/conversions accepts, so both creation routes produce equally complete forms. Returns { form, warnings }
POST /api/forms/:id/publish Publish current draft (stores an immutable SHA-256 content_hash; audit-logged)
GET /api/forms/:id/versions List versions
GET /api/forms/:id/versions/:versionId/integrity Recompute a published version's content hash to detect tampering
POST /api/forms/:id/clone Clone form
GET /api/forms/:id/export Export an OpenMedForm template bundle for re-import
POST /api/forms/:id/jsonforms/refine Prompt-based designer: refine a jsonforms form's Data/UI/Print schemas via natural language; accepts JSON or multipart image visual reference (SSE stream; edits a draft in place, or forks a new draft if published — the published version stays current for data entry until the fork is published)
PATCH /api/forms/:id/coding Set/replace/clear the clinical terminology bindings of one field or answer option ({ scope, optionCode?, coding[] }; empty list clears). Draft edited in place, published forks (the published version stays current for data entry until the fork is published) — audited as form.coding.update. See docs/features/CLINICAL-TERMINOLOGY.md
GET /api/terminology/systems Which terminology systems this tenant can use (loinc/icd10/snomed) with reasons when off — the SNOMED licensing gate, reported
GET /api/terminology/search Top candidates for ?system=loinc|icd10|snomed&q=. LOINC/ICD-10 search local tables; SNOMED proxies the configured FHIR terminology server and is tenant-gated
GET /api/terminology/loinc Top LOINC candidates for ?q= (name, synonym, or exact code) from the locally loaded table; also reports how many codes are loaded
PATCH /api/forms/:id/field-meta Set/clear a field's or a section's previous-values setting and a field's unit (ADR-006): { target: { scope } | { pointer }, history?: { show, count?, trend? } | null, unit?: string | null }. pointer addresses a layout element (a Group) by its /elements/N path in uiSchema.layout; unit is refused on a section. Draft edited in place, published forks (published version stays current until the fork is published) — audited as form.field-meta.update
POST /api/forms/:id/coding/suggest Retrieve-then-select AI pass: for every uncoded field, search the local LOINC table for candidates and let the model choose among them or decline (it can never invent a code). Writes source: 'ai', verified: false suggestions to the draft for dictionary approval; never overwrites existing bindings. Audited form.coding.suggest; metered coding.suggest
GET /api/forms/:id/ai/messages The form's refine conversation (chat history), oldest first — one row per instruction/outcome, failures included

Submissions

Method Path Description
GET /api/forms/:formId/submissions List submissions
POST /api/forms/:formId/submissions Start submission. Optional effectiveAt (ISO-8601): when the readings were taken
GET /api/submissions List records. Voided ones are excluded unless ?includeVoided=true. /api/submissions/count counts the same set
GET /api/submissions/count Total record count, matching the default list (voided excluded)
GET /api/submissions/:id Get submission
PUT /api/submissions/:id Update submission (auto-save). Optional effectiveAt
POST /api/submissions/:id/complete Finalize and score (jsonforms: Ajv-validated server-side; 400 on invalid; audit-logged). Fixes effectiveAt and rebuilds the submission's observation rows (ADR-005)
DELETE /api/submissions/:id Void a record — how "delete" behaves for clinical data. Status becomes VOIDED; the row and its data are kept and drop out of the default list. Own records for any user; anyone's for TENANT_ADMIN/SUPER_ADMIN (403 otherwise). Idempotent. Audited as submission.void with the previous status
DELETE /api/submissions/:id/permanent Destroy a record. TENANT_ADMIN/SUPER_ADMIN only (403 otherwise), unrecoverable. Audited as submission.delete before the row is removed, with form, status, MRN, encounter and submitter — once it is gone that entry is the only trace
POST /api/submissions/:id/sign Sign a COMPLETED submission → status SIGNED + signed_at/signed_by (audit-logged)

Patient observations (ADR-005)

Any authenticated user of the tenant, like submission reads. Rows come from completed submissions only; voided ones are excluded.

Method Path Description
GET /api/patients/:mrn/observations Prior readings, newest first — the server side of a renderer historyProvider. Query code=8867-4[&system=http://loinc.org] or path=vitals.pulse (index-free), plus limit (default 5, max 500), optional formId, from, to. Returns form-core Observation[]
GET /api/patients/:mrn/flowsheet Everything charted on one form for the patient: { definition: { dataSchema, uiSchema } | null, observations }. Required formId; optional from, to, limit. The client builds the grid with form-core buildFlowsheet

AI Builder

Method Path Description
POST /api/ai/generate Generate form from prompt
POST /api/ai/generate-from-pdf Generate schema from uploaded PDF
GET /api/ai/providers List configured LLM providers

Conversions (engine-targeted PDF/image → form)

Async pipeline (Phase 6). POST creates a conversion_job and runs in the background; poll GET /api/conversions/:id for status (PENDING → RUNNING → REVIEW | FAILED). On success a draft form (status REVIEW) is created for the chosen engine, and for jsonforms per-field confidence + warnings are persisted.

Method Path Description
POST /api/conversions multipart: file, optional provider, instructions, category, formType (PATIENT|NON_PATIENT), extractScriptConfig. category/formType are the same form metadata /api/forms/from-prompt collects and are written to the created form row; omitting them leaves the column null / the PATIENT schema default. The body is validated with forbidNonWhitelisted, so an undeclared field is a 400. Returns the created job. Accepts PDF, PNG/JPEG/WebP/GIF, and HTML (text/html, max 2MB — 400 otherwise). Oversized/empty HTML mock-ups are rejected with guidance rather than half-converted. extractScriptConfig ("true"/"1"; anything else, including absent, is off) opts an HTML upload in to having its scripts parsed, never executed for literal option lists / thresholds / reference tables — see PDF-TO-FORM. The choice is recorded in the ai.convert audit entry
POST /api/conversions/:id/accept Accept a reviewed job: promote the draft form REVIEW→DRAFT, mark job COMPLETED (audited)
GET /api/conversions List conversion jobs for the tenant
GET /api/conversions/:id Job status + persisted warnings. While RUNNING, stage (READING_SOURCE / GENERATING / VALIDATING / SAVING) and stageDetail (e.g. 3 pages · claude) report live progress for the dialog's checklist; both are null once the job finishes