Skip to content
Join our Slack

Workflows

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.

OperationWhat it doesPermission
GET /v1/forms/{form_public_id}/workflowsLists the workflows of a form without their graphs.forms:read and workflows:read
GET /v1/forms/{form_public_id}/workflows/{workflow_public_id}Reads one Workflow Document.forms:read and workflows:read
POST /v1/forms/{form_public_id}/workflowsCreates a workflow, disabled.forms:write and workflows:write
PUT /v1/forms/{form_public_id}/workflows/{workflow_public_id}Replaces the name and graph.forms:write and workflows:write
POST /v1/forms/{form_public_id}/workflows/{workflow_public_id}/enableTurns the workflow on after the app’s checks.forms:write and workflows:write
POST /v1/forms/{form_public_id}/workflows/{workflow_public_id}/disableTurns the workflow off.forms:write and workflows:write
DELETE /v1/forms/{form_public_id}/workflows/{workflow_public_id}Deletes the workflow.forms:write and workflows:write
GET /v1/integration-connectionsLists 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.

Terminal window
curl -s -H "Authorization: Bearer $FOMR_TOKEN" \
https://platform.fomr.io/v1/forms/Ev3ntReg01/workflows/Yq3vK8mTn2Lw5Rb7Xc1dFh
GET /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.

GET /v1/forms/Ev3ntReg01/workflows
{
"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_id and target_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; leave public_id out to have one minted.
  • No credential ever appears. A Google Sheets step names its connection by connection_public_id; tokens stay with fomr.

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 config creates 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, and metadata then 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 its config null and set it in a later write.
  • A destination is set once. After the spreadsheet exists, config cannot change or be cleared; a different value answers 400 with workflow_node_config_locked at the node’s config path. Send the stored value back, or leave config out, to keep it. To use another spreadsheet, replace the node with a new one.
  • A step may wait for its destination. config: null is 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.

Terminal window
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
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_INPUT issue at its path in the request body, such as ["edges", 0].
  • A form holds at most 25 workflows. One more answers 400 with WORKFLOW_VALIDATION_TOO_MANY_WORKFLOWS at 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_id that is unknown, belongs to another organization, or is not connected answers 400 with WORKFLOW_VALIDATION_INTEGRATION_CONNECTION_UNAVAILABLE at its path. The three cases are not told apart.
  • A failed create writes nothing and spends no credits, as on createForm. Use dry_run=true to validate and preview the document without writing it.
400 INVALID_INPUT
{
"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"
]
}
]
}

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_key answers workflow_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 400 with workflow_enabled at path [], on a dry run too. Disable it first, as the editor asks you to.
400 INVALID_INPUT
{
"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": []
}
]
}

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_INPUT with the issue at its path in the Workflow Document as GET returns it, and the workflow records validation_status: "invalid" with the same code in validation_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_REACHED with limit.key: "max_active_workflows_per_form".
  • Enabling an enabled workflow that passed its checks writes nothing.
400 INVALID_INPUT
{
"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"
]
}
]
}
402 PLAN_LIMIT_REACHED
{
"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.

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.

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.

GET /v1/integration-connections
{
"data": [
{
"public_id": "Gc7pQ2sLx9",
"integration_key": "google-sheet",
"display_name": "Avery Operations",
"account_email": "[email protected]",
"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.

StatusCodeWhen
400INVALID_INPUTThe 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.
402PLAN_LIMIT_REACHEDEnabling would pass the plan’s max_active_workflows_per_form.
403FORBIDDENThe token’s member lacks forms or workflows access.
404NOT_FOUNDThe form or workflow does not exist, is in Trash or deleted, belongs to another organization, or the form is a Private Template.
503SERVICE_UNAVAILABLEGoogle Sheets cannot be reached through fomr right now. Retry after retry_after_seconds.

Errors describes the envelope every error shares.

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

Free forever. Unlimited responses. No signup to start.