Skip to content
Join our Slack

Forms

A form is the resource everything else in the Platform API hangs off. This page covers creating and reading forms, replacing their drafts, and the lifecycle calls that decide whether respondents can reach them.

OperationWhat it does
POST /v1/formsCreates a form, or a Private Template, from a Form Blueprint.
POST /v1/forms/{form_public_id}/duplicateCopies a form or a Private Template into a new draft.
GET /v1/formsLists forms, or Private Templates when is_template=true.
GET /v1/forms/{form_public_id}Reads one form’s status, access, counts, and links.
GET /v1/forms/{form_public_id}/draftReads the Draft Document, the form as it stands in the editor.
PUT /v1/forms/{form_public_id}/draftReplaces the Draft Document with the one you send.
GET /v1/forms/{form_public_id}/publishedReads the Published Document, the version respondents fill in.
POST /v1/forms/{form_public_id}/validateReports whether the draft would publish, without publishing it.
POST /v1/forms/{form_public_id}/publishPublishes the stored draft.
POST /v1/forms/{form_public_id}/unpublishTakes the form offline.
POST /v1/forms/{form_public_id}/closeStops the form accepting submissions now.
POST /v1/forms/{form_public_id}/reopenLets the form accept submissions again.
PATCH /v1/forms/{form_public_id}/accessSets the closed flag, the schedule, and the duplicate-response rule.
PUT /v1/forms/{form_public_id}/domainSets the custom domain and slug of the share link.
DELETE /v1/forms/{form_public_id}/domainRemoves the custom domain from the share link.
POST /v1/forms/{form_public_id}/mark-templateTurns a form into a Private Template in place.
POST /v1/forms/{form_public_id}/moveMoves the form to another workspace.
DELETE /v1/forms/{form_public_id}Moves the form to Trash.
POST /v1/forms/{form_public_id}/restoreBrings the form back from Trash as a draft.
GET /v1/workspacesLists the organization’s workspaces. Workspaces covers the other workspace calls.

A Personal Access Token acts with the permissions of the member who owns it.

CallsPermission
Reads, validate, and GET /v1/workspacesforms:read, or templates:read for a Private Template
Every other writeforms:write, or templates:write for a Private Template
publish and unpublishforms:write and form_publish. A Private Template is never published
duplicateRead on the source’s kind, and write on the kind the copy becomes
mark-templateforms:write and templates:write together
PUT and DELETE …/domainforms:write and domains:write

A missing permission is 403 FORBIDDEN, with the permission named in required_permission. The Public OpenAPI Document states each operation’s requirement in x-fomr-permission. To check what a token can do before you call, use GET /v1/me.

POST /v1/forms takes a Form Blueprint and answers 201 with the form resource plus, under draft, the whole document as stored. Every page and block ID the server minted is in that document, so you can address those blocks in your next call without a second read. The draft’s ETag comes back as the ETag response header.

  • Workspace. The form lands in the Default Workspace unless you add ?workspace_public_id= naming another workspace. An unknown ID is 400 INVALID_INPUT at path workspace_public_id.
  • Server-owned fields. public_id is ignored and assigned. styles.theme_id picks a theme; without it the form gets the default theme.
  • Honored only here. is_template and access are applied on create, though a draft write ignores them. Send is_template: true to create a Private Template.
Terminal window
curl -s -X POST https://platform.fomr.io/v1/forms \
-H "Authorization: Bearer $FOMR_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @event-registration.json
POST /v1/forms
{
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"status": "draft",
"has_unpublished_changes": true,
"published_version": null,
"published_at": null,
"source_template_public_id": null,
"workspace_public_id": "Mn1w",
"access": {
"is_closed": false,
"close_at": null,
"open_at": null,
"accepting_submissions": false,
"duplicate_response_rule": null
},
"submissions": {
"completed_count": 0,
"partial_count": 0
},
"urls": {
"editor": "https://fomr.io/app/s/Ev3ntReg01/_/editor",
"share": null
},
"domain": null,
"preview_image_url": null,
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-01T09:00:00.000Z",
"draft": {
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"hidden_values": [
{
"key": "utm_source",
"default_value": null
}
],
"logic_rules": [],
"calculated_values": [],
"pages": [
{
"public_id": "pIn1",
"name": "Welcome",
"type": "intro",
"is_hidden": false,
"is_entrypoint": true,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nWlc",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text",
"meta": {
"content": "<p>Join us for the Acme Events community meetup.</p>"
}
},
{
"public_id": "nSt1",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Start",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pQ01",
"name": "Your details",
"type": "normal",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nNam",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text_input_short",
"meta": {
"question": "<span>Full name</span>",
"placeholder": "Jane Doe",
"validation": {
"required": true
}
}
},
{
"public_id": "nEml",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "text_input_email",
"meta": {
"question": "<span>Email address</span>",
"placeholder": "[email protected]",
"validation": {
"required": true
}
}
},
{
"public_id": "nTck",
"parent_node_id": null,
"column_index": null,
"sort_order": 3,
"is_hidden": false,
"type": "multiple_choice",
"meta": {
"question": "<span>Ticket type</span>",
"select_options_data": [
{
"value": "general",
"label": "General",
"label_formatted": "<span>General</span>"
},
{
"value": "student",
"label": "Student",
"label_formatted": "<span>Student</span>"
},
{
"value": "speaker",
"label": "Speaker",
"label_formatted": "<span>Speaker</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"multiple_choice_variant": "alphabet",
"validation": {
"required": true
}
}
},
{
"public_id": "nDie",
"parent_node_id": null,
"column_index": null,
"sort_order": 4,
"is_hidden": false,
"type": "checkbox_choice",
"meta": {
"question": "<span>Dietary needs</span>",
"select_options_data": [
{
"value": "vegetarian",
"label": "Vegetarian",
"label_formatted": "<span>Vegetarian</span>"
},
{
"value": "vegan",
"label": "Vegan",
"label_formatted": "<span>Vegan</span>"
},
{
"value": "gluten_free",
"label": "Gluten-free",
"label_formatted": "<span>Gluten-free</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"select_options_style": "checkbox",
"validation": {
"required": false
}
}
},
{
"public_id": "nCmp",
"parent_node_id": null,
"column_index": null,
"sort_order": 5,
"is_hidden": true,
"type": "text_input_short",
"meta": {
"question": "<span>Company</span>",
"validation": {
"required": false
}
}
},
{
"public_id": "nSub",
"parent_node_id": null,
"column_index": null,
"sort_order": 6,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Register",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pFn1",
"name": "Thank you",
"type": "final",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nThx",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "thank_you",
"meta": {
"thank_you_title": "<h1 style=\"text-align: center\">See you there!</h1>",
"thank_you_description": "<p style=\"text-align: center\">We sent a confirmation to your email address.</p>",
"thank_you_icon": "check_animated"
}
}
]
}
],
"settings": {
"email_notifications_enabled_owner": 1,
"email_notifications_enabled_respondent": 0,
"email_notifications_recipients_owner": [
],
"email_notifications_recipient_respondent": null
},
"properties": {
"show_branding": 1,
"auto_jump_next_page": 1,
"allow_back_navigation": 1,
"meta_title": null,
"meta_description": null,
"meta_index": 1,
"meta_favicon_url": null,
"meta_ogimage_url": null,
"partial_response_capture_enabled": 0
},
"access": {
"is_closed": 0,
"close_at": null,
"open_at": null,
"duplicate_response_rule": null
},
"styles": {
"theme_id": null,
"override_properties": null
}
}
}

Add ?dry_run=true and nothing is written. The server runs the same validation and plan checks and answers 200 with dry_run: true and the document the real call would have stored. There is no ETag, because no draft exists, and the IDs in the document are illustrative: nothing reserves them. A dry run fails with exactly the errors the real call would give.

A create either succeeds with 201 or fails with an error and writes nothing. A failed call spends no credits, so you can fix the request and send it again. If the connection drops before you see a response, list your forms before sending the create again, so you do not end up with a copy.

  • Form limit. If your plan sets a form limit and you are at it, the call is 402 PLAN_LIMIT_REACHED with limit.key, limit.max, and limit.current. Forms in Trash count until they are permanently deleted. Private Templates do not count.
  • Branding. On Free, properties.show_branding is forced on rather than refused. On Pro, a form created without show_branding hides the branding, as a form created in the app does. Send show_branding: true to keep it.
  • Pro features. Any other field your plan does not include is refused with 402 PRO_FEATURE_REQUIRED, naming the feature and the path of the field.

Nothing is written when a plan check fails.

POST /v1/forms/{form_public_id}/duplicate copies a form or a Private Template into a new draft. It answers 201 with the same envelope as creating a form: the copy’s form resource, its stored Draft Document under draft, and the ETag response header.

Terminal window
curl -s -X POST "https://platform.fomr.io/v1/forms/Ev3ntReg01/duplicate" \
-H "Authorization: Bearer $FOMR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"Autumn registration"}'

Both body fields are optional and no others are accepted. Send {}, or no body, to copy the source as it stands.

FieldEffect
nameThe copy’s name, 1 to 100 characters.
is_templateWhether the copy is a Private Template. Left out, the copy keeps the source’s kind.

The copy is made from the source’s draft, or from its published version when the source holds no draft. It carries over:

  • Page and block IDs, unchanged. The copy has a new public_id, but the IDs inside it match the source’s, so a document pulled from the source addresses the copy.
  • Access, including the close_at and open_at schedule.
  • The theme and any per-form override. Restyling one form afterwards leaves the other alone.
  • Settings, properties, hidden values, logic rules, and Calculated values.
  • The workspace. Move the copy if it belongs elsewhere.

Nothing the source earned by being live is copied. The copy is never published: status reads draft, and there is no version, published_at, or share link. Submissions are not copied, and a custom domain bound to the source stays with the source.

An archived form can be duplicated. This is the only way to keep working from an archived form over the API.

Leave name out and the source name gains a copy suffix, as in the app: “Contact us” becomes “Contact us copy”, then “Contact us copy (2)”. A long name is trimmed to fit 100 characters. The suffix depends only on the source’s name, so duplicating the same form twice gives two forms called “Contact us copy”. Send name when the copy needs to be recognizable.

  • is_template: false against a Private Template starts a form from the template. See Starting a form from a template.
  • is_template: true against a form saves a copy as a Private Template and leaves the original alone. Use this for a form that holds submissions, which cannot be converted in place.

A copy that is a form counts against the form limit, like a create. A copy that is a Private Template does not. On Free, the copy gets properties.show_branding forced on.

If the source uses a Pro feature your plan does not include, such as meta tags on a form built while the organization was on Pro, the duplicate is refused with 402 PRO_FEATURE_REQUIRED naming the feature and path. Remove the feature from the source’s draft, or upgrade, and try again.

A refused duplicate writes nothing and spends no credits, so you can fix the source and try again.

Every form read answers the same object, whether it comes from a list or a single read.

Terminal window
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \
https://platform.fomr.io/v1/forms/Ev3ntReg01
GET /v1/forms/Ev3ntReg01
{
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"status": "published",
"has_unpublished_changes": true,
"published_version": 3,
"published_at": "2026-10-01T09:00:00.000Z",
"source_template_public_id": null,
"workspace_public_id": "Mn1w",
"access": {
"is_closed": false,
"close_at": "2026-10-31T00:00:00.000Z",
"open_at": null,
"accepting_submissions": true,
"duplicate_response_rule": null
},
"submissions": {
"completed_count": 120,
"partial_count": 8
},
"urls": {
"editor": "https://fomr.io/app/s/Ev3ntReg01/_/editor",
"share": "https://fomr.io/s/Ev3ntReg01"
},
"domain": null,
"preview_image_url": "https://cdn.fomr.io/previews/Ev3ntReg01.png",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-01T09:00:00.000Z"
}

Every field of the form resource is listed in the response of Read a form.

Situationstatushas_unpublished_changes
Built but never publisheddrafttrue
Published, untouched sincepublishedfalse
Published, then editedpublishedtrue
Archivedarchivedfalse

Use status to tell whether the form is live, and has_unpublished_changes to tell whether a publish would change anything.

access.accepting_submissions is derived, not stored. It is true only when a version is published, the form is not closed by hand, the schedule allows submissions right now, and the form is under its response limit. is_closed, close_at, and open_at are the inputs, so you can tell why a form is not taking answers. access.duplicate_response_rule shows whether the form blocks a second completed response; see Scheduling with close_at and open_at.

urls.share is the link respondents use once a version is published. It is the fomr link unless the form has a custom domain, which domain describes with its host, slug, verification status, and is_active. The custom link becomes urls.share while the domain is verified and active. Domains covers setting and removing it.

GET /v1/forms returns one page of forms, most recently changed first.

GET /v1/forms?limit=3
{
"data": [
{
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"status": "published",
"has_unpublished_changes": true,
"published_version": 3,
"published_at": "2026-10-01T09:00:00.000Z",
"source_template_public_id": null,
"workspace_public_id": "Mn1w",
"access": {
"is_closed": false,
"close_at": "2026-10-31T00:00:00.000Z",
"open_at": null,
"accepting_submissions": true,
"duplicate_response_rule": null
},
"submissions": {
"completed_count": 120,
"partial_count": 8
},
"urls": {
"editor": "https://fomr.io/app/s/Ev3ntReg01/_/editor",
"share": "https://fomr.io/s/Ev3ntReg01"
},
"domain": null,
"preview_image_url": "https://cdn.fomr.io/previews/Ev3ntReg01.png",
"created_at": "2026-10-01T09:00:00.000Z",
"updated_at": "2026-10-01T09:00:00.000Z"
},
{
"public_id": "C0ntactUs1",
"name": "Contact us",
"is_template": false,
"status": "published",
"has_unpublished_changes": false,
"published_version": 7,
"published_at": "2026-08-30T07:45:00.000Z",
"source_template_public_id": "Onb0ardT01",
"workspace_public_id": "Mn1w",
"access": {
"is_closed": false,
"close_at": null,
"open_at": null,
"accepting_submissions": true,
"duplicate_response_rule": null
},
"submissions": {
"completed_count": 1843,
"partial_count": 96
},
"urls": {
"editor": "https://fomr.io/app/s/C0ntactUs1/_/editor",
"share": "https://fomr.io/s/C0ntactUs1"
},
"domain": null,
"preview_image_url": null,
"created_at": "2026-05-11T13:02:00.000Z",
"updated_at": "2026-08-30T07:45:00.000Z"
},
{
"public_id": "P0stEvent1",
"name": "Post-event survey",
"is_template": false,
"status": "draft",
"has_unpublished_changes": true,
"published_version": null,
"published_at": null,
"source_template_public_id": null,
"workspace_public_id": "Mn1w",
"access": {
"is_closed": false,
"close_at": null,
"open_at": null,
"accepting_submissions": false,
"duplicate_response_rule": null
},
"submissions": {
"completed_count": 0,
"partial_count": 0
},
"urls": {
"editor": "https://fomr.io/app/s/P0stEvent1/_/editor",
"share": null
},
"domain": null,
"preview_image_url": null,
"created_at": "2026-08-28T10:15:00.000Z",
"updated_at": "2026-08-28T10:41:00.000Z"
}
],
"next_cursor": "eyJrIjoiMjAyNi0wOC0yOFQxMDo0MTowMC4wMDBaIiwiaWQiOiJQMHN0RXZlbnQxIiwicyI6InVwZGF0ZWRfYXQiLCJvIjoiZGVzYyJ9"
}

Every query parameter is listed under List forms.

A page holds either forms or Private Templates, never both. is_template picks which, and defaults to forms. workspace_public_id keeps one workspace; leave it out to list every workspace. Forms in Trash are never listed.

Read a page, then pass its next_cursor back as cursor until it is null:

Terminal window
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms?limit=100"
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms?limit=100&cursor=eyJrIjoiMjAyNi0wOC0yOFQxMDo0MTowMC4wMDBaIiwiaWQiOiJQMHN0RXZlbnQxIiwicyI6InVwZGF0ZWRfYXQiLCJvIjoiZGVzYyJ9"

A cursor remembers the sort and order it was created with, so changing either mid-run is 400 INVALID_INPUT at path cursor. Conventions covers cursors in full.

Every form belongs to one workspace, named by workspace_public_id on the form resource. Use a workspace ID to filter the list, to create or generate a form in that workspace, or to move a form. Workspaces covers listing, creating, renaming, and reordering the workspaces themselves.

POST /v1/forms/{form_public_id}/move takes { "workspace_public_id": "..." } and answers the form resource. Moving is a Pro feature: a Free organization gets 402 PRO_FEATURE_REQUIRED with feature: "workspaces". A form already in the target workspace answers 200 and writes nothing, and an unknown workspace ID is 400 INVALID_INPUT.

Terminal window
curl -s -X POST "https://platform.fomr.io/v1/forms/Ev3ntReg01/move" \
-H "Authorization: Bearer $FOMR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"workspace_public_id":"Wk02"}'

GET /v1/forms/{form_public_id}/draft returns the whole form as it stands in the editor, as a Form Blueprint in canonical form: every page and node ID present, sort orders numbered from one in each column, and omitted settings filled in with the values in force. Hidden pages and hidden blocks are included. The form’s own public_id is in the document as a Read-Only Field.

Terminal window
curl -s -i -H "Authorization: Bearer $FOMR_TOKEN" \
https://platform.fomr.io/v1/forms/Ev3ntReg01/draft
GET /v1/forms/Ev3ntReg01/draft
{
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"hidden_values": [
{
"key": "utm_source",
"default_value": null
}
],
"logic_rules": [],
"calculated_values": [],
"pages": [
{
"public_id": "pIn1",
"name": "Welcome",
"type": "intro",
"is_hidden": false,
"is_entrypoint": true,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nWlc",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text",
"meta": {
"content": "<p>Join us for the Acme Events community meetup.</p>"
}
},
{
"public_id": "nSt1",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Start",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pQ01",
"name": "Your details",
"type": "normal",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nNam",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text_input_short",
"meta": {
"question": "<span>Full name</span>",
"placeholder": "Jane Doe",
"validation": {
"required": true
}
}
},
{
"public_id": "nEml",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "text_input_email",
"meta": {
"question": "<span>Email address</span>",
"placeholder": "[email protected]",
"validation": {
"required": true
}
}
},
{
"public_id": "nTck",
"parent_node_id": null,
"column_index": null,
"sort_order": 3,
"is_hidden": false,
"type": "multiple_choice",
"meta": {
"question": "<span>Ticket type</span>",
"select_options_data": [
{
"value": "general",
"label": "General",
"label_formatted": "<span>General</span>"
},
{
"value": "student",
"label": "Student",
"label_formatted": "<span>Student</span>"
},
{
"value": "speaker",
"label": "Speaker",
"label_formatted": "<span>Speaker</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"multiple_choice_variant": "alphabet",
"validation": {
"required": true
}
}
},
{
"public_id": "nDie",
"parent_node_id": null,
"column_index": null,
"sort_order": 4,
"is_hidden": false,
"type": "checkbox_choice",
"meta": {
"question": "<span>Dietary needs</span>",
"select_options_data": [
{
"value": "vegetarian",
"label": "Vegetarian",
"label_formatted": "<span>Vegetarian</span>"
},
{
"value": "vegan",
"label": "Vegan",
"label_formatted": "<span>Vegan</span>"
},
{
"value": "gluten_free",
"label": "Gluten-free",
"label_formatted": "<span>Gluten-free</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"select_options_style": "checkbox",
"validation": {
"required": false
}
}
},
{
"public_id": "nCmp",
"parent_node_id": null,
"column_index": null,
"sort_order": 5,
"is_hidden": true,
"type": "text_input_short",
"meta": {
"question": "<span>Company</span>",
"validation": {
"required": false
}
}
},
{
"public_id": "nSub",
"parent_node_id": null,
"column_index": null,
"sort_order": 6,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Register",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pFn1",
"name": "Thank you",
"type": "final",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nThx",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "thank_you",
"meta": {
"thank_you_title": "<h1 style=\"text-align: center\">See you there!</h1>",
"thank_you_description": "<p style=\"text-align: center\">We sent a confirmation to your email address.</p>",
"thank_you_icon": "check_animated"
}
}
]
}
],
"settings": {
"email_notifications_enabled_owner": 1,
"email_notifications_enabled_respondent": 0,
"email_notifications_recipients_owner": [
],
"email_notifications_recipient_respondent": null
},
"properties": {
"show_branding": 1,
"auto_jump_next_page": 1,
"allow_back_navigation": 1,
"meta_title": null,
"meta_description": null,
"meta_index": 1,
"meta_favicon_url": null,
"meta_ogimage_url": null,
"partial_response_capture_enabled": 0
},
"access": {
"is_closed": 0,
"close_at": null,
"open_at": null,
"duplicate_response_rule": null
},
"styles": {
"theme_id": null,
"override_properties": null
}
}

Reading never checks whether the draft could be published. A form missing options on a question reads back exactly as it stands.

The draft read carries a weak ETag over the draft’s content:

ETag: W/"9f2c…"

Two reads of an unchanged draft return the same value. Any edit, in the app or through the API, gives a new one. Store it beside a pulled copy and compare before you write to detect that someone else changed the draft. If-Match is not supported in /v1, so the ETag detects drift but does not block a write.

PUT /v1/forms/{form_public_id}/draft replaces the draft with the whole document you send. The usual flow is read, edit, write: GET the draft, change what you want, and PUT the result.

Terminal window
curl -s -X PUT https://platform.fomr.io/v1/forms/Ev3ntReg01/draft \
-H "Authorization: Bearer $FOMR_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @event-registration.json

The server never deletes and recreates the form. It matches your document against the stored draft by public ID and writes only the difference:

  • An item with a known ID is updated in place, so submissions and answer pipes that reference it keep working.
  • An item without an ID is created, and its minted ID comes back in the response.
  • An item you leave out is removed. Its answers stay in results as historical questions, and sending its ID again later restores it.
  • Array order is canonical. The first page is where respondents start.
  • is_template and access are ignored. The lifecycle calls own them. In styles, only theme_id is written: leave it out to keep the theme. See Applying a theme.
  • logic_rules and calculated_values are kept when you leave them out. Send an empty array to remove them.
  • A settings or properties key you leave out keeps its stored value. Send only the keys you want to change. To reset a key, send its default value.

Writing a draft has the full rules. A document the editor could never produce, such as one with no ending, is refused with 400 INVALID_INPUT and the path in issues[]; Draft Write Validation lists the checks. An incomplete form, such as a choice question with no options, is accepted and reported later as a Publish Issue.

The response is the stored draft with a fresh ETag, the same value the next GET returns, so you can keep writing without a re-read.

  • Writing the same document twice changes nothing. There is no write and no audit entry, and you get back the ETag you had. Retrying after a timeout is safe, and so is a pipeline that pushes the same file on every run.
  • ?dry_run=true runs the whole validation and ID resolution without writing, and answers 200 with the document the real call would store and no ETag.
  • An archived form has no editable draft. A write is 409 FORM_ARCHIVED.
  • While someone has the form open in the editor, the two writes apply in order and the later one wins field by field. Neither side’s work is thrown away, though a teammate’s undo history for a page you removed is dropped.

GET /v1/forms/{form_public_id}/published returns the version respondents are filling in now. It is the same canonical document after hidden pages and blocks are stripped, with sort orders renumbered over what is left.

Terminal window
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \
https://platform.fomr.io/v1/forms/Ev3ntReg01/published
GET /v1/forms/Ev3ntReg01/published
{
"public_id": "Ev3ntReg01",
"name": "Event registration",
"is_template": false,
"hidden_values": [
{
"key": "utm_source",
"default_value": null
}
],
"logic_rules": [],
"calculated_values": [],
"pages": [
{
"public_id": "pIn1",
"name": "Welcome",
"type": "intro",
"is_hidden": false,
"is_entrypoint": true,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nWlc",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text",
"meta": {
"content": "<p>Join us for the Acme Events community meetup.</p>"
}
},
{
"public_id": "nSt1",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Start",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pQ01",
"name": "Your details",
"type": "normal",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nNam",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "text_input_short",
"meta": {
"question": "<span>Full name</span>",
"placeholder": "Jane Doe",
"validation": {
"required": true
}
}
},
{
"public_id": "nEml",
"parent_node_id": null,
"column_index": null,
"sort_order": 2,
"is_hidden": false,
"type": "text_input_email",
"meta": {
"question": "<span>Email address</span>",
"placeholder": "[email protected]",
"validation": {
"required": true
}
}
},
{
"public_id": "nTck",
"parent_node_id": null,
"column_index": null,
"sort_order": 3,
"is_hidden": false,
"type": "multiple_choice",
"meta": {
"question": "<span>Ticket type</span>",
"select_options_data": [
{
"value": "general",
"label": "General",
"label_formatted": "<span>General</span>"
},
{
"value": "student",
"label": "Student",
"label_formatted": "<span>Student</span>"
},
{
"value": "speaker",
"label": "Speaker",
"label_formatted": "<span>Speaker</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"multiple_choice_variant": "alphabet",
"validation": {
"required": true
}
}
},
{
"public_id": "nDie",
"parent_node_id": null,
"column_index": null,
"sort_order": 4,
"is_hidden": false,
"type": "checkbox_choice",
"meta": {
"question": "<span>Dietary needs</span>",
"select_options_data": [
{
"value": "vegetarian",
"label": "Vegetarian",
"label_formatted": "<span>Vegetarian</span>"
},
{
"value": "vegan",
"label": "Vegan",
"label_formatted": "<span>Vegan</span>"
},
{
"value": "gluten_free",
"label": "Gluten-free",
"label_formatted": "<span>Gluten-free</span>"
}
],
"select_options_randomize": false,
"select_options_selection": "single",
"select_options_style": "checkbox",
"validation": {
"required": false
}
}
},
{
"public_id": "nSub",
"parent_node_id": null,
"column_index": null,
"sort_order": 5,
"is_hidden": false,
"type": "actions",
"meta": {
"label": "Register",
"is_page_actions_override": false,
"back_button_label": "Back"
}
}
]
},
{
"public_id": "pFn1",
"name": "Thank you",
"type": "final",
"is_hidden": false,
"is_entrypoint": false,
"is_auto_jump_next_page": false,
"nodes": [
{
"public_id": "nThx",
"parent_node_id": null,
"column_index": null,
"sort_order": 1,
"is_hidden": false,
"type": "thank_you",
"meta": {
"thank_you_title": "<h1 style=\"text-align: center\">See you there!</h1>",
"thank_you_description": "<p style=\"text-align: center\">We sent a confirmation to your email address.</p>",
"thank_you_icon": "check_animated"
}
}
]
}
],
"settings": {
"email_notifications_enabled_owner": 1,
"email_notifications_enabled_respondent": 0,
"email_notifications_recipients_owner": [
],
"email_notifications_recipient_respondent": null
},
"properties": {
"show_branding": 1,
"auto_jump_next_page": 1,
"allow_back_navigation": 1,
"meta_title": null,
"meta_description": null,
"meta_index": 1,
"meta_favicon_url": null,
"meta_ogimage_url": null,
"partial_response_capture_enabled": 0
},
"access": {
"is_closed": 0,
"close_at": null,
"open_at": null,
"duplicate_response_rule": null
},
"styles": {
"theme_id": null,
"override_properties": null
}
}

A form with nothing published answers 404 FORM_NOT_PUBLISHED, a different code from a form that does not exist.

Creating a form and writing its draft change nothing respondents see. These calls do:

  • publish and unpublish decide whether a version is served.
  • close, reopen, and PATCH …/access decide whether the served version takes answers.
  • delete moves the form to Trash, and restore brings it back as a draft.
  • validate changes nothing. It reports whether a publish would succeed.
Publish moves a draft to published and unpublish moves it back. Close takes a published form to closed and reopen brings it back. The access patch takes it to scheduled, and reopen clears a schedule that is holding the form closed. Delete moves a form from any state to Trash.DraftPublishedTrashClosedScheduledpublishunpublishdeleteclosereopenaccessreopenfrom any state

Each arrow is labeled with the last segment of its URL. delete is dashed because it works from any state, archived included.

Closed and scheduled are not values of status, which only reads draft, published, or archived. They are access fields that decide whether the live version takes answers. access.accepting_submissions combines them into the one field to branch on.

Each call costs one credit; see Rate limits and plans.

POST /v1/forms/{form_public_id}/validate runs Publish Validation against the stored draft and answers 200 whether the draft is ready or not. It writes nothing. A form with no draft is 409 NO_DRAFT_DURING_PUBLISH, and a Private Template, which is never published, is 400 INVALID_INPUT at is_template.

Terminal window
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/validate"
POST /v1/forms/Ev3ntReg01/validate
{
"valid": true,
"issues": [],
"would_publish_version": 1,
"share_url": "https://fomr.io/s/Ev3ntReg01"
}

A draft that is not ready comes back with valid: false and every blocking issue at once, so one call gives you the whole list:

POST /v1/forms/Ev3ntReg01/validate, not ready
{
"valid": false,
"issues": [
{
"code": "FORM_PUBLISH_MISSING_OPTIONS",
"message": "Add at least one option before publishing this component.",
"path": [
"pages",
1,
"nodes",
2,
"meta",
"select_options_data"
],
"page_public_id": "pQ01",
"node_public_id": "nTck"
},
{
"code": "FORM_PUBLISH_MISSING_FINAL_PAGE",
"message": "Add a thank-you page before publishing.",
"path": [
"pages"
],
"page_public_id": null,
"node_public_id": null
}
],
"would_publish_version": 1,
"share_url": "https://fomr.io/s/Ev3ntReg01"
}

Each Publish Issue has a stable code to branch on, a message that is not part of the contract, and these locators:

  • path: property names and indexes into the Draft Document, such as ["pages", 1, "nodes", 0, "meta", "select_options_data"]. It is empty when the issue concerns the whole form.
  • page_public_id and node_public_id: the page and block to fix, or null.

Validate also reports what publish itself would refuse, with null page and block IDs:

CodeMeaning
FORM_PUBLISH_EMAIL_UNVERIFIEDThe token owner’s email address is not verified.
PRO_FEATURE_REQUIREDThe draft uses a feature the plan does not include. path points at it: ["pages"] for several endings or custom ending content, ["logic_rules"] for a redirect rule, ["properties", "meta_title"] for meta tags, or [] for a custom domain.
FORM_SAFETY_REJECTEDAutomated safety checks would hold the form.

would_publish_version and share_url preview the version number and the share link. The version number is the latest published one when the draft has not changed since that publish, because publishing it again reuses that version, and the next number otherwise. Validation reserves nothing, so another publish can take that number first.

POST /v1/forms/{form_public_id}/publish publishes the stored draft and answers the form resource as it stands afterwards.

Terminal window
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/publish"

There are no preconditions and no If-Match. Two publishes arriving together queue rather than collide, and the last one to land is what respondents get.

A draft that differs from the latest published version becomes the next version number, and published_at moves. A draft identical to the latest published version republishes that version under its existing number. Read has_unpublished_changes first if you only want to publish when something changed.

Publishing needs the form_publish permission on top of write access; see Permissions.

Nothing is written in any of these cases.

StatusCodeWhat happened
400INVALID_INPUTThe resource is a Private Template, which is never published. issues[] points at is_template. Unpublish answers the same.
422FORM_PUBLISH_VALIDATION_FAILEDThe draft has Publish Issues. issues is the array validate returns.
422FORM_SAFETY_REJECTEDAutomated safety checks flagged the form. The form and its owner are held for review; do not retry.
402PRO_FEATURE_REQUIREDThe draft uses a feature the plan does not include, such as several endings (custom_endings) or a redirect rule (redirect_on_completion).
403FORM_PUBLISH_EMAIL_UNVERIFIEDThe token owner has not verified their email address. Verify it in the app, then publish again.
409NO_DRAFT_DURING_PUBLISHThe form holds no draft to publish.
409FORM_ARCHIVEDThe form is archived.
409FORM_PUBLISH_REVISION_CONFLICTThe form changed while it was being published. Publish again.
503SERVICE_UNAVAILABLEThe automated safety check could not run, or writes are briefly saturated. Publish again after retry_after_seconds, which the Retry-After header repeats.
422 FORM_PUBLISH_VALIDATION_FAILED
{
"code": "FORM_PUBLISH_VALIDATION_FAILED",
"message": "This form cannot be published until its issues are resolved.",
"request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90",
"issues": [
{
"code": "FORM_PUBLISH_MISSING_OPTIONS",
"message": "Add at least one option before publishing this component.",
"path": [
"pages",
1,
"nodes",
2,
"meta",
"select_options_data"
],
"page_public_id": "pQ01",
"node_public_id": "nTck"
}
]
}

PRO_FEATURE_REQUIRED names the feature in feature and its location in the Draft Document in path. path is an empty string when the blocker is not a document field, such as a custom domain bound to the form. Upgrade, or remove the feature from the draft, and publish again.

402 PRO_FEATURE_REQUIRED
{
"code": "PRO_FEATURE_REQUIRED",
"message": "This action needs a Pro plan.",
"request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90",
"plan": "free",
"feature": "meta_tags",
"path": "properties/meta_title",
"upgrade_url": "https://fomr.io/app/upgrade?source=platform_api&feature=platform_api"
}
409 NO_DRAFT_DURING_PUBLISH
{
"code": "NO_DRAFT_DURING_PUBLISH",
"message": "This form has no draft changes to publish.",
"request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90"
}

POST /v1/forms/{form_public_id}/unpublish stops the share link serving. The form reads back with status: "draft", and published_version, published_at, and urls.share become null. GET …/published answers 404 FORM_NOT_PUBLISHED.

Terminal window
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/unpublish"
  • Version history is kept. Republishing continues the numbering rather than starting again at 1.
  • Already offline is fine. A form that is not published, or never was, answers 200 and writes nothing.
  • Submissions and the draft are untouched. Unpublishing is the lightest way to take a form down. Deleting also hides it from every read until you restore it.

Unpublishing needs form_publish, like publishing.

POST …/close stops the form accepting submissions now. It sets access.is_closed and leaves any schedule alone.

POST …/reopen clears is_closed and also clears whichever schedule is currently holding the form closed:

Before reopenAfter reopen
is_closed: trueis_closed: false
A close_at that has passedclose_at: null
A close_at still in the futureUnchanged
An open_at still in the futureopen_at: null
An open_at that has passedUnchanged

Reopen means “take answers now”, so a schedule that has already closed the form, or would keep it closed, is cleared. A future close_at is not stopping anything today, so it stays.

Terminal window
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/close"
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/reopen"

Both calls answer 200 and write nothing when the form is already in the target state, so you can call them without reading the form first. Both also work on a form that was never published: the flag is stored and applies once a version is live.

PATCH /v1/forms/{form_public_id}/access sets the access fields directly. Send only the fields you want to change:

FieldEffect
is_closedCloses the form by hand, or opens it.
close_atWhen the form stops accepting submissions.
open_atWhen the form starts accepting submissions.
duplicate_response_ruleBlocks a second completed response by IP address, by browser, or by the answer to one question.

An omitted field keeps its value. null clears a schedule, and is the only way to clear one; null in duplicate_response_rule accepts every response again. A component rule must name a visible short text, email, phone, link, or number block of the draft, or the call is 400 INVALID_INPUT. Access lists the rule shapes. Times are ISO 8601 strings in UTC.

Terminal window
curl -s -X PATCH "https://platform.fomr.io/v1/forms/Ev3ntReg01/access" \
-H "Authorization: Bearer $FOMR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"open_at":"2026-12-01T09:00:00.000Z","close_at":"2026-12-14T23:59:00.000Z"}'

The response is the form resource with access.accepting_submissions recomputed. A body that changes nothing answers 200 and writes nothing, so the call is safe to retry or run on every deploy.

DELETE /v1/forms/{form_public_id} moves the form to Trash and answers 204 with no body.

Terminal window
curl -s -X DELETE -H "Authorization: Bearer $FOMR_TOKEN" \
-o /dev/null -w '%{http_code}\n' \
"https://platform.fomr.io/v1/forms/Ev3ntReg01"

A form in Trash is unpublished, so its share link stops serving immediately. It leaves every list, and every call on it answers 404 NOT_FOUND, including its submissions. Trash keeps the form for 30 days, then deletes it permanently. Until then it still counts toward the plan’s form limit, so deleting a form does not free a slot for a new one. See Trash for the app’s view.

POST /v1/forms/{form_public_id}/restore brings the form back as a draft, in its workspace, or in the Default Workspace if that workspace is gone. Publish it again to put it back in front of respondents. Because a form in Trash already counts toward the form limit, restoring it never answers 402 PLAN_LIMIT_REACHED. A form that is not in Trash answers 200 and writes nothing.

Terminal window
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \
"https://platform.fomr.io/v1/forms/Ev3ntReg01/restore"

Forms are archived in the app, not over the API. An archived form has no editable draft and nothing to publish, so most of the lifecycle is closed to it. Restore it in the app to edit or publish it again.

OperationAn archived form answers
validate, publish, close, reopen, PATCH …/access, PUT …/draft, mark-template409 FORM_ARCHIVED
unpublish200, writing nothing, because archiving already took it offline
duplicate201 with a new draft copy
move200, moving it
DELETE204
409 FORM_ARCHIVED
{
"code": "FORM_ARCHIVED",
"message": "This form is archived.",
"request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90"
}

A form that does not exist, is in Trash, or belongs to another organization answers 404 NOT_FOUND on every call, except that restore finds a form of yours in Trash. The cases are deliberately indistinguishable, so a mistyped ID and someone else’s ID look the same.

StatusCodeWhen
400INVALID_INPUTA query parameter, cursor, workspace ID, blueprint value, or access body is invalid, or a Private Template is sent to validate, publish, or unpublish. issues[] names it by path.
402PLAN_LIMIT_REACHEDCreating or duplicating would pass the plan’s form limit.
402PRO_FEATURE_REQUIREDThe blueprint, draft, or duplicated source uses a feature the plan does not include, or a move needs workspaces.
403FORBIDDENThe member lacks the permission named in required_permission.
403FORM_PUBLISH_EMAIL_UNVERIFIEDThe token owner must verify their email address before publishing.
404NOT_FOUNDThe form does not exist, is in Trash, or belongs to another organization.
404FORM_NOT_PUBLISHEDThe form has no published version.
409FORM_ARCHIVEDThe form is archived.
409FORM_CONVERSION_BLOCKEDA published form, or one holding submissions, cannot become a Private Template. reason is published or has_submissions.
409NO_DRAFT_DURING_PUBLISHThe form holds no draft to validate or publish.
409FORM_PUBLISH_REVISION_CONFLICTThe form changed during a publish. Publish again.
422FORM_PUBLISH_VALIDATION_FAILEDThe draft has Publish Issues, listed in issues.
422FORM_SAFETY_REJECTEDAutomated safety checks flagged the form on publish, or abusive meta tags or respondent email content on a draft write.
503SERVICE_UNAVAILABLEWrites are briefly saturated, or the automated safety check could not run on a publish or a meta tag write. retry_after_seconds and Retry-After say how long to wait.

Errors has the full catalog and the fields each code carries.

Your next form could be the best-looking thing on your site.

Free forever. Unlimited responses. No signup to start.