# Workflows API (/api/workflows)



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](/guides/workflows).

<Boundary title="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](https://app.riffads.com), run them from code.
</Boundary>

## The four routes [#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. `generate` alone can't start a run. Without it: `403 insufficient_scope`. [Authentication](/api/authentication).

## The loop [#the-loop]

<Steps>
  <Step>
    ### List templates [#list-templates]

    <Endpoint method="GET" path="/workflows/templates" />

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

    ```bash title="Terminal"
    curl -s https://app.riffads.com/api/v1/workflows/templates \
      -H "Authorization: Bearer $RIFFADS_API_KEY"
    ```

    ```json title="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
            }
          ]
        }
      ]
    }
    ```
  </Step>

  <Step>
    ### Invoke [#invoke]

    <Endpoint method="POST" path="/workflows/templates/{key}/invoke" />

    <Endpoint method="POST" path="/workflows/{id}/invoke" />

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

    ```bash title="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..." }
        ]
      }'
    ```

    ```json title="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.
  </Step>

  <Step>
    ### Poll the run [#poll-the-run]

    <Endpoint method="GET" path="/workflow-runs/{id}" />

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

    ```bash title="Terminal"
    curl -s https://app.riffads.com/api/v1/workflow-runs/wfr_0193c8f0a1b24e7f9d3c5a6b7e8f0077 \
      -H "Authorization: Bearer $RIFFADS_API_KEY"
    ```
  </Step>
</Steps>

* MCP: `list_templates`, `run_workflow` and `get_workflow_run`. [MCP tools](/mcp/tools).
* CLI: `riffads workflows templates` and `riffads workflows invoke --template <key>`. [Commands](/cli/commands).

## Template fields [#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 [#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) |

<Callout type="warn" title="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.
</Callout>

## Invoke body [#invoke-body]

```ts
{
  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 [#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.

<Callout type="warn" title="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.
</Callout>

### What inputs can set [#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 |

```json title="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 [#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`.

<Callout type="info" title="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.
</Callout>

## Reading a run [#reading-a-run]

```json title="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](/api/generations).

| 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_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 [#the-credits-object]

`credits_charged` is `null` while the run is open. Never show it as `0`.

### What next\_action says [#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 [#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](/guides/results).

## Errors [#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](/reference/errors).

<Callout type="warn" title="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.
</Callout>

## Not here [#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.
