Workflows API
List templates, invoke a template or a workflow, poll the run every 30 seconds.
Read as MarkdownRun a saved workflow or a published template from code, then read it node by node until it ends. What a workflow is: workflows guide.
Agents run workflows, they can't build them
No route creates, edits or deletes a graph. The only writes are the two invokes. Build workflows in the app at app.riffads.com, run them from code.
The four routes
| Endpoint | Scope | Rate limit per key | What it does |
|---|---|---|---|
GET /workflows/templates | Read | 120 a minute (agent_read) | Published templates, with the node ids you can fill |
POST /workflows/templates/{key}/invoke | Workflows | 20 a minute (workflow_run) | Runs a template |
POST /workflows/{id}/invoke | Workflows | 20 a minute (workflow_run) | Runs a workflow your workspace owns |
GET /workflow-runs/{id} | Read | 120 a minute (agent_read) | One run, node by node |
- Every key has Read.
- Invokes need the Workflows scope, set when the key is created.
generatealone can't start a run. Without it:403 insufficient_scope. Authentication.
The loop
List templates
GET/api/v1/workflows/templatesAPI keyNo params, no paging. Templates your workspace can't run are left out.
curl -s https://app.riffads.com/api/v1/workflows/templates \
-H "Authorization: Bearer $RIFFADS_API_KEY"{
"ok": true,
"templates": [
{
"template_key": "ugc_face_cam",
"name": "Creator to camera",
"description": "A creator you invent, talking straight to camera about your brand",
"category": "End to end",
"step_count": 7,
"inputs": [
{
"node": "f_brand",
"label": "Brand context",
"field": "text",
"field_label": "value",
"kind": "text",
"max_length": 8000,
"filled": true
}
]
}
]
}Invoke
POST/api/v1/workflows/templates/{key}/invokeAPI keyPOST/api/v1/workflows/{id}/invokeAPI keySame body, same response. The path names the graph. There is no workflow_id or template_key body field.
curl -i -X POST https://app.riffads.com/api/v1/workflows/templates/ugc_face_cam/invoke \
-H "Authorization: Bearer $RIFFADS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"max_credits": 1200,
"inputs": [
{ "node": "f_brand", "field": "text", "value": "Lumen Skin, direct to consumer skincare..." }
]
}'{
"ok": true,
"workflow_run_id": "wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077",
"workflow_id": "wf_0193c8f0a1b24e7f9d3c5a6b7e8f0055",
"template_key": "ugc_face_cam",
"billable_node_count": 6,
"applied_inputs": [{ "node": "f_brand", "field": "text" }],
"replay": false,
"credits": {
"credits_estimated": 742,
"credits_charged_so_far": 0,
"credits_charged": null,
"settlement": "open",
"terminal": false,
"wallet_balance": 8321
},
"next_action": "This run has 6 steps to work through and takes minutes, not seconds. It is accepted, not finished. GET /api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 about every thirty seconds until still_running is false. Do not tell the person it is ready until then, and do not hand the checking back to them."
}201 carries Location: /api/v1/workflow-runs/{workflow_run_id}. Numbers are examples.
Poll the run
GET/api/v1/workflow-runs/{id}API keyNever blocks. Read it about every 30 seconds until run.still_running is false.
curl -s https://app.riffads.com/api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 \
-H "Authorization: Bearer $RIFFADS_API_KEY"- MCP:
list_templates,run_workflowandget_workflow_run. MCP tools. - CLI:
riffads workflows templatesandriffads workflows invoke --template <key>. Commands.
Template fields
| Field | Type | Notes |
|---|---|---|
template_key | string | Path param for the template invoke |
name | string | |
description | string | null | |
category | string | null | Free text. Never branch on it |
step_count | integer | Every node except canvas notes (value nodes count). An upper bound. The invoke returns the real billable_node_count |
inputs | array | One entry per writable text field, open setting, actor slot and voice slot |
Input slots
| Field | What it is |
|---|---|
node | Node id to use in inputs. Use it exactly |
label | Node name, for people |
field | Field on that node to fill |
field_label | Field name as people see it. A value node's field is value |
kind | text, setting, actor or voice |
accepts | Settings only: the legal values, in words |
max_length | Text only: character limit |
filled | true if the node already holds a value (usually example copy) |
You need this list to fill inputs
inputs are keyed by node id, and you can't guess them. A wrong id is refused before anything starts. Without the list you can only run a template unchanged.
Invoke body
{
inputs?: Array<{ node: string; field: string; value: string | number | boolean }>;
max_credits: number;
}| Field | Type | Required | Rules |
|---|---|---|---|
max_credits | integer | yes | Whole number above zero. Spend cap for the whole run |
inputs | array | no | Max 60 entries |
inputs[].node | string | yes | Node id from the template list or a run you read |
inputs[].field | string | yes | Field name on that node |
inputs[].value | string, number or boolean | yes | Scalar only. No objects, arrays or null |
Strict at both levels. An unknown key is 400 invalid_config.
Sizing max_credits
max_creditsis checked against every step's estimate plus 10%, summed.- There is no run estimate endpoint.
POST /estimatesprices one config, not a graph. Size fromstep_countand leave room. - Too low:
402 max_credits_exceeded, withlimit.bound_by: "max_credits". The message names both numbers. Nothing starts.
Don't reuse credits_estimated as max_credits
credits_estimated is usually below the number max_credits is checked against. Using it as your next cap refuses runs that would have fit.
What inputs can set
kind | Send | field |
|---|---|---|
text | Text, up to max_length characters | The slot's field |
setting | One of the values accepts lists | The slot's field |
actor | An act_ id from GET /actors | Always actor |
voice | A voc_ id from GET /voices | Always voice |
- Inputs patch this run's snapshot only. The saved workflow never changes.
- All or nothing. One bad entry refuses the whole call. Nothing starts.
- A later entry for the same field wins.
Refused, each with a message that says why:
| Input | Refusal |
|---|---|
| Unknown node or field | Message lists what the node takes |
| A wired field (fed by another node) | Message names the node to set instead |
count | Never writable |
| Empty text | Leave the field out to keep the template value |
Text over max_length | Message gives your length and the limit |
| Illegal setting value | Message lists the accepted values |
| Actor or voice id you can't use | 404 not_found |
| A file | Values are scalar. File slots aren't exposed, so you can't attach an upload to a run |
{
"error": {
"ok": false,
"code": "invalid_config",
"message": "This workflow has no node called \"brand\". Use a node id exactly as list_templates or get_workflow_run reported it. Nothing was started and nothing was charged.",
"retryable": false,
"credits_charged": 0
}
}Messages name MCP tools like list_templates. The REST route is GET /api/v1/workflows/templates.
Invoking by template key
- The first run creates your workspace's copy: one workflow per template, its name ending in
(agent). Later runs of that key reuse it. - The copy keeps the template's node ids, so the template's
inputslist works on it. workflow_idin the response is that copy. Invoking it by id runs the same graph.- Delete the copy in the app and the next run makes a fresh one.
- One run at a time per workflow. Same key again while it runs:
409 submission_in_flight, "This workflow is already running. Wait for it to finish." Different workflows can run at once, up to your plan's render limit. - Unpublished and unknown keys both answer
404 not_found.
Invoking by workflow id
No endpoint lists your workflows or returns a saved graph. With a bare workflow_id, run it as saved. Never invent node ids.
Reading a run
{
"ok": true,
"run": {
"workflow_run_id": "wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077",
"workflow_id": "wf_0193c8f0a1b24e7f9d3c5a6b7e8f0055",
"status": "completed",
"still_running": false,
"nodes": [
{
"node_id": "f_analyze",
"label": "Analyze the brand",
"status": "completed",
"credits": 2,
"generations": [
{
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0011",
"capability_id": "script_llm",
"status": "completed",
"outputs": [],
"credits": {
"credits_held": 3,
"credits_charged": 2,
"settlement": "captured",
"terminal": true
}
}
],
"text": "Lumen Skin sells to people who read the ingredient list first...",
"error": null
},
{
"node_id": "f_video",
"label": "The face cam",
"status": "completed",
"credits": 400,
"generations": [
{
"generation_id": "gen_0193c8f0a1b24e7f9d3c5a6b7e8f0033",
"capability_id": "veo_31",
"status": "completed",
"outputs": [
{
"index": 0,
"kind": "video",
"url": "https://<signed-url>",
"file_name": "veo-31-0.mp4",
"width": 1080,
"height": 1920
}
],
"credits": {
"credits_held": 440,
"credits_charged": 400,
"settlement": "captured",
"terminal": true
}
}
],
"text": null,
"error": null
}
],
"credits": {
"credits_estimated": 742,
"credits_charged_so_far": 402,
"credits_charged": 402,
"settlement": "captured",
"terminal": true,
"wallet_balance": 8133
},
"started_at": "2026-09-17T10:04:11.482Z",
"finished_at": "2026-09-17T10:12:02.900Z"
},
"output_urls_expire_in_seconds": 600,
"next_action": "This run is finished and credits_charged is final. The output links below expire in 10 minutes, so pass them on now."
}Each generations entry has the same shape as GET /generations/{id}. Generations API.
| Field | Type | Notes |
|---|---|---|
run.status | string | queued, running, completed, partial, failed, canceled |
run.still_running | boolean | false for the last four. Poll on this |
run.nodes[].node_id | string | Same id inputs uses |
run.nodes[].label | string | Node name |
run.nodes[].status | string | pending, ready, dispatching, running, completed, failed, skipped, canceled |
run.nodes[].credits | integer | Charged for this node. 0 until it finishes |
run.nodes[].generations | array | Generations with outputs. Empty until the node runs |
run.nodes[].text | string | null | Writer nodes return text, no file |
run.nodes[].error | object | null | { code, message }. code is one of provider_error, moderation, timeout, canceled, insufficient_credits, invalid_input, unknown |
run.credits | object | See below |
run.started_at, run.finished_at | string | null | ISO 8601. finished_at is null while running |
output_urls_expire_in_seconds | 600 | Present only when an output has a URL |
next_action | string | One sentence for the run's state |
partial: some steps delivered.failed: none did.skipped: the node never ran because an input it needed failed. Nothing charged.canceled: a person canceled the run in the app.waiting_approvalexists in the status enum but is never set. Don't branch on it.- A talking actor node runs its own voice step, then the video. Both show under that node's
generations. - A refused script shows up at its step as
error.code: "moderation", not on the invoke.
The credits object
credits_charged is null while the run is open. Never show it as 0.
What next_action says
| Run state | next_action says |
|---|---|
still_running is true | Read again in about 30 seconds |
partial | Report what came back, ask whether to run again |
failed or canceled | Read each step's error first. An unchanged rerun usually fails the same way |
completed | Totals are final, links expire in 10 minutes, pass them on now |
Getting the files
run.nodes[].generations[].outputs[].urlisnulluntil that generation iscompleted.- Links last 600 seconds. Download now. Read the run again for fresh links. Results guide.
Errors
Standard error envelope. Branch on code and retryable, never the message.
| Code | HTTP | Retryable | Cause |
|---|---|---|---|
insufficient_scope | 403 | no | Key lacks the Workflows scope |
not_found | 404 | no | Unknown or unpublished key, unknown workflow or run id, another workspace's id, or an actor or voice id you can't use |
invalid_config | 400 | no | Bad body, unknown key, rejected input, graph not ready, or nothing to generate |
insufficient_credits | 402 | no | Not enough credits to start. Carries shortfall. Top up in the app |
max_credits_exceeded | 402 | no | Run needs more than your max_credits. limit.bound_by: "max_credits" |
spend_limit_exceeded | 402 | no | A workspace or key cap blocked it. limit.bound_by names it. Ask a workspace owner |
submission_in_flight | 409 | yes | This workflow is already running, or this key has a generation running |
not_priced | 409 | no | A model in the graph has no published price. Swap the model in the app |
request_blocked | 429 | no | The same request failed again and again, so it is paused. Carries blocked_reason |
rate_limited | 429 | yes | Over 20 invokes a minute on this key. Carries retry_after_seconds |
moderation_unavailable | 503 | yes | Content checks are down. Nothing can start |
Any refused invoke charges nothing. All codes: error codes.
Runs and generations on one key
A run is refused while the key has a generation in flight. A generation is not refused while the key has a run in flight. Need both at once? Use a second key.
Not here
- No authoring. No create, edit or delete.
- No workflow list, no graph read. Get a
workflow_idfrom an invoke or from a person. - No run list. Keep your
workflow_run_id. - No cancel, no retry. Both live in the app. On
partial, start a new run. - No
/wait, no stream. Poll. - No webhooks. The body is strict, so a callback field is a
400. - No single node run. No
scopeortarget_node_id. - No
Idempotency-Keyheader. A blind retry starts and charges a second run, unless the first is still running (409).replay: trueis authoritative when present.