A workflow runs steps when a respondent completes a form, such as adding the response as a row in Google Sheets. It is the same workflow you build in the Workflows tab of the editor, described in Google Sheets workflows. The Platform API reads and writes a workflow as one document, the Workflow Document, the way it reads and writes a form as a Form Blueprint: read it, change it, and send it back. Every write reaches open editors at once.
| Operation | What it does | Permission |
|---|---|---|
GET / | Lists the workflows of a form without their graphs. | forms:read and workflows:read |
GET / | Reads one Workflow Document. | forms:read and workflows:read |
POST / | Creates a workflow, disabled. | forms:write and workflows:write |
PUT / | Replaces the name and graph. | forms:write and workflows:write |
POST / | Turns the workflow on after the app’s checks. | forms:write and workflows:write |
POST / | Turns the workflow off. | forms:write and workflows:write |
DELETE / | Deletes the workflow. | forms:write and workflows:write |
GET / | Lists the connections a node can use. | integration_management, or forms:read and workflows:read |
Every call costs one credit. A missing permission is 403 FORBIDDEN with the permission named in required_permission. Private Templates have no workflows: their public ID answers 404 NOT_FOUND, as does a form in Trash or of another organization.
The Workflow Document
Section titled “The Workflow Document”curl -s -H "Authorization: Bearer $FOMR_TOKEN" \ https://platform.fomr.io/v1/forms/Ev3ntReg01/workflows/Yq3vK8mTn2Lw5Rb7Xc1dFh{ "public_id": "Yq3vK8mTn2Lw5Rb7Xc1dFh", "name": "Send responses to Google Sheets", "is_enabled": false, "validation_status": "unchecked", "validation_error_code": null, "created_at": "2026-10-21T09:12:44.000Z", "updated_at": "2026-10-21T09:12:44.000Z", "nodes": [ { "public_id": "trigger-new-response", "node_type_key": "form_completed", "position": { "x": 0, "y": 0 } }, { "public_id": "sheet-leads", "node_type_key": "google_sheets_add_row", "position": { "x": 360, "y": 0 }, "config": { "connection_public_id": "Gc7pQ2sLx9", "spreadsheet_name": "Event registrations" }, "metadata": { "spreadsheet_id": "1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789", "spreadsheet_url": "https://docs.google.com/spreadsheets/d/1AbCdEfGhIjKlMnOpQrStUvWxYz0123456789/edit", "spreadsheet_name": "Event registrations", "sheet_name": "Submissions" } } ], "edges": [ { "public_id": "edge-trigger-sheet", "source_node_public_id": "trigger-new-response", "target_node_public_id": "sheet-leads", "source_handle": "source", "target_handle": "target" } ]}Every field of a workflow is listed in the response of Read a workflow.
GET /v1/forms/{form_public_id}/workflows answers { data } with the same fields minus nodes and edges, oldest workflow first.
{ "data": [ { "public_id": "Yq3vK8mTn2Lw5Rb7Xc1dFh", "name": "Send responses to Google Sheets", "is_enabled": true, "validation_status": "valid", "validation_error_code": null, "created_at": "2026-10-21T09:12:44.000Z", "updated_at": "2026-10-21T09:15:02.000Z" }, { "public_id": "Pr8sW1nZq4Tk6Vm2Hd9yBg", "name": "Backup sheet", "is_enabled": false, "validation_status": "invalid", "validation_error_code": "WORKFLOW_VALIDATION_NODE_CONFIG_INVALID", "created_at": "2026-10-21T09:20:10.000Z", "updated_at": "2026-10-21T09:21:33.000Z" } ]}- Nodes and edges are identified by public ID. Order carries no meaning; edges name their endpoints with
source_node_public_idandtarget_node_public_id. - Node IDs and edge IDs are unique across fomr and never reused. A removed node keeps its ID, so sending it again creates nothing and answers
workflow_public_id_taken. An ID you choose has at least 8 characters; leavepublic_idout to have one minted. - No credential ever appears. A Google Sheets step names its connection by
connection_public_id; tokens stay with fomr.
Node types
Section titled “Node types”The nodes array is a union discriminated by node_type_key, published in the Public OpenAPI Document as ModelPlatformWorkflowNodeWriteInput for writes and ModelPlatformWorkflowNode for reads.
form_completed is the trigger. It runs when a respondent completes the form and has no settings. A workflow that can be enabled has exactly one.
google_sheets_add_row appends each completed response to a spreadsheet. Its config names the Google account and the spreadsheet to create.
The fields of each node and of its config are listed in the request body of Create a workflow.
- Setting
configcreates the spreadsheet. When a write first sets the destination of a step, fomr creates the spreadsheet in that Google account, exactly as saving the step in the app does, andmetadatathen carries its ID and link. A dry run creates nothing. If Google refuses, the rest of the document is still saved and the step keeps no destination: fix the cause and send the document again. - One write sets at most 5 new destinations. A sixth answers
workflow_destinations_per_write; leave itsconfignull and set it in a later write. - A destination is set once. After the spreadsheet exists,
configcannot change or be cleared; a different value answers400withworkflow_node_config_lockedat the node’sconfigpath. Send the stored value back, or leaveconfigout, to keep it. To use another spreadsheet, replace the node with a new one. - A step may wait for its destination.
config: nullis allowed while you build, as in the app, but the workflow cannot be enabled until every step has one.
Edge fields are listed in the request body of Create a workflow.
Only the trigger can start an edge, an edge never leads to the trigger or back to its own node, and the trigger connects to at most three steps.
Creating a workflow
Section titled “Creating a workflow”curl -s -X POST https://platform.fomr.io/v1/forms/Ev3ntReg01/workflows \ -H "Authorization: Bearer $FOMR_TOKEN" \ -H "Content-Type: application/json" \ -d @workflow.json{ "name": "Send responses to Google Sheets", "nodes": [ { "public_id": "trigger-new-response", "node_type_key": "form_completed", "position": { "x": 0, "y": 0 } }, { "public_id": "sheet-leads", "node_type_key": "google_sheets_add_row", "position": { "x": 360, "y": 0 }, "config": { "connection_public_id": "Gc7pQ2sLx9", "spreadsheet_name": "Event registrations" } } ], "edges": [ { "source_node_public_id": "trigger-new-response", "target_node_public_id": "sheet-leads" } ]}Every field of the request body is listed under Create a workflow and Replace a workflow.
POST /v1/forms/{form_public_id}/workflows answers 201 with the stored Workflow Document, every minted ID included. The workflow starts disabled with validation_status: "unchecked"; enable it to run it.
- The graph passes the builder’s checks before anything is written: at most 50 nodes and 100 edges, valid edges, and a config under the size limit. Each problem is a
400 INVALID_INPUTissue at its path in the request body, such as["edges", 0]. - A form holds at most 25 workflows. One more answers
400withWORKFLOW_VALIDATION_TOO_MANY_WORKFLOWSat path[], on a dry run too. The check is the one the app’s sync runs. - Connections must be connected and the organization’s own. A
connection_public_idthat is unknown, belongs to another organization, or is not connected answers400withWORKFLOW_VALIDATION_INTEGRATION_CONNECTION_UNAVAILABLEat its path. The three cases are not told apart. - A failed create writes nothing and spends no credits, as on
createForm. Usedry_run=trueto validate and preview the document without writing it.
{ "code": "INVALID_INPUT", "message": "The request is not valid.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90", "issues": [ { "code": "WORKFLOW_VALIDATION_INTEGRATION_CONNECTION_UNAVAILABLE", "message": "No connected integration connection of this organization has this public ID. Reconnect the account in the app or pick another connection.", "path": [ "nodes", 1, "config", "connection_public_id" ] } ]}Replacing a workflow
Section titled “Replacing a workflow”PUT /v1/forms/{form_public_id}/workflows/{workflow_public_id} takes a whole Workflow Document and answers the stored one. The read-only fields of a read document, such as is_enabled and metadata, are accepted and ignored, so a document you read can be sent back as is.
- Nodes and edges are matched by public ID and kept in place. An edge sent without an ID keeps the stored edge between the same nodes and handles.
- An item without an ID is created; a stored node or edge you leave out is removed, and removing a node removes its edges.
- A node keeps its type: changing
node_type_keyanswersworkflow_node_type_changed. - A document identical to the stored one writes nothing.
- An enabled workflow takes only a rename. Changing its nodes, edges, or destinations answers
400withworkflow_enabledat path[], on a dry run too. Disable it first, as the editor asks you to.
{ "code": "INVALID_INPUT", "message": "The request is not valid.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90", "issues": [ { "code": "workflow_enabled", "message": "The workflow is enabled. Disable it before changing its nodes or edges, as in the app; renaming is allowed.", "path": [] } ]}Enabling and disabling
Section titled “Enabling and disabling”POST …/enable turns the workflow on, so the next completed response runs it, after the same checks the app runs when you switch a workflow on: exactly one trigger, every node connected to it, no loops, a valid config on every step, and a connected account behind every connection.
- A failed check answers
400 INVALID_INPUTwith the issue at its path in the Workflow Document asGETreturns it, and the workflow recordsvalidation_status: "invalid"with the same code invalidation_error_code, as in the app. - The plan limits enabled workflows per form. The Free plan keeps one enabled workflow per form; Pro has no limit. Over the limit, the call answers
402 PLAN_LIMIT_REACHEDwithlimit.key: "max_active_workflows_per_form". - Enabling an enabled workflow that passed its checks writes nothing.
{ "code": "INVALID_INPUT", "message": "The request is not valid.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90", "issues": [ { "code": "WORKFLOW_VALIDATION_NODE_CONFIG_INVALID", "message": "This step has no valid settings yet. Set its config before enabling the workflow.", "path": [ "nodes", 1, "config" ] } ]}{ "code": "PLAN_LIMIT_REACHED", "message": "A plan limit has been reached.", "request_id": "0f3c2a9e-6b1d-4e57-9a1c-2d8e4f6b7c90", "plan": "free", "upgrade_url": "https://fomr.io/app/upgrade?source=platform_api&feature=platform_api", "limit": { "key": "max_active_workflows_per_form", "max": 1, "current": 1 }}POST …/disable turns the workflow off, as pausing it in the app does. Completed responses no longer run it, and its graph can be replaced. Disabling a disabled workflow writes nothing. Both calls answer the Workflow Document.
Deleting a workflow
Section titled “Deleting a workflow”DELETE /v1/forms/{form_public_id}/workflows/{workflow_public_id} answers 204 with no body. An enabled workflow is turned off first. Its run history stays, spreadsheets it created stay in Google Sheets, and a deleted workflow cannot be restored.
Integration connections
Section titled “Integration connections”GET /v1/integration-connections lists the organization’s connections, most recently updated first, so you can pick a connection_public_id. Connections are made, reconnected, and removed in the app under Integrations; the API only reads them.
{ "data": [ { "public_id": "Gc7pQ2sLx9", "integration_key": "google-sheet", "display_name": "Avery Operations", "status": "connected", "last_connected_at": "2026-10-20T16:04:11.000Z", "created_at": "2026-09-02T10:00:00.000Z" } ]}Every field of a connection is listed in the response of List integration connections.
Only a connection with status: "connected" can be used in a node. Tokens, provider IDs, and scopes are never returned. display_name is the account name the app shows, or null when the provider reported no readable name; the internal ID of the provider connection is never shown in its place.
Errors
Section titled “Errors”| Status | Code | When |
|---|---|---|
400 | INVALID_INPUT | The document breaks a graph check, names an unknown node or a taken or duplicate ID, changes a set destination or a node type, changes an enabled workflow’s graph, references an unusable connection, or fails an enable check. Issue codes such as WORKFLOW_VALIDATION_CYCLE match validation_error_code. |
402 | PLAN_LIMIT_REACHED | Enabling would pass the plan’s max_active_workflows_per_form. |
403 | FORBIDDEN | The token’s member lacks forms or workflows access. |
404 | NOT_FOUND | The form or workflow does not exist, is in Trash or deleted, belongs to another organization, or the form is a Private Template. |
503 | SERVICE_UNAVAILABLE | Google Sheets cannot be reached through fomr right now. Retry after retry_after_seconds. |
Errors describes the envelope every error shares.