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
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 changes
DELETE/api/forms/:id/overridesAdminGo back to the schedule 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.

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": "[email protected]" }, "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
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 real submission would — 201 with { id, number }, or 409 / 410 — but nothing is stored.

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.

Export

GET /api/forms/:id/export?format=csv downloads every response as <id>-responses-<date>.<format>, oldest first.

  • CSV (default): #, Submitted at, Language on translated forms once a response has one, then one column per field headed by its label. Fields that only older versions had keep their columns. UTF-8 with a BOM, and cells that start with =, +, - or @ are escaped so spreadsheets don't run them as formulas.
  • 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 } — 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.

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