Open Form

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

MethodPathAccessWhat
GET/api/forms/:idPublicForm status and a timing token
POST/api/forms/:id/submissionsPublicSubmit a response
GET/api/formsAdminEvery form with response counts and status
GET/api/forms/:id/schemaAdminSchema, config, effective schedule and status
GET/api/forms/:id/submissionsAdminResponses, 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/:sidAdminOne response with the schema it was submitted under
DELETE/api/forms/:id/submissions/:sidAdminDelete one response
POST/api/forms/:id/submissions/deleteAdminDelete 1–500 responses: { ids }
PUT/api/forms/:id/overridesAdminSave Studio's schedule, time zone and Published changes
DELETE/api/forms/:id/overridesAdminGo back to the settings in code
GET/api/forms/:id/exportAdminDownload responses: format=csv, xlsx or json
GET/api/studio/foldersAdminStudio's form folders, from forms/.folders.json (as of the build in production)
GET/api/studio/sessionOpenWhether this browser is signed in to Studio
POST/api/studio/sessionOpenSign in with { password }
DELETE/api/studio/sessionOpenSign 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": "" }
FieldWhat
dataAnswers keyed by field name.
localeThe language shown. Used for error messages and stored with the response if the form offers it; otherwise the form's own language is stored.
tokenThe timing token from the status route. Required unless minimumCompletionTime is false.
hpThe honeypot input. Must be empty.
visitorOptional 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.

StatuserrorWhen
201—Stored. Body: { id, number }.
400too_fast, invalid_token, expired_token, bad_requestSent 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
403forbiddenCross-site request from an origin not in embed.origins
404not_foundNo such form, or it's unpublished
409duplicateuniqueBy already has a response for this value or device
410closed, not_open, fullOutside the schedule, or maxResponses reached
413too_largeBody over the limit (256 KB by default)
415unsupported_media_typeNot application/json
422invalidValidation failed; errors maps field names to messages
429rate_limitedOver 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 without field tests when the response was submitted. Up to 20 rules.
  • sort: { field?, direction: 'asc' | 'desc', values? }; values orders 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>) as YYYY-MM-DD HH:mm:ss in the form's time zone, Language by name on translated forms once a response has one, then one column per field headed by its label. Checkboxes are Yes or No. 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 at is 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.

server.ts
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().

On this page