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.
| Operation | What it does |
|---|---|
POST / | Creates a form, or a Private Template, from a Form Blueprint. |
POST / | Copies a form or a Private Template into a new draft. |
GET / | Lists forms, or Private Templates when is_template=true. |
GET / | Reads one form’s status, access, counts, and links. |
GET / | Reads the Draft Document, the form as it stands in the editor. |
PUT / | Replaces the Draft Document with the one you send. |
GET / | Reads the Published Document, the version respondents fill in. |
POST / | Reports whether the draft would publish, without publishing it. |
POST / | Publishes the stored draft. |
POST / | Takes the form offline. |
POST / | Stops the form accepting submissions now. |
POST / | Lets the form accept submissions again. |
PATCH / | Sets the closed flag, the schedule, and the duplicate-response rule. |
PUT / | Sets the custom domain and slug of the share link. |
DELETE / | Removes the custom domain from the share link. |
POST / | Turns a form into a Private Template in place. |
POST / | Moves the form to another workspace. |
DELETE / | Moves the form to Trash. |
POST / | Brings the form back from Trash as a draft. |
GET / | Lists the organization’s workspaces. Workspaces covers the other workspace calls. |
Permissions
Section titled “Permissions”A Personal Access Token acts with the permissions of the member who owns it.
| Calls | Permission |
|---|---|
Reads, validate, and GET / | forms:read, or templates:read for a Private Template |
| Every other write | forms:write, or templates:write for a Private Template |
publish and unpublish | forms:write and form_publish. A Private Template is never published |
duplicate | Read on the source’s kind, and write on the kind the copy becomes |
mark-template | forms:write and templates:write together |
PUT and DELETE …/ | forms: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.
Creating a form
Section titled “Creating a form”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 is400 INVALID_INPUTat pathworkspace_public_id. - Server-owned fields.
public_idis ignored and assigned.styles.theme_idpicks a theme; without it the form gets the default theme. - Honored only here.
is_templateandaccessare applied on create, though a draft write ignores them. Sendis_template: trueto create a Private Template.
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{ "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>", "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 } }}Trying it first
Section titled “Trying it first”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.
When a call fails
Section titled “When a call fails”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_REACHEDwithlimit.key,limit.max, andlimit.current. Forms in Trash count until they are permanently deleted. Private Templates do not count. - Branding. On Free,
properties.show_brandingis forced on rather than refused. On Pro, a form created withoutshow_brandinghides the branding, as a form created in the app does. Sendshow_branding: trueto keep it. - Pro features. Any other field your plan does not include is refused with
402 PRO_FEATURE_REQUIRED, naming thefeatureand thepathof the field.
Nothing is written when a plan check fails.
Duplicating a form
Section titled “Duplicating a form”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.
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.
| Field | Effect |
|---|---|
name | The copy’s name, 1 to 100 characters. |
is_template | Whether the copy is a Private Template. Left out, the copy keeps the source’s kind. |
What the copy carries over
Section titled “What the copy carries over”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_atandopen_atschedule. - 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.
Naming the copy
Section titled “Naming the copy”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.
Duplicating across kinds
Section titled “Duplicating across kinds”is_template: falseagainst a Private Template starts a form from the template. See Starting a form from a template.is_template: trueagainst 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.
Plans and retries
Section titled “Plans and retries”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.
The form resource
Section titled “The form resource”Every form read answers the same object, whether it comes from a list or a single read.
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \ https://platform.fomr.io/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.
Status and unpublished changes
Section titled “Status and unpublished changes”| Situation | status | has_unpublished_changes |
|---|---|---|
| Built but never published | draft | true |
| Published, untouched since | published | false |
| Published, then edited | published | true |
| Archived | archived | false |
Use status to tell whether the form is live, and has_unpublished_changes to tell whether a publish would change anything.
Accepting submissions
Section titled “Accepting submissions”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.
Share link and domain
Section titled “Share link and domain”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.
Listing forms
Section titled “Listing forms”GET /v1/forms returns one page of forms, most recently changed first.
{ "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:
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.
Workspaces
Section titled “Workspaces”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.
Moving a form to another workspace
Section titled “Moving a form to another workspace”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.
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"}'The Draft Document
Section titled “The Draft Document”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.
curl -s -i -H "Authorization: Bearer $FOMR_TOKEN" \ https://platform.fomr.io/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>", "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 ETag
Section titled “The ETag”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.
Replacing the draft
Section titled “Replacing the draft”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.
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.jsonThe 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_templateandaccessare ignored. The lifecycle calls own them. Instyles, onlytheme_idis written: leave it out to keep the theme. See Applying a theme.logic_rulesandcalculated_valuesare kept when you leave them out. Send an empty array to remove them.- A
settingsorpropertieskey 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
ETagyou had. Retrying after a timeout is safe, and so is a pipeline that pushes the same file on every run. ?dry_run=trueruns the whole validation and ID resolution without writing, and answers200with the document the real call would store and noETag.- 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.
The Published Document
Section titled “The Published Document”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.
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \ https://platform.fomr.io/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>", "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.
The lifecycle
Section titled “The lifecycle”Creating a form and writing its draft change nothing respondents see. These calls do:
publishandunpublishdecide whether a version is served.close,reopen, andPATCH …/accessdecide whether the served version takes answers.deletemoves the form to Trash, andrestorebrings it back as a draft.validatechanges nothing. It reports whether a publish would succeed.
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.
Validating before you publish
Section titled “Validating before you publish”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.
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \ "https://platform.fomr.io/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:
{ "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_idandnode_public_id: the page and block to fix, ornull.
Validate also reports what publish itself would refuse, with null page and block IDs:
| Code | Meaning |
|---|---|
FORM_PUBLISH_EMAIL_UNVERIFIED | The token owner’s email address is not verified. |
PRO_FEATURE_REQUIRED | The 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_REJECTED | Automated 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.
Publishing
Section titled “Publishing”POST /v1/forms/{form_public_id}/publish publishes the stored draft and answers the form resource as it stands afterwards.
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.
When a publish is refused
Section titled “When a publish is refused”Nothing is written in any of these cases.
| Status | Code | What happened |
|---|---|---|
400 | INVALID_INPUT | The resource is a Private Template, which is never published. issues[] points at is_template. Unpublish answers the same. |
422 | FORM_PUBLISH_VALIDATION_FAILED | The draft has Publish Issues. issues is the array validate returns. |
422 | FORM_SAFETY_REJECTED | Automated safety checks flagged the form. The form and its owner are held for review; do not retry. |
402 | PRO_FEATURE_REQUIRED | The draft uses a feature the plan does not include, such as several endings (custom_endings) or a redirect rule (redirect_on_completion). |
403 | FORM_PUBLISH_EMAIL_UNVERIFIED | The token owner has not verified their email address. Verify it in the app, then publish again. |
409 | NO_DRAFT_DURING_PUBLISH | The form holds no draft to publish. |
409 | FORM_ARCHIVED | The form is archived. |
409 | FORM_PUBLISH_REVISION_CONFLICT | The form changed while it was being published. Publish again. |
503 | SERVICE_UNAVAILABLE | The automated safety check could not run, or writes are briefly saturated. Publish again after retry_after_seconds, which the Retry-After header repeats. |
{ "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.
{ "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"}{ "code": "NO_DRAFT_DURING_PUBLISH", "message": "This form has no draft changes to publish.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90"}Taking a form offline
Section titled “Taking a form offline”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.
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
200and 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.
Closing and reopening
Section titled “Closing and reopening”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 reopen | After reopen |
|---|---|
is_closed: true | is_closed: false |
A close_at that has passed | close_at: null |
A close_at still in the future | Unchanged |
An open_at still in the future | open_at: null |
An open_at that has passed | Unchanged |
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.
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.
Scheduling with close_at and open_at
Section titled “Scheduling with close_at and open_at”PATCH /v1/forms/{form_public_id}/access sets the access fields directly. Send only the fields you want to change:
| Field | Effect |
|---|---|
is_closed | Closes the form by hand, or opens it. |
close_at | When the form stops accepting submissions. |
open_at | When the form starts accepting submissions. |
duplicate_response_rule | Blocks 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.
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.
Deleting and restoring a form
Section titled “Deleting and restoring a form”DELETE /v1/forms/{form_public_id} moves the form to Trash and answers 204 with no body.
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.
curl -s -X POST -H "Authorization: Bearer $FOMR_TOKEN" \ "https://platform.fomr.io/v1/forms/Ev3ntReg01/restore"Archived forms
Section titled “Archived forms”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.
| Operation | An archived form answers |
|---|---|
validate, publish, close, reopen, PATCH …/, PUT …/, mark-template | 409 FORM_ARCHIVED |
unpublish | 200, writing nothing, because archiving already took it offline |
duplicate | 201 with a new draft copy |
move | 200, moving it |
DELETE | 204 |
{ "code": "FORM_ARCHIVED", "message": "This form is archived.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90"}A form you cannot see
Section titled “A form you cannot see”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.
Errors
Section titled “Errors”| Status | Code | When |
|---|---|---|
400 | INVALID_INPUT | A 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. |
402 | PLAN_LIMIT_REACHED | Creating or duplicating would pass the plan’s form limit. |
402 | PRO_FEATURE_REQUIRED | The blueprint, draft, or duplicated source uses a feature the plan does not include, or a move needs workspaces. |
403 | FORBIDDEN | The member lacks the permission named in required_permission. |
403 | FORM_PUBLISH_EMAIL_UNVERIFIED | The token owner must verify their email address before publishing. |
404 | NOT_FOUND | The form does not exist, is in Trash, or belongs to another organization. |
404 | FORM_NOT_PUBLISHED | The form has no published version. |
409 | FORM_ARCHIVED | The form is archived. |
409 | FORM_CONVERSION_BLOCKED | A published form, or one holding submissions, cannot become a Private Template. reason is published or has_submissions. |
409 | NO_DRAFT_DURING_PUBLISH | The form holds no draft to validate or publish. |
409 | FORM_PUBLISH_REVISION_CONFLICT | The form changed during a publish. Publish again. |
422 | FORM_PUBLISH_VALIDATION_FAILED | The draft has Publish Issues, listed in issues. |
422 | FORM_SAFETY_REJECTED | Automated safety checks flagged the form on publish, or abusive meta tags or respondent email content on a draft write. |
503 | SERVICE_UNAVAILABLE | Writes 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.