REST API

Workflows API

List templates, invoke a template or a workflow, poll the run every 30 seconds.

Read as Markdown

Run 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

EndpointScopeRate limit per keyWhat it does
GET /workflows/templatesRead120 a minute (agent_read)Published templates, with the node ids you can fill
POST /workflows/templates/{key}/invokeWorkflows20 a minute (workflow_run)Runs a template
POST /workflows/{id}/invokeWorkflows20 a minute (workflow_run)Runs a workflow your workspace owns
GET /workflow-runs/{id}Read120 a minute (agent_read)One run, node by node
  • Every key has Read.
  • Invokes need the Workflows scope, set when the key is created. generate alone can't start a run. Without it: 403 insufficient_scope. Authentication.

The loop

List templates

GET/api/v1/workflows/templatesAPI key

No params, no paging. Templates your workspace can't run are left out.

Terminal
curl -s https://app.riffads.com/api/v1/workflows/templates \
  -H "Authorization: Bearer $RIFFADS_API_KEY"
Response: 200 (trimmed)
{
  "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 key

Same body, same response. The path names the graph. There is no workflow_id or template_key body field.

Terminal
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..." }
    ]
  }'
Response: 201
{
  "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 key

Never blocks. Read it about every 30 seconds until run.still_running is false.

Terminal
curl -s https://app.riffads.com/api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 \
  -H "Authorization: Bearer $RIFFADS_API_KEY"
  • MCP: list_templates, run_workflow and get_workflow_run. MCP tools.
  • CLI: riffads workflows templates and riffads workflows invoke --template <key>. Commands.

Template fields

FieldTypeNotes
template_keystringPath param for the template invoke
namestring
descriptionstring | null
categorystring | nullFree text. Never branch on it
step_countintegerEvery node except canvas notes (value nodes count). An upper bound. The invoke returns the real billable_node_count
inputsarrayOne entry per writable text field, open setting, actor slot and voice slot

Input slots

FieldWhat it is
nodeNode id to use in inputs. Use it exactly
labelNode name, for people
fieldField on that node to fill
field_labelField name as people see it. A value node's field is value
kindtext, setting, actor or voice
acceptsSettings only: the legal values, in words
max_lengthText only: character limit
filledtrue 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;
}
FieldTypeRequiredRules
max_creditsintegeryesWhole number above zero. Spend cap for the whole run
inputsarraynoMax 60 entries
inputs[].nodestringyesNode id from the template list or a run you read
inputs[].fieldstringyesField name on that node
inputs[].valuestring, number or booleanyesScalar only. No objects, arrays or null

Strict at both levels. An unknown key is 400 invalid_config.

Sizing max_credits

  • max_credits is checked against every step's estimate plus 10%, summed.
  • There is no run estimate endpoint. POST /estimates prices one config, not a graph. Size from step_count and leave room.
  • Too low: 402 max_credits_exceeded, with limit.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

kindSendfield
textText, up to max_length charactersThe slot's field
settingOne of the values accepts listsThe slot's field
actorAn act_ id from GET /actorsAlways actor
voiceA voc_ id from GET /voicesAlways 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:

InputRefusal
Unknown node or fieldMessage lists what the node takes
A wired field (fed by another node)Message names the node to set instead
countNever writable
Empty textLeave the field out to keep the template value
Text over max_lengthMessage gives your length and the limit
Illegal setting valueMessage lists the accepted values
Actor or voice id you can't use404 not_found
A fileValues are scalar. File slots aren't exposed, so you can't attach an upload to a run
Response: 400
{
  "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 inputs list works on it.
  • workflow_id in 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

Response: 200 (generation objects trimmed)
{
  "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.

FieldTypeNotes
run.statusstringqueued, running, completed, partial, failed, canceled
run.still_runningbooleanfalse for the last four. Poll on this
run.nodes[].node_idstringSame id inputs uses
run.nodes[].labelstringNode name
run.nodes[].statusstringpending, ready, dispatching, running, completed, failed, skipped, canceled
run.nodes[].creditsintegerCharged for this node. 0 until it finishes
run.nodes[].generationsarrayGenerations with outputs. Empty until the node runs
run.nodes[].textstring | nullWriter nodes return text, no file
run.nodes[].errorobject | null{ code, message }. code is one of provider_error, moderation, timeout, canceled, insufficient_credits, invalid_input, unknown
run.creditsobjectSee below
run.started_at, run.finished_atstring | nullISO 8601. finished_at is null while running
output_urls_expire_in_seconds600Present only when an output has a URL
next_actionstringOne 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_approval exists 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 statenext_action says
still_running is trueRead again in about 30 seconds
partialReport what came back, ask whether to run again
failed or canceledRead each step's error first. An unchanged rerun usually fails the same way
completedTotals are final, links expire in 10 minutes, pass them on now

Getting the files

  • run.nodes[].generations[].outputs[].url is null until that generation is completed.
  • 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.

CodeHTTPRetryableCause
insufficient_scope403noKey lacks the Workflows scope
not_found404noUnknown or unpublished key, unknown workflow or run id, another workspace's id, or an actor or voice id you can't use
invalid_config400noBad body, unknown key, rejected input, graph not ready, or nothing to generate
insufficient_credits402noNot enough credits to start. Carries shortfall. Top up in the app
max_credits_exceeded402noRun needs more than your max_credits. limit.bound_by: "max_credits"
spend_limit_exceeded402noA workspace or key cap blocked it. limit.bound_by names it. Ask a workspace owner
submission_in_flight409yesThis workflow is already running, or this key has a generation running
not_priced409noA model in the graph has no published price. Swap the model in the app
request_blocked429noThe same request failed again and again, so it is paused. Carries blocked_reason
rate_limited429yesOver 20 invokes a minute on this key. Carries retry_after_seconds
moderation_unavailable503yesContent 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_id from 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 scope or target_node_id.
  • No Idempotency-Key header. A blind retry starts and charges a second run, unless the first is still running (409). replay: true is authoritative when present.

On this page