HTTP API
The routes every Open Form server answers under /api, and the handler behind them.
open-form dev, open-form start and the Next.js route all serve the same
API, mounted at /api. It's one Web-standard (Request) => Response handler
from @open-form-build/core/server, so it runs on any runtime with Request and
Response. The form page and <FormHost> use the public routes; Studio uses
the admin routes.
Routes
| Method | Path | Access | What |
|---|---|---|---|
GET | /api/forms/:id | Public | Form status and a timing token |
POST | /api/forms/:id/submissions | Public | Submit a response |
GET | /api/forms | Admin | Every form with response counts and status |
GET | /api/forms/:id/schema | Admin | Schema, config, effective schedule and status |
GET | /api/forms/:id/submissions | Admin | Responses, paged: limit (default 50, max 500) and offset as whole numbers, q, and Studio's filter and sort as JSON (see below) |
GET | /api/forms/:id/submissions/:sid | Admin | One response with the schema it was submitted under |
DELETE | /api/forms/:id/submissions/:sid | Admin | Delete one response |
POST | /api/forms/:id/submissions/delete | Admin | Delete 1–500 responses: { ids } |
PUT | /api/forms/:id/overrides | Admin | Save Studio's schedule, time zone and Published changes |
DELETE | /api/forms/:id/overrides | Admin | Go back to the settings in code |
GET | /api/forms/:id/export | Admin | Download responses: format=csv, xlsx or json |
GET | /api/studio/folders | Admin | Studio's form folders, from forms/.folders.json (as of the build in production) |
GET | /api/studio/session | Open | Whether this browser is signed in to Studio |
POST | /api/studio/session | Open | Sign in with { password } |
DELETE | /api/studio/session | Open | Sign out |
Responses are JSON with cache-control: no-store. Errors share one shape:
{ "error": "<code>", "message": "<readable text>" }. Across the API, an
unknown path gets 404 not_found, a known path with the wrong method gets
405 method_not_allowed, and an unexpected failure gets 500 internal
with a generic message (the details only with exposeErrors).
Form status
GET /api/forms/:id tells the page whether to show the questions:
{
"id": "meetup",
"title": "Developer Meetup Registration",
"availability": { "state": "open" },
"token": "…"
}availability.state is open, closed, not-open (with opensAt),
full or duplicate (for uniqueBy: 'device'). Closed states carry the
form's message. token is a signed timing token, issued only while the form
is open. An unpublished form answers
404 here and on submit, like a form that doesn't exist.
For duplicate, the device is the of_vid cookie the server sets on submit
(HttpOnly, SameSite=Lax, one year). Where cookies are blocked, as in
embeds, send the page's own random id — 16 to 64 letters, digits, - or _
— in the x-open-form-visitor header. <FormHost> and the form page do this
for you.
Submitting
POST /api/forms/meetup/submissions
Content-Type: application/json
{ "data": { "name": "Ada", "email": "ada@example.com" }, "locale": "en", "token": "…", "hp": "" }| Field | What |
|---|---|
data | Answers keyed by field name. |
locale | The language shown. Used for error messages and stored with the response if the form offers it; otherwise the form's own language is stored. |
token | The timing token from the status route. Required unless minimumCompletionTime is false. |
hp | The honeypot input. Must be empty. |
visitor | Optional browser id for uniqueBy: 'device' when cookies are blocked, as in embeds. The x-open-form-visitor header works too. |
The handler checks the request's origin, the form's schedule and the timing
token. It then drops unknown fields and fields hidden by <When>, coerces the
rest, and runs the same validation as the browser plus custom validators. Only a
valid submission counts against the rate limit, so fixing mistakes doesn't use
it up. The response is stored with maxResponses and uniqueBy checked in one
transaction, and webhooks go out after the response.
| Status | error | When |
|---|---|---|
201 | — | Stored. Body: { id, number }. |
400 | too_fast, invalid_token, expired_token, bad_request | Sent too quickly, bad token, a token over 7 days old (reloading the page gets a new one), or a body that isn't a JSON object |
403 | forbidden | Cross-site request from an origin not in embed.origins |
404 | not_found | No such form, or it's unpublished |
409 | duplicate | uniqueBy already has a response for this value or device |
410 | closed, not_open, full | Outside the schedule, or maxResponses reached |
413 | too_large | Body over the limit (256 KB by default) |
415 | unsupported_media_type | Not application/json |
422 | invalid | Validation failed; errors maps field names to messages |
429 | rate_limited | Over the form's rate limit |
A filled-in honeypot goes through the same checks and gets the answer a new
submission would — 201 with { id, number }, or 410 — but nothing is
stored. It never answers 409: that would tell, without leaving a trace,
whether a uniqueBy value has already responded.
Same origin means the Origin header matches the request URL. Behind a proxy
that ends TLS the server sees http:// while the browser sends an https://
Origin for the same host; that counts as the same origin. The reverse, an
http:// page calling an https:// server, doesn't.
Listing responses
GET /api/forms/:id/submissions answers
{ items, total, columns, limit, offset }, newest first unless sort says
otherwise. total counts the
responses that match q and filter, and columns lists the table's columns
as { key, header, type? }: the newest schema's fields in order, then fields
only older versions had.
filter and sort are what Studio's Responses tab sends, as URL-encoded JSON:
filter:{ match?: 'all' | 'any', rules, timeZone? }, where each rule is{ field?, op, value?, values?, to? }; a rule withoutfieldtests when the response was submitted. Up to 20 rules.sort:{ field?, direction: 'asc' | 'desc', values? };valuesorders a choice question's answers by the options' order.
Both types, ResponseFilter and ResponseSort, and their parsers
parseResponseFilter and parseResponseSort are exported from
@open-form-build/core/server. A value that isn't valid JSON or isn't a valid
rule answers 400 bad_request.
Export
GET /api/forms/:id/export?format=csv downloads every response as
<id>-responses-<date>.<format>, oldest first. q and filter, the same as
on the submissions list, export only the responses they match.
- CSV (default):
#,Submitted at (<time zone>)asYYYY-MM-DD HH:mm:ssin the form's time zone,Languageby name on translated forms once a response has one, then one column per field headed by its label. Checkboxes areYesorNo. Fields that only older versions had keep their columns. UTF-8 with a BOM, and text cells that start with=,+,-,@, a tab or a carriage return are escaped so spreadsheets don't run them as formulas, except a+followed only by digits, such as an international phone number, which is written as stored. - xlsx: the same columns with typed numbers and dates;
Submitted atis shown in the form's time zone. - json:
[{ id, number, submittedAt, locale?, data }].
Overrides
PUT /api/forms/:id/overrides takes the schedule Studio wants —
{ submissions: { opensAt, closesAt, maxResponses, closed }, timeZone, published }
— and stores only what differs from the code. It answers { overrides }. Other
policy keys can't be overridden; change them in code.
Admin access
Without adminAuth, every admin and session route answers 404: the API is
public forms only. With it, an unauthenticated admin request gets 401
unauthorized. Requests that change data must also come from the same origin.
Prop
Type
The session cookie is of_session: HttpOnly, SameSite=Strict, and
Secure over HTTPS. DELETE /api/studio/session revokes the session in
storage, so the same cookie is refused afterwards. Sign-in is limited to 10
attempts per IP (per /64 for IPv6) every 10 minutes: a wrong password gets
401 invalid_password, and attempts over the limit get 429 rate_limited.
With publicStudio: 'read-only', the admin GET routes (forms, schemas,
responses, export, folders) answer everyone, and GET /api/studio/session
answers even without adminAuth, with readOnly: true for a browser that
isn't signed in. The routes that change data still need adminAuth, and
answer 404 without it. open-form start sets it from
studio.public.
open-form start and the Next.js route turn on password sign-in when
OPEN_FORM_ADMIN_PASSWORD is set. open-form dev always serves the admin
routes: without a password, only direct requests from your own machine get in
and the rest are refused, with a hint in the terminal to set one; with a
password, every request signs in, local ones too.
createOpenFormHandler
Most projects never call it: open-form start, open-form dev and
createOpenFormRoute from @open-form-build/core/next build it for you. Use it to mount
the API in another server.
import { createEntryRegistry, forwardedAddress } from '@open-form-build/core/node';
import { createOpenFormHandler } from '@open-form-build/core/server';
import { createSqliteStorage } from '@open-form-build/core/sqlite';
import * as entry from './dist/server/entry.js'; // written by `open-form build`
export const handler = createOpenFormHandler({
registry: createEntryRegistry(entry),
storage: await createSqliteStorage({ path: '.open-form/open-form.db' }),
secret: process.env.OPEN_FORM_SECRET!,
config: entry.config,
adminAuth: { password: process.env.OPEN_FORM_ADMIN_PASSWORD! },
// behind one reverse proxy that appends the client to X-Forwarded-For
clientAddress: (request) => forwardedAddress(request.headers.get('x-forwarded-for'), 1),
});Prop
Type
Cross-origin calls are allowed only on the two public routes and only for
sites in embed.origins, never with credentials; they may send
content-type and x-open-form-visitor. An OPTIONS preflight on those
routes gets 204 for an allowed site and 404 otherwise.
open-form dev adds more /api/studio/* routes for Studio's editing
features. They aren't part of the handler and don't exist in production.
/api/studio/session and /api/studio/folders belong to the handler, so
production serves them too. There, /api/studio/folders answers with
forms/.folders.json as it was when you ran open-form build. The Next.js
route has no folders, so it answers { "folders": [], "assignments": {} }, as
does any handler whose registry has no folders().