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.
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. |
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.
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 -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 '{}'{ "executionId": "3f9e2b1a-7c6d-4e5f-8a9b-0c1d2e3f4a5b", "status": "pending" }const { executionId } = await client.workflows.run(workflowId)
// Or run only some nodes:
await client.workflows.run(workflowId, { nodeIds: ['text-prompt-1', 'generate-image-1'] })nodaro workflows run 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f --watch
nodaro workflows run 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2f --node text-prompt-1 generate-image-1Prop
Type
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. 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.
The example below runs a workflow with a Text node, id text-prompt-1, wired into a 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:
curl -s "https://app.nodaro.ai/v1/api/schema?workflowId=$WORKFLOW_ID" \
-H "Authorization: Bearer $NODARO_API_KEY"{
"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
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" }
}
}'{ "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.
{
"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:
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 .// 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="…".
Prop
Type
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.
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:
POST /v1/api/run?wait=true&timeout=120- The server checks the run every 5 seconds for up to
timeoutseconds. 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. Itsstatusiscompleted,failed,cancelled,timed_outordiscarded. - If it does not, the response is
202with{ 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.
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, the social publishers such as Publish to Social, and the fetchers: Web Scrape, RSS Feed, Telegram Channel Feed and 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,hostorprivacy;- the
actorandmodeselectors 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:
{
"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."
}
]
}adjustmentsis absent when nothing changed. A valid request's response is exactly{ "jobId": "…" }.tois absent when the model has no such setting and the value was dropped, for example anaspectRatiosent to an upscaler.- Credits follow the corrected value. A GPT Image 2 request for
autoat2Krenders and bills at 1K, becauseautorenders 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.
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
4krequest on a model that stops at 1080p renders 1080p, not 480p, and a portrait9:21becomes9:16, not16:9. - An omitted
resolutionis 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'sinput_data, so you can always see what was sent. - Spelling is normalized before pricing.
4Kis read as4k, so it prices and renders the 4K band. - Some models render a fixed band for any other value. MiniMax Hailuo 3 renders 2K for anything that is not
768P, and the 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 inGET /v1/modelsdeclares this inunlistedResolutionRendersAs. durationis sent as you give it, except on the LTX 2.3 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 inadjustments.duration: -1means Auto on the models that support it, the Seedance 2 family (autoDuration: trueinGET /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-1and 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.
curl -s https://app.nodaro.ai/v1/projects/$PROJECT_ID/workflows \
-H "Authorization: Bearer $NODARO_API_KEY" | jq '.data[] | {id, name}'const { data: workflows } = await client.workflows.list({ projectId })
const { data: workflow } = await client.workflows.get(workflows[0].id)
console.log(workflow.nodes.length)nodaro workflows list --project $PROJECT_ID --json
nodaro workflows get 8c2d7f1e-3a4b-4c5d-9e6f-7a8b9c0d1e2fCreate 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:
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.
import { WorkflowConflictError } from '@nodaro/sdk'
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. thumbnailUrlsets the workflow's preview image from an image that is already hosted.nullclears it.- A save that sends only
edgesand wires a Video Overlay layer rewrites that node, so it answers409 workflow_conflictwhen the workflow changed since it was read, even withoutexpectedVersion. visibilityisprivateorworkspace. Only the creator or a workspace admin may change it, and a workflow outside a workspace answers400 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:
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 for what crosses over.
Move to another project
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:
{
"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.
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.
Frequently asked questions
Related
Executions
Nodes
Embed a MiniApp
Running workflows
Webhooks
Last updated on
Authentication
Authenticate Nodaro API calls with a personal API token, an OAuth app token or a session JWT, and create, scope, bind and revoke your API tokens.
Nodes
Run any Nodaro node with POST /v1/<node-type>, discover nodes, models and picker values, and steer prompts with references and cinematic direction ids.