Workflows API
Create, list, update, deploy and delete workflows with the Workflows API, with the parameters, request bodies and responses of each endpoint.
A workflow is a saved definition: one trigger and an ordered list of steps. Use these endpoints to create and edit definitions, publish and pause them, start runs, and read version history. The definition itself is described in Workflow definition format.
| Scope | Endpoints |
|---|---|
workflows:read | List, get, versions, compare, schedule (read) |
workflows:write | Create, update, canvas, delete, archive, unarchive, activate, pause, clone, rollback, schedule (update) |
workflows:execute | Run, trigger a scheduled run |
runs:read (or legacy workflows:read) | List a workflow's runs |
Every endpoint is scoped to the company that owns the API key. A workflow in another company returns 404.
Workflow object
| Field | Type | Meaning |
|---|---|---|
id | string (UUID) | Workflow ID |
name | string | 1 to 255 characters |
description | string or null | |
status | string | draft, active, paused or archived |
version | integer | Internal version number. Goes up by 1 for each new version. Use it for expectedVersion, rollback and compare |
versionLabel | string | Display name, v{major}.{minor}, for example v2.1 |
versionMajor, versionMinor | integer or null | Parts of the label |
publishedVersion | integer or null | version of the last publish; null if never published |
trigger | object | Trigger configuration |
steps | array | Steps (single-workflow responses only) |
inputSchema, outputSchema, settings | object or null | Single-workflow responses only |
canvasState, isVisual | Editor layout. Returned by get only | |
nextRunAt, scheduleTimezone | string or null | List only. Set when the workflow is active, has an enabled schedule and the next run time is in the future |
createdAt, updatedAt | string (ISO 8601) |
List workflows
GET /api/v1/workflows| Query parameter | Type | Default | Meaning |
|---|---|---|---|
status | string | draft, active, paused or archived. Without it, archived workflows are left out | |
search | string | Case-insensitive match on the name | |
limit | integer | 20 | 1 to 100 |
cursor | string | meta.cursor from the previous page |
curl 'https://workflow.loopfour.ai/api/v1/workflows?status=active&limit=10' \
-H 'x-api-key: wfk_live_your_api_key'{
"success": true,
"data": [
{
"id": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"name": "Month-end close report",
"description": "Run the close checklist each weekday",
"status": "active",
"version": 3,
"versionLabel": "v2.1",
"versionMajor": 2,
"versionMinor": 1,
"publishedVersion": 2,
"trigger": { "type": "schedule", "cron": "0 18 * * 1-5", "timezone": "America/New_York" },
"createdAt": "2026-02-01T08:00:00.000Z",
"updatedAt": "2026-02-10T09:15:00.000Z",
"nextRunAt": "2026-02-12T23:00:00.000Z",
"scheduleTimezone": "America/New_York"
}
],
"meta": { "cursor": "MTcwNjc3NDQwMDAwMDpjM2ExZjdkMi4uLg", "hasMore": true }
}Results are ordered newest first. When meta.hasMore is true, pass meta.cursor as cursor to get the next page.
Create a workflow
POST /api/v1/workflowsNew workflows start as draft at v0.1 (version 1), unpublished. The response is 201 with the full workflow object.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | Yes | 1 to 255 characters |
description | string | No | |
trigger | object | Yes | Trigger configuration, see Triggers |
steps | array | Yes | At least one step. Each step needs id, type, name and config; action steps also need action |
inputSchema | object | No | JSON Schema checked against input when the workflow is run |
outputSchema | object | No | |
settings | object | No | timeoutMs, maxRetries, concurrencyLimit, machine, maxDurationSeconds (5 or more) |
Step type is one of action, condition, loop, parallel, wait, transform, slack, email, gmail, approval, agent, code, subworkflow, data_table or workspace_files. Steps are checked when you create the workflow; a step that refers to a step that does not exist returns 400.
This example creates a workflow that you start by API call. It reads a QuickBooks invoice and posts a Slack message with the result:
curl -X POST 'https://workflow.loopfour.ai/api/v1/workflows' \
-H 'x-api-key: wfk_live_your_api_key' \
-H 'Content-Type: application/json' \
-d '{
"name": "Invoice status to Slack",
"description": "Look up an invoice and post its balance",
"trigger": { "type": "api" },
"inputSchema": {
"type": "object",
"required": ["invoiceId"],
"properties": { "invoiceId": { "type": "string" } }
},
"steps": [
{
"id": "get_invoice",
"type": "action",
"name": "Get invoice",
"action": "quickbooks.getInvoice",
"config": { "invoiceId": "{{input.invoiceId}}" }
},
{
"id": "notify",
"type": "action",
"name": "Post to finance channel",
"action": "slack.sendMessage",
"config": {
"channel": "#finance",
"text": "Invoice {{input.invoiceId}} balance: {{steps.get_invoice.Invoice.Balance}}"
}
}
]
}'Find each action's ID and settings with block and action discovery. References such as {{steps.get_invoice.Invoice.Balance}} are explained in Variable references.
Get a workflow
GET /api/v1/workflows/{id}Returns the full workflow object, including steps, canvasState and isVisual. Archived workflows can still be read. A deleted workflow returns 404.
Update a workflow
PUT /api/v1/workflows/{id}Every field is optional. Send only what you want to change.
| Field | Type | Meaning |
|---|---|---|
name, description | string | Changing only these never starts a new version |
trigger, steps, inputSchema, outputSchema, settings | Definition fields. A change follows the version rules | |
changeSummary | string | Up to 500 characters. Stored with the outgoing version when this save starts a new one |
expectedStatus | string | The status you read. A different current status returns 409 |
expectedVersion | integer | The version you read. A different current version returns 409 |
expectedUpdatedAt | string (ISO 8601) | The updatedAt you read. A different value returns 409. An in-place save keeps version but moves updatedAt, so this is the field that detects it |
The response is the full workflow object. Compare version with the value you sent to see whether a new version started.
- An archived workflow returns
409(Workflow is archived; unarchive it before editing). - If the workflow changes between the server's read and its write, the request returns
409and nothing is saved. Reload and retry. - On an
activeworkflow whose schedule timing changes, the schedule is replaced or removed after the save. If that fails, the request returns502(UPSTREAM_ERROR); the new version is already saved, so repeating the same edit does not fix the schedule.
Save the canvas
PATCH /api/v1/workflows/{id}/canvasSaves the editor layout and, optionally, the compiled steps and trigger. Studio uses this endpoint for autosave.
| Field | Type | Required | Meaning |
|---|---|---|---|
canvasState | object | Yes | Editor blocks, edges, loop and parallel membership, notes and viewport. Each top-level key you send replaces the stored value; keys you leave out stay as stored |
steps | array | No | Replacement steps. Left out, the saved steps do not change |
triggerConfig | object | No | Replacement trigger. null returns 400 |
expectedUpdatedAt | string (ISO 8601) | No | The updatedAt you read. A later save returns 409 |
canvasState.notes is a patch keyed by note ID: { "body": "...", "position": { "x": 0, "y": 0 } } creates or replaces a note, null deletes it, and IDs you do not send stay as stored. A save that would leave more than 100 notes returns 400.
A save that sends steps or triggerConfig follows the version rules. A save of only canvasState never changes the version. On an active workflow, new steps must pass full validation, and changing the trigger type returns 409 (Pause the workflow before changing its trigger).
The response has id, version, versionLabel, canvasState, isVisual, lastSavedAt, lastModifiedAt and updatedAt.
Every trigger type accepts enabled (boolean; missing means true). It is the trigger's on/off switch: while it is false, a run the trigger starts (schedule, webhook, inbound email, event) is recorded as cancelled with error code TRIGGER_DISABLED and message Trigger disabled, and no step runs. Runs started with POST /api/v1/workflows/{id}/run, restarts and retries still run. A sub-workflow step whose workflow has its trigger off fails. On an active workflow, a save can change enabled alone; any other trigger change still returns 409. A triggerConfig that leaves out enabled keeps the stored value, as long as the trigger type is the same.
Activate (deploy) a workflow
POST /api/v1/workflows/{id}/activateMakes the workflow active so its trigger can start it. This is what Deploy does in Studio. Allowed from draft, paused and active; an archived workflow returns 400 with details.allowedTransitions.
Activating also publishes. If the definition changed since the last publish, it becomes a new major version (for example v2.2 becomes v3.0) and publishedVersion is set to the new version. With nothing to publish, an active workflow returns 200 and nothing changes.
Before it activates, the API checks the steps and the trigger. Invalid steps, an event trigger with no event chosen, or a schedule trigger with neither cron nor interval return 400.
{
"success": true,
"data": {
"id": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"status": "active",
"version": 4,
"versionLabel": "v3.0",
"schedule": {
"id": "0b6c2c55-1d8f-4a2b-9e37-5f4a8d1c2e90",
"triggerScheduleId": "sched_123",
"nextRunAt": "2026-02-12T23:00:00.000Z"
}
}
}schedule appears only for schedule triggers. If the schedule or inbound address cannot be set up, the status goes back to what it was and the request returns 400; the publish stays, so the next activation does not bump the version again. A status change by another request while this one runs returns 409.
Pause a workflow
POST /api/v1/workflows/{id}/pauseMoves an active workflow to paused. Runs already in progress are not cancelled. The response is { "id": "...", "status": "paused" }.
A schedule trigger's schedule is turned off. If that fails, the workflow is still paused and the request returns 502; scheduled starts are skipped while it is paused.
Archive and unarchive
POST /api/v1/workflows/{id}/archive
POST /api/v1/workflows/{id}/unarchiveArchive stops a draft, active or paused workflow from running. It removes the schedule, deactivates the inbound email address and keeps the workflow readable under status=archived. Update, canvas save, rollback and schedule update then return 409 until you unarchive. Archiving an archived workflow returns 200 and changes nothing.
Unarchive returns the workflow to draft. Activate it again to make it runnable. An inbound email workflow gets a new address when it is activated again. Unarchiving a workflow that is not archived returns 400.
Both return { "id": "...", "status": "archived" } or { "id": "...", "status": "draft" }.
Delete a workflow
DELETE /api/v1/workflows/{id}Soft-deletes the workflow from any status and removes its schedule. It no longer appears in any list or lookup. Its past runs stay readable through the Runs API. To keep the workflow viewable, archive it instead.
{ "success": true, "data": { "deleted": true } }Run a workflow
POST /api/v1/workflows/{id}/runStarts a run of an active workflow. Any other status returns 400 with code WORKFLOW_NOT_ACTIVE.
| Field | Type | Default | Meaning |
|---|---|---|---|
input | object | {} | Run input. Checked against the workflow's inputSchema; a mismatch returns 400 VALIDATION_ERROR with details.errors |
idempotencyKey | string | If a run of this workflow already has this key, that run is returned instead of a new one | |
mode | string | async | async returns at once; sync waits for the result |
maxExecutionTimeMs | number | 30000 | Sync only. 1,000 to 300,000 |
onTimeout | string | return_run_id | Sync only. return_run_id or throw_error |
onFailure | string | throw_error | Sync only. throw_error or switch_to_async |
curl -X POST 'https://workflow.loopfour.ai/api/v1/workflows/c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15/run' \
-H 'x-api-key: wfk_live_your_api_key' \
-H 'Content-Type: application/json' \
-d '{ "input": { "invoiceId": "1042" }, "idempotencyKey": "invoice-1042-status" }'Async (default), 202:
{
"success": true,
"data": {
"run_id": "4e1f9c02-7b3d-4a1e-9c85-2f6d0b8e1a47",
"status": "pending",
"workflow_id": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"mode": "async"
}
}Poll GET /api/v1/runs/{id} with run_id until the status is completed, failed or cancelled.
Sync, completed, 200: the same fields with "mode": "sync", output and duration_ms.
Sync, timed out: with onTimeout: "return_run_id", 202 with "timed_out": true and "mode": "async". With throw_error, 408 with code SYNC_TIMEOUT and details.run_id.
Sync, failed: with onFailure: "throw_error", 500 with the run's error code (or WORKFLOW_FAILED) and details.run_id. With switch_to_async, 202 with data.error and "switched_to_async": true.
Idempotency match, 200: run_id, status, workflow_id and message: "Existing run returned (idempotency key matched)".
If the run cannot be queued, the request returns 500 with code TRIGGER_ERROR (or 202 with switched_to_async when onFailure is switch_to_async).
List a workflow's runs
GET /api/v1/workflows/{id}/runs| Query parameter | Type | Meaning |
|---|---|---|
status | string | pending, running, waiting_approval, completed, failed, suspended, cancelled or paused |
triggerType | string | api, manual, webhook, schedule, event, connection_event or subworkflow |
from, to | string (ISO 8601) | Start time range |
search | string | Run ID prefix |
limit, cursor | Default 20, maximum 100 |
Each entry has id, workflowId, workflowName, status, triggerType, startedAt, completedAt, durationMs, error and createdAt. Pagination works as in List workflows.
Clone a workflow
POST /api/v1/workflows/{id}/clone| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | Yes | 1 to 255 characters |
Copies the trigger, steps, schemas and settings into a new draft at v0.1. The response is 201 with the new workflow and clonedFrom set to the source ID.
Versions
How versions change
A save that changes the definition (trigger, steps, input or output schema, or settings) starts a new minor version (for example v3.0 to v3.1) when any of these is true:
- the current version is the published one;
- more than 30 minutes passed since the last definition save;
- a run used the current version.
Otherwise it updates the current version in place: version stays the same and updatedAt moves. A save that does not change the definition never starts a version. Activating publishes a new major version, and rollback always starts a new minor version.
List versions
GET /api/v1/workflows/{id}/versions{
"success": true,
"data": {
"workflowId": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"currentVersion": 4,
"currentVersionLabel": "v2.2",
"publishedVersion": 2,
"workflowStatus": "active",
"versions": [
{ "version": 4, "versionLabel": "v2.2", "versionMajor": 2, "versionMinor": 2, "status": "active", "isCurrent": true },
{
"version": 3,
"versionLabel": "v2.1",
"versionMajor": 2,
"versionMinor": 1,
"name": "Month-end close report",
"description": null,
"changeSummary": "Added the bank balance check",
"status": "archived",
"createdAt": "2026-02-09T10:00:00.000Z",
"isCurrent": false
}
]
}
}When publishedVersion differs from currentVersion, the workflow has unpublished edits.
Get one version
GET /api/v1/workflows/{id}/versions/{version}version is the integer version, not the label. A value that is not a number returns 400; an unknown version returns 404. The response has the version's name, description, trigger, steps and labels.
Compare two versions
GET /api/v1/workflows/{id}/versions/compare?from=2&to=4from and to are required integers. The response lists changes.name and changes.description (from, to, changed), changes.trigger.changed, and changes.steps with the added, removed and modified step IDs.
Roll back to a version
POST /api/v1/workflows/{id}/versions/{version}/rollback| Field | Type | Meaning |
|---|---|---|
changeSummary | string | Reason, up to 500 characters |
expectedStatus, expectedVersion, expectedUpdatedAt | As in Update a workflow |
Copies the chosen version into a new minor version. On an active workflow the next run uses it; activate again to publish it. Rolling back to the current version returns 400, and an archived workflow returns 409. The response includes rolledBackFrom, rolledBackTo and their labels, plus the new version, versionLabel, trigger and steps.
Schedule
These endpoints apply to workflows with a schedule trigger. For cron syntax and time zones, see Schedule trigger.
Get the schedule
GET /api/v1/workflows/{id}/scheduleReturns null in data when the trigger is not a schedule or no schedule exists yet. Otherwise the schedule has id, workflowId, scheduleType, cronExpression, interval, timezone, enabled, nextRunAt, lastRunAt, lastRunId, runCount, consecutiveFailures, lastError, createdAt and updatedAt.
Update the schedule
PUT /api/v1/workflows/{id}/schedule| Field | Type | Meaning |
|---|---|---|
cron | string | Cron expression |
interval | object | value (1 to 525,600) and unit (minutes, hours, days or weeks) |
timezone | string | IANA time zone, for example America/New_York |
enabled | boolean | Left out, a schedule that is off stays off |
{ "cron": "0 10 * * 1-5", "timezone": "America/Los_Angeles", "enabled": true }The response is { "updated": true, "scheduleId": "...", "nextRunAt": "..." }. The change is saved to the trigger, so it follows the version rules. A workflow whose trigger is not a schedule returns 400, and so does a request that leaves neither cron nor interval. An archived workflow returns 409. If the schedule cannot be turned off when enabled is false, the request returns 502 and the trigger is not changed.
Start a scheduled run now
POST /api/v1/workflows/{id}/schedule/triggerStarts one run now, as if the schedule had fired. The workflow must be active with a schedule trigger, otherwise 400. When the trigger is turned off (enabled is false), the response is 409; to test the workflow, start a run with POST /api/v1/workflows/{id}/run. The response is 202:
{
"success": true,
"data": {
"runId": "4e1f9c02-7b3d-4a1e-9c85-2f6d0b8e1a47",
"status": "pending",
"workflowId": "c3a1f7d2-58e4-4b9a-8f21-6d0e9b7c4a15",
"triggeredAt": "2026-02-12T18:30:00.000Z"
}
}Status transitions
| From | Allowed to |
|---|---|
draft | active, archived |
active | paused, archived |
paused | active, archived |
archived | draft (unarchive) |
Delete is allowed from any status. An invalid transition returns 400:
{
"success": false,
"error": {
"code": "BAD_REQUEST",
"message": "Cannot transition from 'archived' to 'active'",
"requestId": "req_123",
"details": { "currentStatus": "archived", "targetStatus": "active", "allowedTransitions": ["draft"] }
}
}Errors
| Status | Code | Cause |
|---|---|---|
400 | BAD_REQUEST | Invalid steps (Invalid workflow steps: ...), invalid transition, schedule problems |
400 | ZodError in error.name | Body or query does not match the schema. error.issues lists each problem |
400 | WORKFLOW_NOT_ACTIVE | Run requested on a workflow that is not active |
400 | VALIDATION_ERROR | Run input does not match inputSchema |
401 | UNAUTHORIZED | Missing or invalid API key |
403 | FORBIDDEN | Key lacks the scope |
404 | NOT_FOUND | Workflow or version not found |
408 | SYNC_TIMEOUT | Sync run timed out with onTimeout: "throw_error" |
409 | CONFLICT | Archived workflow edited, expected* mismatch, or a concurrent change |
429 | RATE_LIMITED | Rate limit exceeded |
500 | TRIGGER_ERROR, WORKFLOW_FAILED | Run could not be queued, or a sync run failed |
502 | UPSTREAM_ERROR | Schedule could not be updated after a save or pause |
See Errors, retries and timeouts for the shared error format.
Block and action discovery
List the block types and integration actions available to your company with the blocks endpoints, and read the schema of each one.
Runs API
Start, list, inspect, retry and cancel workflow runs with the Runs API, including how to record a decision on a pending approval request.
Loopfour