# Workflows

> Run saved Nodaro workflows from the API with new input values, wait for the result or poll for it, and list, create, update, export and move workflows.

Source: https://nodaro.ai/docs/developers/api/workflows

A **workflow** is a saved canvas of connected nodes, and the API can run it, give it new input values for one run, and manage it like any other resource. A run starts with one request that returns an `executionId`, and you poll the run until it finishes. A short run can also wait for its result in the same request.

## Endpoints

### Run a workflow

| Method | Path | What it does |
| --- | --- | --- |
| `POST` | `/v1/workflows/:id/run` | Run the saved workflow, or some of its nodes. Answers `202` with an `executionId`. |
| `GET` | `/v1/api/workflows` | List the workflows your API token can run. Supports `?limit=` and `?cursor=`. |
| `GET` | `/v1/api/schema?workflowId=…` | A workflow's input fields and outputs, with `estimatedCredits`. |
| `POST` | `/v1/api/run` | Run a workflow with new input values. Add `?wait=true&timeout=…` to wait for the result. |
| `GET` | `/v1/api/status/:execId` | The run's status, node counts and credits used. |
| `GET` | `/v1/api/result/:execId` | The run's outputs, once its status is `completed` or `failed`. |
| `POST` | `/v1/app/:slug/run` | Run a published app with its form fields. See [MiniApps](https://nodaro.ai/docs/developers/embed/miniapps). |

### Manage workflows

| Method | Path | What it does |
| --- | --- | --- |
| `GET` | `/v1/projects/:projectId/workflows` | The workflows of one project, without their nodes and edges. |
| `GET` | `/v1/workflows` | Your workflows across all projects. |
| `GET` | `/v1/workflows/:id` | One workflow with its nodes, edges and settings. |
| `POST` | `/v1/projects/:projectId/workflows` | Create a workflow in a project. |
| `PATCH` | `/v1/workflows/:id` | Change any subset of a workflow's fields. |
| `DELETE` | `/v1/workflows/:id` | Delete a workflow. |
| `GET` | `/v1/workflows/:id/export` | Export the workflow as a JSON bundle. Add `?assets=true` to include its characters, objects and locations. |
| `POST` | `/v1/workflows/import` | Create a workflow from a bundle. |
| `POST` | `/v1/workflows/:id/move` | Move a workflow to another project. |
| `GET` | `/v1/workflows/shared-with-me` | Workflows other people shared with you. |

Sharing, collaborators and per-workflow permissions are covered in [Workspaces and organizations](https://nodaro.ai/docs/developers/api/workspaces).

## Three ways to run a workflow

| Endpoint | Credential | Input values | Use it when |
| --- | --- | --- | --- |
| `POST /v1/workflows/:id/run` | Any token. OAuth tokens need `workflows:execute`. | The saved values. `nodeIds` runs a subset. | You run the workflow as saved, for your account or for an OAuth user. |
| `POST /v1/api/run` | A personal API token | `inputs` replaces input-node values for this run. | A script needs different values on every run, or wants to wait for the result. |
| `POST /v1/app/:slug/run` | Any token | The app's form fields, plus raw node overrides | The workflow is published as an app with a curated form. |

The five `/v1/api/` endpoints are the original API-token lane. They predate published apps and remain supported, but new integrations that need inputs usually publish the workflow as an app and use `POST /v1/app/:slug/run`. That route takes the app's fields as flat `inputs` and an optional `inputOverrides` object of raw `{ nodeId: { field: value } }` overrides. The two are merged, and `inputOverrides` wins on any field both set.

## Run a saved workflow

`POST /v1/workflows/:id/run` starts a run of the workflow as it is saved. Send an empty body to run every node, or `nodeIds` to run a subset:

**curl**

```bash
curl -s -X POST https://app.nodaro.ai/v1/workflows/8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f/run \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
```

```json
{ "executionId": "3f9e2b1a-7c6d-4e5f-8a9b-0c1d2e3f4a5b", "status": "pending" }
```

**TypeScript SDK**

```ts
const { executionId } = await client.workflows.run(workflowId)

// Or run only some nodes:
await client.workflows.run(workflowId, { nodeIds: ['text-prompt-1', 'generate-image-1'] })
```

**CLI**

```bash
nodaro workflows run 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f --watch
nodaro workflows run 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f --node text-prompt-1 generate-image-1
```

<TypeTable
type={{
nodeIds: { type: 'string[]', description: 'Optional. The ids of the nodes to run. Omit it to run the whole workflow.' },
}}
/>

The answer is `202 Accepted` with `{ executionId, status }`, where `status` is `pending` or `running`. Poll the run with `GET /v1/workflow-executions/:id`, described in [Executions](https://nodaro.ai/docs/developers/api/executions). With `--watch`, the CLI polls for you and exits with code `2` when the run fails and `130` when it is cancelled.

| Status | Code | Meaning |
| --- | --- | --- |
| 402 | `insufficient_credits` | Your credits cannot cover the run's worst-case cost. |
| 403 | `forbidden` | You may see the workflow but not run it. In a workspace, running needs edit access and active membership. |
| 404 | `not_found` | No such workflow, or none you can see. |
| 409 | `already_running` | The workflow already has an active run. The response carries that run's `executionId`. |

## Run a workflow with new input values

`POST /v1/api/run` takes an `inputs` object that replaces the values of input nodes for this one run. It authenticates with a personal API token. The keys of `inputs` are node ids or, as a convenience, unique node labels. Inside each key, name the node's input field, such as `text` for a text prompt.

Workflow: The example workflow: a Text node, whose text the API run replaces, feeds a Generate Image node.

- Text → Generate Image (prompt)

The example below runs a workflow with a [Text](https://nodaro.ai/docs/nodes/automate/text) node, id `text-prompt-1`, wired into a [Generate Image](https://nodaro.ai/docs/nodes/image/generate-image) node.

### Find the input fields

`GET /v1/api/schema` lists the workflow's inputs, with the field each one takes, its outputs, and an estimate of the credits a run costs:

```bash
curl -s "https://app.nodaro.ai/v1/api/schema?workflowId=$WORKFLOW_ID" \
  -H "Authorization: Bearer $NODARO_API_KEY"
```

```json
{
"workflowId": "8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
"name": "Sunset stills",
"estimatedCredits": 45,
"inputs": [
{ "nodeId": "text-prompt-1", "key": "text", "label": "Prompt", "type": "text" }
],
"outputs": [
{ "nodeId": "generate-image-1", "label": "Generate Image", "type": "image" }
]
}
```

### Start the run

```bash
curl -s -X POST https://app.nodaro.ai/v1/api/run \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
"workflowId": "8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f",
"inputs": {
"text-prompt-1": { "text": "a cat at sunset" }
}
}'
```

```json
{ "executionId": "3f9e2b1a-7c6d-4e5f-8a9b-0c1d2e3f4a5b", "status": "pending" }
```

### Poll until the run ends

`GET /v1/api/status/:execId` returns the status, the node counts and the credits used so far. Poll every 2 to 5 seconds until the status is `completed`, `failed`, `cancelled`, `timed_out` or `discarded`.

### Fetch the result

`GET /v1/api/result/:execId` returns the outputs of a `completed` or `failed` run. For a run that ended `cancelled`, `timed_out` or `discarded`, read `errorMessage` from the status response instead: those runs have no result payload, and the result route keeps answering `202`.

```json
{
"executionId": "3f9e2b1a-7c6d-4e5f-8a9b-0c1d2e3f4a5b",
"status": "completed",
"creditsUsed": 45,
"durationMs": 12450,
"errorMessage": null,
"outputs": [
{
"nodeId": "generate-image-1",
"label": "Generate Image",
"type": "image",
"url": "https://…/output.png"
}
]
}
```

The whole flow as one script, and the same calls from TypeScript:

**curl**

```bash
BASE="https://app.nodaro.ai"
WORKFLOW_ID="8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f"

EXEC=$(curl -s -X POST "$BASE/v1/api/run" \
  -H "Authorization: Bearer $NODARO_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"workflowId\": \"$WORKFLOW_ID\", \"inputs\": {\"text-prompt-1\": {\"text\": \"a cat at sunset\"}}}" \
| jq -r .executionId)

while true; do
STATUS=$(curl -s -H "Authorization: Bearer $NODARO_API_KEY" \
"$BASE/v1/api/status/$EXEC" | jq -r .status)
echo "Status: $STATUS"
case "$STATUS" in completed|failed|cancelled|timed_out|discarded) break;; esac
sleep 5
done

curl -s -H "Authorization: Bearer $NODARO_API_KEY" "$BASE/v1/api/result/$EXEC" | jq .
```

**TypeScript SDK**

```ts
// The /v1/api/ lane has no dedicated SDK method: call it with client.request.
const schema = await client.request('GET', '/v1/api/schema', {
query: { workflowId },
})

const { executionId } = await client.request<{ executionId: string }>('POST', '/v1/api/run', {
body: { workflowId, inputs: { 'text-prompt-1': { text: 'a cat at sunset' } } },
})

const final = ['completed', 'failed', 'cancelled', 'timed_out', 'discarded']
let status = 'pending'
while (!final.includes(status)) {
await new Promise((r) => setTimeout(r, 3_000))
;({ status } = await client.request<{ status: string }>('GET', `/v1/api/status/${executionId}`))
}

const result = await client.request('GET', `/v1/api/result/${executionId}`)
```

The CLI runs workflows with their saved values only. To pass values from the terminal, publish the workflow as an app and run `nodaro apps run <slug> --input prompt="…"`.

<TypeTable
type={{
workflowId: { type: 'string (uuid)', description: 'The workflow to run.', required: true },
inputs: { type: 'object', description: 'Optional. New values for input nodes, keyed by node id or by unique node label. Each value is an object of fields for that node, for example { text: "…" }.' },
}}
/>

A token with a workflow scope can run only the workflows in its scope. Any other workflow answers `403 forbidden`. `POST /v1/api/run` and `GET /v1/api/workflows` count against the token's per-minute limit; the status, result and schema reads do not. See [Rate limits](https://nodaro.ai/docs/developers/api/rate-limits).

## Sync or async

`POST /v1/api/run` is asynchronous by default: it answers `202 Accepted` with `{ executionId, status: "pending" }` at once, and you poll.

For a short workflow, hold the connection until it finishes:

```http
POST /v1/api/run?wait=true&timeout=120
```

- The server checks the run every 5 seconds for up to `timeout` seconds. The default is 120 and the maximum is 600.
- If the run finishes in time, the response is the same payload as `GET /v1/api/result/:execId`. Its `status` is `completed`, `failed`, `cancelled`, `timed_out` or `discarded`.
- If it does not, the response is `202` with `{ executionId, status: "pending" }`, and you poll from there.

Use the synchronous form for runs you expect to finish in under a minute, such as text generation and light image work. Use the asynchronous form for workflows that render or upscale video. From the SDK, raise `timeoutMs` in `createClient` above your `timeout`, because the client gives up after 60 seconds by default.

`POST /v1/workflows/:id/run` and the generation routes are always asynchronous. For a single node, the SDK's `nodes.runAndWait` polls the job for you: see [Run a single node](https://nodaro.ai/docs/developers/api/nodes).

## What a run cannot change

A run request cannot re-point a workflow's outbound nodes. Where a workflow sends to or fetches from is decided by the workflow itself, on every run endpoint, including `POST /v1/workflows/:id/run`, `POST /v1/api/run` and `POST /v1/app/:slug/run`.

The outbound nodes are [Webhook Output](https://nodaro.ai/docs/nodes/publish/webhook-output), the social publishers such as [Publish to Social](https://nodaro.ai/docs/nodes/publish/publish-to-social), and the fetchers: [Web Scrape](https://nodaro.ai/docs/nodes/automate/web-scrape), [RSS Feed](https://nodaro.ai/docs/nodes/automate/rss-feed), [Telegram Channel Feed](https://nodaro.ai/docs/nodes/automate/telegram-channel-feed) and [Video URL](https://nodaro.ai/docs/nodes/automate/video-url). On those nodes, an override may not touch:

- any field whose name ends in `Url`;
- `target`, `targets`, `query`, `channel`, `chatId`, `connectionId`, `credentialId`, `platform`, `webhook`, `endpoint`, `host` or `privacy`;
- the `actor` and `mode` selectors that decide which destination field a fetcher reads.

The rule covers values nested in an object or a `fieldMappings` entry, and an empty value as well as a new address: blanking a destination would make the node read its upstream text instead. Such a request answers `400 locked_field` before anything runs. The error names up to ten of the offending fields and counts the rest, and an override nested more than 32 levels deep on such a node is refused outright.

Ordinary fields on those nodes, such as a caption or a limit, and the media `url` fields of input nodes, such as uploads and reference audio, stay overridable.

## Parameter corrections

The image and video generation routes accept one vocabulary for `aspectRatio`, `resolution` and `quality`, whatever the model. No model supports all of it. Instead of rejecting a value the chosen model does not accept, the server **corrects** it to one the model does accept and tells you what it changed. A rejection in the middle of a workflow would fail every node beside it that had already generated and been billed.

The routes that correct values are `POST /v1/generate-image`, `/v1/image-to-image`, `/v1/edit-image`, `/v1/text-to-video` and `/v1/generate-video`:

```json
{
"jobId": "0f1a9c2e-5b7d-4e8a-9c31-6d2f8b4a7e10",
"adjustments": [
{
"field": "aspectRatio",
"from": "3:2",
"to": "auto",
"reason": "GPT Image 2 does not support aspectRatio \"3:2\" — using \"auto\" instead. Supported: auto, 1:1, 16:9, 9:16, 4:3, 3:4."
},
{
"field": "resolution",
"from": "4K",
"to": "1K",
"reason": "GPT Image 2 only renders 1K at the \"auto\" aspect ratio."
}
]
}
```

- **`adjustments` is absent when nothing changed.** A valid request's response is exactly `{ "jobId": "…" }`.
- **`to` is absent when the model has no such setting** and the value was dropped, for example an `aspectRatio` sent to an upscaler.
- **Credits follow the corrected value.** A [GPT Image 2](https://nodaro.ai/docs/models/image/gpt-image-2) request for `auto` at `2K` renders and bills at 1K, because `auto` renders only 1K.
- **Saved workflows are corrected too.** A workflow saved through the API or MCP gets the same corrections when it is written.
- **Every model's accepted values** are in `GET /v1/models`. See [Discover models](https://nodaro.ai/docs/developers/api/nodes#discover-models).

### Corrections on the video routes

`/v1/text-to-video` and `/v1/generate-video` return `adjustments` in the same shape. `/v1/generate-video` also repeats each correction's `reason` in its `warnings` array, beside warnings that are not about parameters, such as `voice_unsupported_for_provider`. Read `adjustments` when you need to know which setting moved. The two never disagree.

- **An unsupported value snaps to the nearest option**, never to the cheapest or the first. A `4k` request on a model that stops at 1080p renders 1080p, not 480p, and a portrait `9:21` becomes `9:16`, not `16:9`.
- **An omitted `resolution` is sent at the band it is priced at.** When the platform declares a model's default band, you are billed for that band and the model is asked for it. The value appears in the job's `input_data`, so you can always see what was sent.
- **Spelling is normalized before pricing.** `4K` is read as `4k`, so it prices and renders the 4K band.
- **Some models render a fixed band for any other value.** [MiniMax Hailuo 3](https://nodaro.ai/docs/models/video/minimax-h3) renders 2K for anything that is not `768P`, and the [Wan 3.0](https://nodaro.ai/docs/models/video/wan-3-0) family renders 720p. For those, the correction targets the band the model will produce, so the price matches the render. The model's entry in `GET /v1/models` declares this in `unlistedResolutionRendersAs`.
- **`duration` is sent as you give it**, except on the [LTX 2.3](https://nodaro.ai/docs/models/video/ltx-2-3-pro) models. They are priced on a fixed ladder of durations per resolution, so a duration between two steps moves to the nearest one and is reported in `adjustments`.
- **`duration: -1` means Auto** on the models that support it, the [Seedance 2](https://nodaro.ai/docs/models/video/seedance-2) family (`autoDuration: true` in `GET /v1/models`). The model picks the clip length, which is the source clip's length when it edits a reference video. An Auto run reserves credits for the model's longest clip and refunds down to the length delivered. Other models ignore `-1` and render their default duration.

The character and location image routes (`/v1/generate-character`, `/v1/generate-character-asset`, `/v1/generate-location` and `/v1/generate-location-asset`) correct `quality` and `resolution` the same way, but do not return `adjustments`. The corrected value is visible only in the job's `input_data`, from `GET /v1/jobs/:id`.

## Manage workflows

### List and read

`GET /v1/projects/:projectId/workflows` returns a project's workflows without their `nodes`, `edges` and `settings`. `GET /v1/workflows/:id` returns one workflow in full. In an organization, a list follows the workspace you act in: see [Workspaces](https://nodaro.ai/docs/developers/api/workspaces).

**curl**

```bash
curl -s https://app.nodaro.ai/v1/projects/$PROJECT_ID/workflows \
  -H "Authorization: Bearer $NODARO_API_KEY" | jq '.data[] | {id, name}'
```

**TypeScript SDK**

```ts
const { data: workflows } = await client.workflows.list({ projectId })
const { data: workflow } = await client.workflows.get(workflows[0].id)
console.log(workflow.nodes.length)
```

**CLI**

```bash
nodaro workflows list --project $PROJECT_ID --json
nodaro workflows get 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f
```

### Create and update

Create a workflow in a project with `POST /v1/projects/:projectId/workflows`. Everything except the project is optional and falls back to the server's defaults:

```ts
const { data: wf } = await client.workflows.create({
projectId,
name: 'My workflow',
nodes: [],
edges: [],
})
```

`PATCH /v1/workflows/:id` changes any subset of fields. To make an update safe against a concurrent edit, send `expectedVersion`, the integer `version` from your last read. When the workflow changed since then, the update is refused with `409 workflow_conflict`, and the error carries `currentVersion`, `currentUpdatedAt` and `currentRecord`, the whole current workflow. Merge your change onto `currentRecord` and save again, with no extra read. `expectedUpdatedAt`, a timestamp, works the same way.

```ts

try {
await client.workflows.update(id, { name: 'Renamed', expectedVersion: 7 })
} catch (err) {
if (err instanceof WorkflowConflictError && err.currentRecord) {
await client.workflows.update(id, { name: 'Renamed', expectedVersion: err.currentVersion })
} else throw err
}
```

- Run-state values on a node's `data`, such as its execution status, current job and progress, are removed on save and never stored.
- `thumbnailUrl` sets the workflow's preview image from an image that is already hosted. `null` clears it.
- A save that sends only `edges` and wires a [Video Overlay](https://nodaro.ai/docs/nodes/video/video-overlay) layer rewrites that node, so it answers `409 workflow_conflict` when the workflow changed since it was read, even without `expectedVersion`.
- `visibility` is `private` or `workspace`. Only the creator or a workspace admin may change it, and a workflow outside a workspace answers `400 not_workspace_scoped`.

### Delete

`DELETE /v1/workflows/:id` deletes a workflow. The creator and a workspace admin may delete it, never a collaborator. Deleting a workflow that does not exist, or that you cannot see, answers `404`, so a delete is never a silent success. A collaborator who can see the workflow but may not delete it gets `403`.

### Export and import

`GET /v1/workflows/:id/export` returns the workflow as a portable JSON bundle. With `?assets=true`, the bundle also carries the characters, objects and locations the workflow uses, if they are yours. When nodes point at media another install cannot fetch, such as files on `localhost` or a private network, the bundle lists them in `portability.unreachableMedia`.

`POST /v1/workflows/import` creates a workflow from a bundle:

```http
POST /v1/workflows/import
{ "projectId": "<project uuid>", "workflow_json": { "version": 1, "name": "…", "nodes": [], "edges": [] } }
```

The import re-creates bundled characters, objects, creatures and locations under your account and re-points the nodes at them. It copies reachable media onto this install's storage: up to 25 files for the workflow's media and 25 more for the bundled entities, with images up to 20 MB and video or audio up to 50 MB. The response carries the new workflow and an `importReport`:

| Field | Meaning |
| --- | --- |
| `rehosted` | How many files were copied onto this install. |
| `unreachable` | Media on private hosts, left pointing where it was. Those nodes do not run until the file is uploaded again. |
| `skipped` | Media that could not be copied, with the reason, such as `HTTP 404`. |
| `assetIdMap` | Each bundled entity id, mapped to the row created for it. |
| `assetsSkipped` | Entities your storage quota had no room for. The workflow still lands. |

From the CLI: `nodaro workflows export <id> --with-assets --output bundle.json`, then `nodaro workflows import bundle.json --project <projectId>`. Read [Import and export](https://nodaro.ai/docs/guides/import-export) for what crosses over.

### Move to another project

```http
POST /v1/workflows/:id/move
{ "projectId": "…" }
```

A move is a workflow write, so an OAuth token needs `workflows:write`. `PATCH /v1/workflows/:id` with a `projectId` does the same thing and follows the same rules. You may move work you created. Inside an organization, a workspace admin may also move work between two workspaces they administer. A personal project must be yours on both sides.

| Status | Code | Meaning |
| --- | --- | --- |
| 400 | `validation_error` | The workflow is already in that project. |
| 403 | `not_permitted` | The workflow is not yours to move, or not yours to move there. |
| 404 | `not_found` | No such workflow, or no such project for you. |
| 409 | `move_blocked` | The work was created for an assignment. |
| 409 | `workspace_archived` | The target workspace is archived. |

A move that changes workspace removes the workflow's collaborator grants and reports them, so you can tell the people who lost access:

```json
{
"data": { "id": "8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f", "projectId": "5e4d3c2b-1a09-4f8e-8d7c-6b5a49382716" },
"droppedCollaborators": [{ "userId": "2b3c4d5e-6f70-4a81-92b3-c4d5e6f70812", "name": "Sam" }]
}
```

The `PATCH` form includes `droppedCollaborators` only when a grant was dropped.

## OAuth scopes for workflows

| Scope | Routes |
| --- | --- |
| `workflows:read` | `GET /v1/projects/:projectId/workflows`, `GET /v1/workflows`, `GET /v1/workflows/:id`, `GET /v1/workflows/:id/export` |
| `workflows:write` | Create, update, delete, import and move, and `POST /v1/workflows/:parentId/sub-workflows` |
| `workflows:execute` | `POST /v1/workflows/:id/run` |

Personal API tokens need no scopes. See [OAuth apps](https://nodaro.ai/docs/developers/oauth).

## Studio production nodes

Some canvas nodes belong to a Studio production and depend on linked frames. They must be generated through the Studio production API: running them through `POST /v1/workflows/:id/run`, or generating one directly, answers `400 sequence_execution_required`. See [Studio productions](https://nodaro.ai/docs/developers/api/studio-productions).

## Frequently asked questions

### How do I run a Nodaro workflow from the API?

Send POST /v1/workflows/:id/run. It runs the saved workflow and answers 202 with an executionId that you poll. To change input values for one run, use POST /v1/api/run with an inputs object and a personal API token.

### How do I pass input values to a workflow run?

Send an inputs object to POST /v1/api/run, keyed by node id or by a unique node label, with the node's field inside, for example text. GET /v1/api/schema lists the input fields of a workflow.

### Can I wait for a workflow result in a single request?

Yes. Add ?wait=true&timeout=120 to POST /v1/api/run. The server holds the connection for up to 600 seconds and returns the outputs, or answers 202 with the executionId when the run is still going, and you poll from there.

### Why did the API change the aspect ratio or resolution I sent?

The model you chose does not support that value, so Nodaro corrected it to the closest one the model accepts instead of failing the run. The response lists every change in adjustments, and credits are charged for the corrected value.

### Can an API run change where a workflow sends its results?

No. Destination fields on outbound nodes, such as the URL of a Webhook Output or the account of a social publisher, cannot be overridden by a run. A request that tries answers 400 locked_field.
