Skip to content

Workflow API

How does your service run a flow and read its answer? Send an authenticated request, keep the execution ID it returns, then poll for status and outputs. For Invoice intake, call Check invoice with an invoice ID and an amount, then read its review decision.

RequestPurpose
POST /api/v1/run-workflowStart one flow.
GET /api/v1/workflow-results/:execution_idRead status and outputs.

The default host is https://api.app.getcontroller.ai. These endpoints run workflows. You author those workflows through the app, the CLI, or MCP.

In the app: open Settings → API keys → Create new secret key, then choose Copy API key on its row. Use the copied value as <apiKey> below. Supply it as Authorization: Bearer …. Keys start with sk-.

Open Invoice intake, select the intended Dev / Live version and Check invoice, then open Triggers → Start this flow via API. The panel generates sample values. For saved-record inputs it generates objects, even though those inputs actually require ID strings. Check the selected version’s Inputs before you use its sample.

With an assistant: this page passes workflow context explicitly with --workflow <workflowId>. Start by listing your workflows:

Terminal window
cai workflow list --limit 100 --json

MCP uses workflow_list with limit: 100, its maximum.

Note Invoice intake’s data.items[].id as <workflowId>, then check data.hasMore. The default is 25 rows, and neither interface offers a next-page cursor. If the workflow is absent with hasMore: true, find it through app search and copy its ID from /workflows/<workflowId>/versions/….

For the Live request below, read the newest published release:

Terminal window
cai workflow releases --workflow <workflowId> --json

MCP uses workflow_release_list with workflowId.

Note data.releases[0].workflowVersionStateId as <workflowVersionStateId>. An empty list means nothing is published.

Now read that release’s document:

Terminal window
cai workflow document --workflow <workflowId> --state <workflowVersionStateId> --json

MCP uses workflow_document_get with workflowId and stateId.

Find Check invoice in data.document.flows, note its id as <flowId>, and inspect its flowInputs.

For a Dev request, read the development document instead:

Terminal window
cai workflow document --workflow <workflowId> --json

flow list and flow get read development. A flow or input change that exists only in Dev does not describe Live.

Save this as invoice-request.json, replacing the placeholders with the IDs just read:

{
"workflow_id": "<workflowId>",
"flow_id": "<flowId>",
"mode": "live",
"flow_inputs": {"Invoice ID": "INV-104", "Amount": 120}
}

Send exactly "dev" or "live". If you omit mode, or send "", null, false, or 0, the request selects Live. Any other value is rejected. Live selects the current live release, and Dev selects development. Both perform real actions and spend usage. Development and Live select different data and file state.

Post that file to the run endpoint:

Terminal window
curl --silent --show-error \
'https://api.app.getcontroller.ai/api/v1/run-workflow' \
--header 'Authorization: Bearer <apiKey>' \
--header 'Content-Type: application/json' \
--data @invoice-request.json

Acceptance returns HTTP 200 and a numeric execution ID:

{"status":"pending","execution_id":301,"error":null}
Field or valueRule
workflow_idPositive integer or numeric string.
flow_idNonempty string, trimmed before lookup.
flow_inputsJSON object; omission supplies {}.
Input keysExact ID, stored name, or display name, checked in that order. Unknown keys and conflicting aliases reject.
Required inputsMissing or null rejects.
Optional inputsOmission or null becomes null; neither saved test values nor declared defaults are substituted.
Non-null valuesMust match the declared type; Amount takes 120, not "120". Lists require their declared item type. Custom objects receive no nested-field validation.

0, false, and empty text count as supplied values when their types match. Dates accept strict ISO-8601 strings, or finite epoch milliseconds that produce a valid date. An empty "" passes even for a required date and becomes null, so supply a real date.

Saved-record inputs accept ID strings or lists of strings at submission. An empty ID, a record missing from the selected version, and an inactive collection each fail after acceptance, before nodes run. An existing active record’s collection is not checked against the declared input type, so supply an ID from the declared collection.

Note execution_id as <workflowExecutionId>, then read the result:

Terminal window
curl --silent --show-error \
'https://api.app.getcontroller.ai/api/v1/workflow-results/<workflowExecutionId>' \
--header 'Authorization: Bearer <apiKey>'
StatusNext action
pending, runningPoll the same ID again.
pausedInspect the existing run; these endpoints cannot resume it.
completedStop polling and check values.
completed_with_errorStop polling; inspect the handled error and continued path.
failed, cancelledStop polling; inspect prior effects before repeating.

For INV-104, expect:

{
"status": "completed",
"execution_id": 301,
"workflow_outputs": [
{"name":"invoice_id","value":"INV-104"},
{"name":"review_needed","value":false}
],
"error": ""
}

Outputs come from the entry flow’s completed Return response nodes. When two of them share a name, the later node execution ID overwrites the earlier value. With no outputs, GET returns workflow_outputs: []. Compare the values, not just the status, and debug the run when they differ.

Synchronous request and input checks, and dispatch failures, return HTTP 4xx/5xx with {"status":"failed","execution_id":null,"error":"…"}. Once a request reaches pending, graph, record-loading, and runtime failures appear when you poll that ID.

Authentication, GET, and JSON-parser failures instead return {statusCode, error: {code, message}, data: null}. Malformed JSON gives HTTP 400 with Invalid JSON body. Check your request payload syntax. A JSON body over 10 MB is rejected before execution.

HTTP statusCause
400 POSTInvalid inputs or truthy mode other than live/dev; missing/foreign workflow; unavailable version/flow; no usage balance.
401Missing/invalid authentication.
404 / 403 GETMissing execution / another owner’s execution.
400 GETOutside the plan’s run-history window.
503 / 500 POSTUnavailable engine / unexpected failure; message: Internal server error.

A dispatch failure can leave a failed history entry despite execution_id: null, because the record is created before dispatch. Each accepted POST creates another execution, so do not resubmit because a response is delayed.

API-key callers can add a receiver they control:

{"callback_url":"https://example.com/hooks/controller"}

callback_url is not validated. A falsey value is ignored. An invalid or unreachable truthy value still allows acceptance. Delivery errors are only logged, and do not change execution status.

The server waits for completion, then attempts one unsigned POST, only if the status is terminal. Pausing delays completion. If the wait fails while the status is still nonterminal, delivery is skipped. Delivery has no retry, and does not survive an API-process restart. Keep polling, and use a callback to schedule a GET check.

The body has status, execution_id, workflow_outputs, and error. Absent outputs can be null here, while GET returns an array.

Assistants normally use this command instead of POST. It defaults to Dev, and Live requires --confirm-live.

Terminal window
cai run flow <flowId> --workflow <workflowId> \
--inputs '{"Invoice ID":"INV-104","Amount":120}' \
--target live --confirm-live --json

MCP uses run_flow with workflowId, flowId, parsed inputs, target: "live", and confirmLive: true.

CLI credentials need workflows:write for POST, cannot include callback_url, and cannot use GET-results. A Dev REST request made with a CLI credential is also checked against the workflow’s daily assistant test-run cap. Inspect results through Debug a Run.

Webhook ingestion uses the separate POST /api/events/:webhookId. Follow Start with Triggers.