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. |
POST | /v1/workflows/:id/render-final/estimate | Quote a Render final: the nodes it runs and its credits. Creates and charges nothing. |
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, credits used and, once it completed, its outcome. |
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. |
GET | /v1/mcp-conversations | Your MCP conversations of one month. See MCP conversations. |
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. A flat input whose field is a number, such as an exposed slider, takes a number or a numeric string: "0.4" arrives at the node as 0.4. A value that is not a number is passed on unchanged, and the node treats it as it always has.
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 | A continuation or a Render final that your credits cannot cover. Nothing was created. |
| 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. |
An ordinary run does not check your balance before it starts. When your credits cannot cover a node, that node fails with an insufficient-credits error, and the run reports the failure.
Continue a run
POST /v1/workflows/:id/run can continue an earlier run instead of starting over. Send continueFromExecutionId with the id of that run, together with nodeIds. The run executes only nodeIds. Every other node hands on what the earlier run produced, as that run recorded it, never the workflow's saved results. The inputs that run used, such as an upload or an app input, are used again, and nothing before nodeIds runs or is billed again.
- A node that the earlier run did not run either hands on its saved results, as it did in that run.
- An Edit Plan hands on the plan that the earlier run made, with your current review of it applied. A review made on another plan is ignored.
- A node that the earlier run did not complete hands on nothing and does not run again. A node whose every input comes from such nodes is skipped, with the nodes after it.
- The
inputOverridesof the earlier run are applied again. TheinputOverridesyou send apply over them, field by field, and yours win.
A Render final written by hand is one example. It runs the render and every node after it, with the render set to Final for this run only:
{
"nodeIds": ["<render node id>", "<every node after it>"],
"inputOverrides": { "<render node id>": { "quality": "final" } },
"continueFromExecutionId": "<a completed run whose render made a Preview>"
}The earlier run must be a run you started, of this workflow itself and not of a published app version, and it must have ended completed. Otherwise the request is refused before any run exists:
| Status | Code | Meaning |
|---|---|---|
| 400 | continuation_subset_required | nodeIds is missing or empty, without continueMode. |
| 400 | continuation_workflow_mismatch | The earlier run ran another workflow. |
| 400 | continuation_version_mismatch | The earlier run ran a published app version of the workflow. Continue a failed app run with the app's Retry. |
| 400 | validation_error | continueFromExecutionId is not a run id. |
| 404 | continuation_not_found | No such run, or the run is not yours. |
| 409 | continuation_not_completed | The earlier run is still going, failed or was cancelled. |
Continue a failed run
A run of a saved workflow that failed can be continued from where it stopped, keeping what it completed. Send continueFromExecutionId with the failed run's id and "continueMode": "from-failed":
{
"continueFromExecutionId": "<the run that failed>",
"continueMode": "from-failed"
}- Only what is left runs. Every node the failed run completed hands on its result, and is not run or charged again. A node that failed runs again in full, every list item included.
- The nodes it considers.
nodeIdsis optional. Without it, the run is continued on the nodes it was started on: a run of named nodes on those nodes, a full run on every node. A continuation of a continuation keeps the nodes of the first run. Every node left failed or held back is added to them, with what those nodes need upstream. - Every node you name counts. With
nodeIds, a node that no run of the chain ever ran counts as left, so it runs and is priced. Naming every node therefore also runs each node of work that every run of the chain left out. A node that ran in any run of the chain is never run again by naming it. - Nodes that never run. A source or upload, a trigger, a picker, or a Group or Collect container hands on what the run handed on. A node set to skip, in the workflow or by the run's
inputOverrides, never runs. Naming only such nodes leaves nothing to run. - Chains with older continuations. A continuation that ran before this rule existed recorded such a node as completed. In that chain, naming the node still counts it as done, so it does not run. To run it, continue the chain's newest run with
nodeIdsalone, withoutcontinueMode. This works only when that run completed. When it failed, first continue from the failure without that node. - The newest continuation decides. A continuation holds everything the runs before it completed, so the server always builds on the newest continuation of the run. When that continuation failed, the request is refused with
continuation_superseded, which names it: continue that one instead. When it already ran everything that was left, the same code says the work is done. When it completed only part of what was left, the run is built on it. The202answer names the run it continued from incontinuedFrom. - A continuation that stopped at a Preview. Continuing again would stop at the same Preview, so the request is refused with
continuation_preview_pending. The answer names that continuation inexecutionId, its renders inrenderNodeIdsand its Tighten Episode nodes inreviewNodeIds. Itsnextfield is the run that finishes them: sendnextas the body ofPOST /v1/workflows/:id/run. - The balance is checked first. A continuation that your credits cannot cover is refused with
402 insufficient_creditsbefore it starts. It is priced on what it still runs. A node the failed run completed is never priced. A node you name that no run of its chain ever ran is priced, because it runs. - Send an
Idempotency-Keyheader. Two continuations sent at the same moment can both start. With the same key, a retry of the request answers the run it already started, with"deduped": true.
A continue-from-failed is refused with these codes, in addition to the ones above:
| Status | Code | Meaning |
|---|---|---|
| 409 | continuation_not_failed | The run did not fail. |
| 409 | continuation_superseded | A newer continuation of the run failed (continue it instead), or completed everything that was left. It is named in executionId and supersededBy: { executionId, status }. |
| 409 | continuation_preview_pending | The newest continuation stopped at a Preview. Send its next first. |
| 409 | continuation_subset_required | Nothing is left to continue, or none of the nodes the first run was started on is still in the workflow. Name the nodes with nodeIds, or start a new run. |
| 409 | already_running | A run of this workflow, a continuation included, is still going. Its executionId is in the answer. |
A run of a published app is not continued here: use the app's Retry. In the editor, the same continuation is Continue from failed, described in Running workflows.
Render final
Instead of listing the nodes yourself, name the render and let the server work out the rest. Send renderFinal with the render's node id and continueFromExecutionId, and no nodeIds or inputOverrides. The server runs what Render final runs in the editor: the Apply EDL render at Final for this run only, then every node after it. When a Camera Switch sits between the Edit Plan and the render, Camera Switch runs again first. The Edit Plan does not run again. The run is then a continuation, with the same checks.
{
"renderFinal": { "renderNodeId": "<render node id>" },
"continueFromExecutionId": "<a completed run whose render made a Preview>"
}Quote it first with POST /v1/workflows/:id/render-final/estimate and a body of { renderNodeId, continueFromExecutionId }. The quote creates and charges nothing. It answers 200 with { data: { renderNodeId, nodeIds, inputOverrides, estimatedCredits, holdCredits, sufficient, available } }:
nodeIdsandinputOverridesare the nodes the run executes and the override it runs with,{ "<render node id>": { "quality": "final" } }.estimatedCreditsis the price of the nodes the run executes, with the render at Final. When the run executes an LLM node, the answer addsestimatedCreditsIsEstimate: true: that node is charged its actual usage, so show the figure as≈N.holdCreditsis what the run holds while it renders. A UGC clip is held at about twice its price, and the part not used comes back when it finishes.holdCreditsis never belowestimatedCredits.sufficientsays whether your credits cover that hold, andavailableis your spendable credits.availableisnullwhen a workspace budget or a deployment's billing account pays.- All three figures are
nullon an edition without credits.
The quote refuses with the continuation codes above. Both routes also refuse with these codes:
| Status | Code | Meaning |
|---|---|---|
| 400 | render_final_node_not_found | The workflow has no node with that id. |
| 400 | render_final_not_a_render | The node is not an Apply EDL render. |
| 400 | validation_error | renderFinal was sent with nodeIds or inputOverrides, or without continueFromExecutionId. |
A Render final checks your balance before it starts, against the quote's holdCredits. A run you cannot cover answers 402 insufficient_credits with required, which is that hold, and available, and no run is created. The check covers the balance only. A model your plan cannot use, or a daily limit, is still refused by that node when the run reaches it.
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": 50,
"inputs": [
{ "nodeId": "text-prompt-1", "key": "text", "label": "Prompt", "type": "text" }
],
"outputs": [
{ "nodeId": "generate-image-1", "label": "Generate Image", "type": "image" }
]
}When the workflow holds a metered LLM node, the answer adds estimatedCreditsIsEstimate: true, and estimatedCredits is an estimate: show it as ≈N. A workflow that names an LLM model with no price yet is still described. estimatedCredits is then left out, and estimatedCreditsUnpriced: { reason: "llm_model_unpriced", modelId, message } says why. Its run is refused. See A model with no price yet.
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",
"outcome": "succeeded",
"creditsUsed": 50,
"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.
Runs that find nothing new
A scheduled run often has nothing to do, because the feed it reads has no new posts. Such a run ends completed, not failed:
- A node that sends text to a model or a voice is skipped when its text is empty and that text came from a node that produced nothing in this run. The nodes after it are skipped too. These nodes are Generate Text, Generate Script, Text to Speech, Generate Music and Text to Audio.
- An output node that sends what is wired into it, such as Webhook Output and the social publishers, is skipped when every one of its connections carried nothing in this run.
- Nothing is billed for a skipped node. A Telegram Channel Feed that returns no new posts is not charged.
GET /v1/api/status/:execId, GET /v1/api/result/:execId and the ?wait=true answer carry "outcome": "nothing_new" for such a run. Every other completed run says "succeeded". A failed or cancelled run has no outcome, only its status. errorMessage stays null, because nothing went wrong. In GET /v1/workflow-executions/:id, each skipped node reads { "status": "skipped", "skipReason": "empty_input" }.
A node with a typed prompt still runs when its connection is empty, because its prompt is the request. A node with no connection at all still fails with its usual validation error. Only a connection that carried nothing in this run counts.
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, such as Web Scrape, Telegram Channel Feed and Video URL. On those nodes, an override may not touch:
- any field whose name ends in
UrlorUrls; 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.
A run also cannot change what a Sub-Workflow node runs. Its workflowId, selectedRouteId and routeSnapshot answer 400 locked_field, whether you set them directly or through a fieldMappings entry. Which workflow the node runs, and which part of it, is decided by the workflow itself.
On a UGC video node, a run can set only UGC Creator's source, gender and photoUrl, and UGC Script's targetDurationSec. Any other field, such as a kept result, a script or a clip, answers 400 locked_field. A run of a UGC Creator set to My photo that gives no photo of the runner's own answers 400 ugc_photo_required before anything is charged. See UGC Creator and A UGC video in an app.
On a published app, an exposed model or quick setting whose choices are a fixed list is held to that list: a value outside it answers 400 validation_error, and so does a number outside a setting's range. A null in inputOverrides clears that field of the node for the run, so the node's own default applies. A null flat input is dropped instead, so the saved value applies. See Model and quick-setting fields.
Parameter corrections
The image and video generation routes accept one vocabulary for aspectRatio, resolution and quality, whatever the model. The image routes also accept background, outputFormat, gifFrames, gifFrameDurationMs and svgLevel in it. 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."
}
]
}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.5 Flare request for
9:8at2Krenders and bills at 1K, because GPT Image 2.5 renders9:8at 1K only. aspectRatio: "auto"keeps your photo's shape on/v1/image-to-imageand/v1/edit-image. A model with its ownautothat keeps the shape receives it as it is. On a model without one, the server reads the size ofimageUrl, uses the closest ratio the model supports and reports the choice inadjustments. GPT Image 2.6 (gpt-image-2-6-*) is treated the same way, because its ownautorenders a portrait photo square. For example, a 1104x1472 portrait sent toseedream-5-pro-i2irenders at3:4, and a 768x1024 one sent togpt-image-2-6-flare-i2ialso renders at3:4. When the size cannot be read,autofalls back to the model's first listed ratio, or to the model's ownautoon GPT Image 2.6. The ratio never changes the price on these models.autostays a choice on every model that lists it: only what is sent changes./v1/generate-imagedoes the same when a photo is connected. The photo is the first image the request sends the model:baseImageUrl(inpaint or refine) when set, else the first reference image (referenceImageUrls, or the first one built fromconnectedReferences). Without a photo,autoon a model without its ownautogives the model's first listed ratio, and GPT Image 2.6 receives its ownauto.- No
aspectRatioon some edit models meansauto. On/v1/image-to-image, a request togpt-image-2-6-flare-i2i(the default),gpt-image-2-6-sunburst-i2i,gpt-image-2-6-economy-i2i,gpt-image-2-5-flare-i2i,gpt-image-2-5-sunburst-i2i,gpt-image-2-i2i,minimax-h3-image-i2iorseedream-5-5-i2ithat names no ratio runs as one that sends"auto". It does not use the model's 16:9 default. GPT Image 2 and 2.5 receive their ownauto, which keeps the photo's shape. MiniMax H3 Image receives its ownauto, which follows the first image, and Seedream 5.5 receives its ownauto. GPT Image 2.6 receives the ratio closest to the photo, as above. The job records the ratio that was sent, and the price is the same, because these models price by quality, size and the number of input images. Every other model keeps its own default when no ratio is named. outputFormatis corrected the same way, on the models that listoutputFormatsinGET /v1/models. The values arepng(the default),jpeg,webp,gifandsvg. The settings that depend on the format follow it:- GIF: 2 to 16 square frames of 512×512.
aspectRatiobecomes1:1andresolutionis dropped, and each change is reported, except anautoratio.gifFrames(2 to 16, default 9) andgifFrameDurationMs(20 to 2,000 ms in steps of 10, default 200) are moved into their range and onto the step. When you leave them out, the job records the defaults. - SVG:
svgLevel(standard, the default,detailedormax) is corrected the same way. Abackgroundother thanautobecomesauto, because an SVG takes no background setting. - Background: a
transparentbackground is kept only withpngandwebp. Withgiforjpegit becomesauto, reported, and the size is not moved to 1K. - Other formats: a format the model does not write moves to the model's first format, and a model with no formats drops the field. GIF or SVG settings sent with another format are ignored without an adjustment.
- Masks: a
maskUrlwithgiforsvgis a400, before anything is reserved. - Price: PNG, JPEG and WebP share one price. A GIF is priced by its quality and its band of frames (2–8, 9–12 or 13–16), and an SVG as the PNG at its size plus its level. See Generate Image formats.
- Result: the job's
output_datanames the stored file's type inimageMimeType(image/jpeg,image/webp,image/giforimage/svg+xml), with itswidthandheight. A GIF or an SVG also gets a PNG still inthumbnailUrl. An SVG is cleaned before it is stored. One that cannot be read safely fails the job, and the job is refunded.
- GIF: 2 to 16 square frames of 512×512.
maskUrlon/v1/image-to-imageis the model's own inpainting mask: white is the area to change, and black is the area to keep. It applies onideogram-edit,flux-fill,gpt-image-2-6-flare-i2iandgpt-image-2-6-sunburst-i2i, the models whoseGET /v1/modelsentry lists themaskfeature. Other models ignore it.- GPT Image 2.6 keeps the rest of the picture. The model redraws the whole picture, so the source (
imageUrl) is pasted back everywhere outside the mask, with a soft edge a few pixels wide inside the painted area. Grey in the mask is a partial change, so a soft edge stays soft. The job'soutput_data.maskCompositedistrue. - The result is the size of
imageUrl, in the format you asked for. A PNG keeps the source's own pixels outside the mask. A JPEG or WebP is encoded once more. - On GPT Image 2.6 the size and shape follow the photo. Any
aspectRatiobecomesauto, the supported ratio nearest the photo.resolutionbecomes the size (1K,2Kor4K) whose long side is nearest the photo's. The run is priced at that size, and both changes come back inadjustments. When the photo's size cannot be read, theresolutionyou asked for is kept. - A mask costs nothing extra. The run is priced as the same edit without a mask at that size, and the mask does not count as an input image.
- GPT Image 2.6 keeps the rest of the picture. The model redraws the whole picture, so the source (
- Saved workflows are corrected too. A workflow saved through the API or MCP gets the same corrections when it is written. The exception is
autoon a generate-image, image-to-image, modify-image or edit-image node: it is kept and resolved against the photo when the workflow runs. - 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 H3 renders 2K for anything that is not
768P, the Wan 3.0 family and PixVerse V6 Max render 720p, and Grok Imagine 1.5 renders 480p. 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. - Duration is billed for the length the model renders. A request without
durationrenders, and is charged for, the model's own default length,defaultDurationinGET /v1/models. Adurationthe model does not offer moves to the nearest length it does, and a tie goes to the shorter one. The change is reported inadjustments. For example, a 7-second request on Kling renders and bills 5 seconds, and on an LTX 2.3 model it renders and bills 6 seconds. 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. With measured reference videos, it reserves for the longest reference video's length instead, rounded up to a whole second plus one second, and never above the model's longest clip, because the model may edit any of them. A Seedance 2.5 request that declaresreferenceTaskType: "reference"with an Auto length is reserved at the longest output the model can make, and charged for what it makes. See Seedance 2.5 reference task type. 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. A reader with view access receives the workflow without the owner's Studio drafts: the empty media slots, the markers of runs in progress and the voice plans of the takes. The bundle then carries no asset that only those parts use. GET /v1/workflows/:id leaves out the same parts for a reader with view access.
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.
MCP conversations
GET /v1/mcp-conversations?month=2026-10&sources=claude,chatgptThis route lists your MCP conversations of one month, newest first. Each conversation is a workflow that an assistant built from its steps. See MCP conversations become workflows. An OAuth token needs workflows:read.
| Query | Meaning |
|---|---|
month | YYYY-MM. The default is the latest month that has conversations. |
sources | The sources to keep, separated by commas: claude, claude-code, chatgpt, or another client's name in lower case. The default is every source. |
The response holds:
month: the month listed.months: every month that has conversations, newest first, with its count.sources: the sources of the listed month, with their counts. A source joins the connections of one product: "Claude Code (nodaro)" and "Claude Code (staging)" are oneclaude-codesource.connectionslists them.data: the conversations. Each conversation has:idandprojectId. A conversation is a workflow, so you open, rename, export, copy or delete it with the workflow routes.name,client,clientLabelandclientName.startedAt,lastActivityAtandlive.liveistruewhile the last step is less than 30 minutes old.firstRequest: your first request, when the assistant passed it.nodeCount, andcountsper kind:image,videoandaudio.credits: the credits charged for its jobs, ornullwhere credits are not billed.cover:{ url, kind, setByUser }. It is the cover you set, or else the latest result.
DELETE /v1/workflows/:id on a conversation deletes its workflow. The jobs the assistant made are not filed in the workflow, so their images and videos stay in your library. Runs started in the editor are deleted with it, as for any workflow. The hub workflow of a month cannot be deleted: the route answers 409 mcp_hub. A personal API key limited to some workflows cannot list conversations, and gets 403.
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.
Both forms return the moved workflow in data, as it now stands. When your own access to it after the move is below edit, you receive it as a view reader does. That happens, for example, when a workspace admin moves a member's work into a workspace where admins can only view work. See Workspaces and organizations.
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, GET /v1/mcp-conversations |
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
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, add an inputOverrides object keyed by node id, or use POST /v1/api/run with an inputs object.
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.
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.