Skip to content
forecraft
Sign in Get an API key
Menu
Get an API key Sign in

Docs

API reference

The REST API is the product: the dashboard, the designer and the MCP server are clients of these same routes. Everything is JSON under /api/v1. The machine-readable version is /openapi.json.

Authentication

Send an API key as a bearer token. Keys start with fc_live_, belong to one workspace, and are created in the dashboard under API keys (or printed by npm run seed).

curl -s "$FORECRAFT_URL/api/v1/forms" -H "Authorization: Bearer $FORECRAFT_API_KEY"

The dashboard's session cookie works too. Only the public submission endpoint needs neither.

Errors

Every error is application/problem+json (RFC 9457):

{
  "type": "https://forecraft.dev/problems/invalid-request",
  "title": "Invalid request",
  "status": 422,
  "detail": "/status: One of \"open\", \"closed\".",
  "instance": "/api/v1/forms/frm_8xQm2kVb0aTz",
  "errors": [{ "pointer": "/status", "detail": "One of \"open\", \"closed\"." }]
}

errors[].pointer is a JSON pointer into what you sent. A rejected submission's errors also carry field and rule. The type names the problem:

Statustype ends inWhen
400bad-jsonthe body is not JSON
401unauthorizedno key, or a revoked one
403cross-origin, session-requireda cookie-authenticated write from another origin; an invite made with a key
404not-foundno such form, version, submission or key in this workspace
409slug-taken, form-unpublished, form-closed, form-fulla slug in use; a form that cannot take submissions
415unsupported-media-typea submission that is neither JSON nor a form encoding
422invalid-definition, invalid-request, invalid-submissiona definition that does not render, a bad field, a broken rule
413too-largethe body is over the limit
429rate-limitedtoo many requests; wait Retry-After seconds

Rate limits

240 requests a minute per key (per person, for the session cookie). Each response carries RateLimit-Limit and RateLimit-Remaining; a 429 carries Retry-After. The public submission endpoint allows 10 submissions a minute per address (the socket's address; X-Forwarded-For is trusted only when the server runs with TRUST_PROXY=1). Bodies are capped: 256 KB for a submission, 1 MB elsewhere, 413 too-large beyond.

Forms

The form resource:

{
  "id": "frm_8xQm2kVb0aTz",
  "slug": "beta-waitlist",
  "title": "Beta waitlist",
  "status": "open",
  "submission_limit": null,
  "published_version": 1,
  "has_unpublished_changes": false,
  "submission_count": 12,
  "created_at": "2026-10-18T09:12:00.000Z",
  "updated_at": "2026-10-18T09:14:31.000Z",
  "definition": { "schema": { "…": "…" } },
  "url": "http://localhost:3000/f/beta-waitlist",
  "preview_url": "http://localhost:3000/app/forms/frm_8xQm2kVb0aTz/preview"
}
Method and pathBodyAnswer
GET /api/v1/forms—{ items }, newest first, without definition
POST /api/v1/forms{ definition, slug? }201 the form; Location header
GET /api/v1/forms/:id—the form
PATCH /api/v1/forms/:idany of definition, status ("open", "closed"), submission_limit (integer or null), slugthe form
DELETE /api/v1/forms/:id—204; its versions and submissions go too

The definition is described in form definitions. A slug is 3–64 lowercase letters, digits and dashes; leave it out and one is made from the title.

Preview, validate, publish

Method and pathBodyAnswer
POST /api/v1/preview{ definition }{ html, audit, rules } for a definition that is not saved anywhere
POST /api/v1/forms/:id/previewoptional { definition }the same, for the form's draft (or the definition you send)
POST /api/v1/forms/:id/validate{ data }{ valid, findings, data } against the draft; nothing stored
POST /api/v1/forms/:id/publish—201 { version, published_at, url }
GET /api/v1/forms/:id/versions—{ items: [{ version, published_at, live, submission_count }] }
GET /api/v1/forms/:id/versions/:n—{ version, published_at, definition }

audit is { passed, counts, findings } (counts by severity): the accessibility and markup audit of the exact HTML the public page will serve. rules is the rules definition that page enforces and the server re-checks.

Submissions

Method and pathAnswer
GET /api/v1/forms/:id/submissions{ items, total }, newest first
GET /api/v1/forms/:id/submissions/export?format=csva CSV file: id,submitted_at,version, then one column per field
GET /api/v1/forms/:id/submissions/export?format=json{ form: { id, slug }, items }
GET /api/v1/submissions/:idone submission
DELETE /api/v1/submissions/:id204

A submission is { id, form_id, version, created_at, data }. The list takes q (text anywhere in the answers), version, since and until (ISO 8601), limit (1–1000, default 50) and offset. Nested answers export as group.field columns and lists join with ; .

Public submissions

No key. This is what the public form posts to, and what an agent filling a form in on someone's behalf would call.

curl -s -X POST "$FORECRAFT_URL/api/v1/f/beta-waitlist/submissions" \
  -H "Content-Type: application/json" \
  -d '{ "data": { "email": "ada@example.com", "plan": "Solo" } }'

It takes JSON { "data": { … } } or application/x-www-form-urlencoded / multipart/form-data with the field names the form uses (contacts[0].name for a repeatable row). The answer is 201 { id, created_at, thank_you, redirect }. The server coerces the values, runs the form's rules, drops hidden and unknown fields, and stores what is left against the live version. A checkbox or switch field you leave out is stored as its default, or false when it has none, just as an untouched control on the page is sent: a visitor cannot leave a checkbox unanswered, so absent means "no". This happens after validation, so a required boolean you leave out is still refused, and a boolean a rule hides is not stored at all. A broken rule is a 422 invalid-submission naming the field and the rule, with the sentence the public page shows for it in detail ("This field is required.", "Enter a valid email address."; a message the form defines wins); an unpublished, closed or full form is a 409.

Workspace and keys

Method and pathBodyAnswer
GET /api/v1/workspace—{ id, name, created_at }
PATCH /api/v1/workspace{ name }the workspace
GET /api/v1/workspace/members—{ members, invites }
POST /api/v1/workspace/invites{ email }201 { id, email, url }; signed-in people only, not keys
GET /api/v1/keys—{ items } (never the secret)
POST /api/v1/keys{ name? }201 the key with its secret, shown this once; signed-in people only, not keys
DELETE /api/v1/keys/:id—204; the key stops working at once; signed-in people only, not keys
GET /api/v1/vocabulary—{ themes, accents, layouts, schemes, appearance_defaults }

Outside /api/v1

  • GET /f/:slug: the public form, latest published version.
  • GET /health: { "ok": true }.
  • GET /llms.txt and GET /openapi.json: this reference, for machines.

Rules forecraft MCP server