NodaroAccount
Nodaro Docs
DocumentationNode ReferenceModelsTemplatesAI Agents (MCP)DevelopersSelf-hostingResearchCompare
REST API

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

MethodPathWhat it does
POST/v1/workflows/:id/runRun the saved workflow, or some of its nodes. Answers 202 with an executionId.
POST/v1/workflows/:id/render-final/estimateQuote a Render final: the nodes it runs and its credits. Creates and charges nothing.
GET/v1/api/workflowsList 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/runRun a workflow with new input values. Add ?wait=true&timeout=… to wait for the result.
GET/v1/api/status/:execIdThe run's status, node counts, credits used and, once it completed, its outcome.
GET/v1/api/result/:execIdThe run's outputs, once its status is completed or failed.
POST/v1/app/:slug/runRun a published app with its form fields. See MiniApps.

Manage workflows

MethodPathWhat it does
GET/v1/projects/:projectId/workflowsThe workflows of one project, without their nodes and edges.
GET/v1/workflowsYour workflows across all projects.
GET/v1/workflows/:idOne workflow with its nodes, edges and settings.
POST/v1/projects/:projectId/workflowsCreate a workflow in a project.
PATCH/v1/workflows/:idChange any subset of a workflow's fields.
DELETE/v1/workflows/:idDelete a workflow.
GET/v1/workflows/:id/exportExport the workflow as a JSON bundle. Add ?assets=true to include its characters, objects and locations.
POST/v1/workflows/importCreate a workflow from a bundle.
POST/v1/workflows/:id/moveMove a workflow to another project.
GET/v1/workflows/shared-with-meWorkflows other people shared with you.
GET/v1/mcp-conversationsYour 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

EndpointCredentialInput valuesUse it when
POST /v1/workflows/:id/runAny 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/runA personal API tokeninputs 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/runAny tokenThe app's form fields, plus raw node overridesThe 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-1

Prop

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.

StatusCodeMeaning
402insufficient_creditsA continuation or a Render final that your credits cannot cover. Nothing was created.
403forbiddenYou may see the workflow but not run it. In a workspace, running needs edit access and active membership.
404not_foundNo such workflow, or none you can see.
409already_runningThe 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 inputOverrides of the earlier run are applied again. The inputOverrides you 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:

StatusCodeMeaning
400continuation_subset_requirednodeIds is missing or empty, without continueMode.
400continuation_workflow_mismatchThe earlier run ran another workflow.
400continuation_version_mismatchThe earlier run ran a published app version of the workflow. Continue a failed app run with the app's Retry.
400validation_errorcontinueFromExecutionId is not a run id.
404continuation_not_foundNo such run, or the run is not yours.
409continuation_not_completedThe 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. nodeIds is 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 nodeIds alone, without continueMode. 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. The 202 answer names the run it continued from in continuedFrom.
  • 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 in executionId, its renders in renderNodeIds and its Tighten Episode nodes in reviewNodeIds. Its next field is the run that finishes them: send next as the body of POST /v1/workflows/:id/run.
  • The balance is checked first. A continuation that your credits cannot cover is refused with 402 insufficient_credits before 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-Key header. 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:

StatusCodeMeaning
409continuation_not_failedThe run did not fail.
409continuation_supersededA newer continuation of the run failed (continue it instead), or completed everything that was left. It is named in executionId and supersededBy: { executionId, status }.
409continuation_preview_pendingThe newest continuation stopped at a Preview. Send its next first.
409continuation_subset_requiredNothing 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.
409already_runningA 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 } }:

  • nodeIds and inputOverrides are the nodes the run executes and the override it runs with, { "<render node id>": { "quality": "final" } }.
  • estimatedCredits is the price of the nodes the run executes, with the render at Final. When the run executes an LLM node, the answer adds estimatedCreditsIsEstimate: true: that node is charged its actual usage, so show the figure as ≈N.
  • holdCredits is 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. holdCredits is never below estimatedCredits.
  • sufficient says whether your credits cover that hold, and available is your spendable credits. available is null when a workspace budget or a deployment's billing account pays.
  • All three figures are null on an edition without credits.

The quote refuses with the continuation codes above. Both routes also refuse with these codes:

StatusCodeMeaning
400render_final_node_not_foundThe workflow has no node with that id.
400render_final_not_a_renderThe node is not an Apply EDL render.
400validation_errorrenderFinal 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.

promptTexttext-prompt-1Generate Imagegenerate-image-1
The example workflow: a Text node, whose text the API run replaces, feeds a Generate Image node.

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 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.

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 Url or Urls;
  • 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.

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."
    }
  ]
}
  • 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.5 Flare request for 9:8 at 2K renders and bills at 1K, because GPT Image 2.5 renders 9:8 at 1K only.
  • aspectRatio: "auto" keeps your photo's shape on /v1/image-to-image and /v1/edit-image. A model with its own auto that keeps the shape receives it as it is. On a model without one, the server reads the size of imageUrl, uses the closest ratio the model supports and reports the choice in adjustments. GPT Image 2.6 (gpt-image-2-6-*) is treated the same way, because its own auto renders a portrait photo square. For example, a 1104x1472 portrait sent to seedream-5-pro-i2i renders at 3:4, and a 768x1024 one sent to gpt-image-2-6-flare-i2i also renders at 3:4. When the size cannot be read, auto falls back to the model's first listed ratio, or to the model's own auto on GPT Image 2.6. The ratio never changes the price on these models. auto stays a choice on every model that lists it: only what is sent changes.
  • /v1/generate-image does 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 from connectedReferences). Without a photo, auto on a model without its own auto gives the model's first listed ratio, and GPT Image 2.6 receives its own auto.
  • No aspectRatio on some edit models means auto. On /v1/image-to-image, a request to gpt-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-i2i or seedream-5-5-i2i that 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 own auto, which keeps the photo's shape. MiniMax H3 Image receives its own auto, which follows the first image, and Seedream 5.5 receives its own auto. 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.
  • outputFormat is corrected the same way, on the models that list outputFormats in GET /v1/models. The values are png (the default), jpeg, webp, gif and svg. The settings that depend on the format follow it:
    • GIF: 2 to 16 square frames of 512×512. aspectRatio becomes 1:1 and resolution is dropped, and each change is reported, except an auto ratio. gifFrames (2 to 16, default 9) and gifFrameDurationMs (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, detailed or max) is corrected the same way. A background other than auto becomes auto, because an SVG takes no background setting.
    • Background: a transparent background is kept only with png and webp. With gif or jpeg it becomes auto, 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 maskUrl with gif or svg is a 400, 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_data names the stored file's type in imageMimeType (image/jpeg, image/webp, image/gif or image/svg+xml), with its width and height. A GIF or an SVG also gets a PNG still in thumbnailUrl. An SVG is cleaned before it is stored. One that cannot be read safely fails the job, and the job is refunded.
  • maskUrl on /v1/image-to-image is the model's own inpainting mask: white is the area to change, and black is the area to keep. It applies on ideogram-edit, flux-fill, gpt-image-2-6-flare-i2i and gpt-image-2-6-sunburst-i2i, the models whose GET /v1/models entry lists the mask feature. 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's output_data.maskComposited is true.
    • 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 aspectRatio becomes auto, the supported ratio nearest the photo. resolution becomes the size (1K, 2K or 4K) whose long side is nearest the photo's. The run is priced at that size, and both changes come back in adjustments. When the photo's size cannot be read, the resolution you 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.
  • Saved workflows are corrected too. A workflow saved through the API or MCP gets the same corrections when it is written. The exception is auto on 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 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 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 in GET /v1/models declares this in unlistedResolutionRendersAs.
  • Duration is billed for the length the model renders. A request without duration renders, and is charged for, the model's own default length, defaultDuration in GET /v1/models. A duration the model does not offer moves to the nearest length it does, and a tie goes to the shorter one. The change is reported in adjustments. 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: -1 means Auto on the models that support it, the 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. 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 declares referenceTaskType: "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 -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.

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-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:

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.
  • 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 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. 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:

FieldMeaning
rehostedHow many files were copied onto this install.
unreachableMedia on private hosts, left pointing where it was. Those nodes do not run until the file is uploaded again.
skippedMedia that could not be copied, with the reason, such as HTTP 404.
assetIdMapEach bundled entity id, mapped to the row created for it.
assetsSkippedEntities 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,chatgpt

This 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.

QueryMeaning
monthYYYY-MM. The default is the latest month that has conversations.
sourcesThe 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 one claude-code source. connections lists them.
  • data: the conversations. Each conversation has:
    • id and projectId. A conversation is a workflow, so you open, rename, export, copy or delete it with the workflow routes.
    • name, client, clientLabel and clientName.
    • startedAt, lastActivityAt and live. live is true while the last step is less than 30 minutes old.
    • firstRequest: your first request, when the assistant passed it.
    • nodeCount, and counts per kind: image, video and audio.
    • credits: the credits charged for its jobs, or null where 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.

StatusCodeMeaning
400validation_errorThe workflow is already in that project.
403not_permittedThe workflow is not yours to move, or not yours to move there.
404not_foundNo such workflow, or no such project for you.
409move_blockedThe work was created for an assignment.
409workspace_archivedThe 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

ScopeRoutes
workflows:readGET /v1/projects/:projectId/workflows, GET /v1/workflows, GET /v1/workflows/:id, GET /v1/workflows/:id/export, GET /v1/mcp-conversations
workflows:writeCreate, update, delete, import and move, and POST /v1/workflows/:parentId/sub-workflows
workflows:executePOST /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.

Last updated on

On this page