API Integration

This guide is for developers building server-to-server automations that talk to Nodaro on behalf of their own Nodaro account — cron jobs, internal tools, scripts, CI pipelines, backend services. If instead you’re building a hosted product that authenticates other users’ Nodaro accounts, you want OAuth — see OAuth Flow.

The mechanism here is API tokens: long-lived bearer tokens minted from the Nodaro UI, scoped to your own account, optionally locked to specific workflows. They are simpler than OAuth and require no consent screen.

1. API tokens vs OAuth

You are… Use Token format
Scripting your own Nodaro account from your server API token ndr_<64hex>
Building a product that runs other users’ workflows OAuth ndr_app_<64hex>
Self-hosting Community edition for personal use Supabase JWT directly eyJ… (JWT)

Quick test: if your server only ever needs one set of credentials and there is no consent screen involved, use an API token. If you need 500 customers to each grant your app access to their own account, use OAuth.

API tokens are currently gated to Business and Cloud editions in the Nodaro UI. If you’re running Community edition for yourself, you can call the same /v1/* endpoints using your Supabase user JWT (Authorization: Bearer <jwt>) and skip the API-token layer entirely.

2. Creating an API token

  1. Sign in to your Nodaro instance.
  2. Go to Settings → API. On a deployment-payer instance (one where a single billing account funds every user) that card is not shown in Settings — open /settings/api directly by URL, signed in as the billing account.
  3. Click Create token.
  4. Fill in:
    • Name — a label for your records (e.g. prod-scheduler).
    • Workflow scope (optional) — pick specific workflows the token can trigger. If empty, the token can run any workflow you own.
    • Rate limit — requests per minute, default 30, max 120.
  5. Save. The full token is shown once. It will look like:

    ndr_<64 hex characters>
    

    Copy it into your secret store immediately. Nodaro only stores a SHA-256 hash, so if you lose it you must mint a new one.

You can have up to 10 tokens per account, active or not — a deactivated token still counts; delete it to free the slot. The eleventh create is refused with 400 limit_reached. Edit name, workflow scope, rate limit, or active flag at any time. Deleting a token revokes it instantly.

A personal token never expires and carries no spend cap: it lives until you deactivate or delete it, and it is not re-checked against your sign-in provider — a token minted before an account was removed from the identity provider keeps working until it is revoked. Treat it as a permanent credential and store it accordingly.

On a deployment-payer instance — where one billing account funds every user — only the billing account can create a token (403 api_tokens_payer_only for anyone else), every call the token makes debits the deployment’s pool, and the token can never administer that pool: balance reads made as the billing account answer 403 payer_balance_jwt_only (§3) and the billing account’s own routes (/v1/deployment-billing/*) answer 403 payer_required — those need the billing account’s browser session.

Backend reference: POST /v1/api-tokens (JWT-authenticated, body { name, workflowIds[], rateLimit }). See backend/src/routes/api-tokens.ts.

Using a token to run a self-hosted or local instance on a hosted one. A personal token is also the simplest relay credential: on the self-hosted instance set NODARO_CLOUD_URL to the hosted instance and NODARO_API_KEY to the token, and every generation runs on the hosted engine and is billed to the token’s account. No OAuth connection is needed for this path. On a deployment-payer instance the billing account is the one that creates that token. Details in Connect your instance to Nodaro Cloud.

3. Public API endpoints

Personal API tokens (Authorization: Bearer ndr_…) authenticate every authenticated route in the backend, including the published-app endpoints under /v1/app/:slug/* (see the Embed App Guide) and the per-feature routes (jobs, workflows, projects, etc.).

A few surfaces are deliberately app-only and reject API tokens and OAuth app tokens with 403 in_app_only — currently the Workflow Copilot (/v1/copilot/*) and the admin panel’s message-a-user send. They exist for the Nodaro web app’s own session, not as an integration surface. To build workflows programmatically, use MCP or the SDK.

One surface is app-only conditionally: on a deployment-payer instance — where a single billing account funds every user — the credit-balance reads (GET /v1/credits/balance, GET /v1/user/credits, GET /v1/credits/check) answer 403 payer_balance_jwt_only when the caller authenticates as the billing account itself with an API token or an OAuth app token. That pool is the operator’s, readable only from that account’s own browser session. Every other identity, and every instance without a deployment payer, is unaffected.

The five legacy endpoints below are scoped specifically to running workflows by ID with input overrides — they live under /v1/api/ and predate the published-app system. Most new integrations should prefer /v1/app/:slug/run instead, but these remain supported.

POST /v1/app/:slug/run takes the app’s exposed fields as flat inputs, plus an optional inputOverrides — nested { nodeId: { field: value } } raw node overrides for THIS run. The two are merged, not either/or: the nested overrides are applied OVER the flat inputs, per node and per field, so inputOverrides wins on any field both set and reaches fields no app input exposes (such as promptPrefix; see Prompt pre & post text).

Method Path Purpose
GET /v1/api/workflows List workflows your token can run. Supports ?limit= and ?cursor= pagination.
GET /v1/api/schema?workflowId=… Inspect a workflow’s input fields and output handles before running it. Includes estimatedCredits.
POST /v1/api/run Execute a workflow. Optionally pass inputs to override input-node values. Supports ?wait=true&timeout=… for sync mode.
GET /v1/api/status/:execId Poll a running execution. Returns status, progress counts, and credits used.
GET /v1/api/result/:execId Fetch the final outputs once status is completed or failed.

All responses use the same envelope: success returns the payload directly (or under data), errors return { error: { code, message } }. See §8 Errors for status codes.

The full route handler is at backend/src/routes/api-tokens.ts.

Moving a workflow between projects

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

Authorized by workflows:write — a move is a workflow write, not a permission of its own. PATCH /v1/workflows/:id with a projectId does the same thing and is decided by the same rule; it remains supported.

You may move work you created. Inside an organization a workspace admin may also move work between two workspaces they administer — both sides, not one. A personal project must be your own on both sides: owning the workflow is not enough to file it in somebody else’s project.

Status Code Meaning
400 validation_error The workflow is already in that project
403 not_permitted 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 clears the workflow’s collaborator grants and reports them, so you can see what the move cost. Both forms do this; the PATCH form includes the field only when something was actually dropped, so an ordinary save keeps the response shape it has always had:

{
  "data": { "id": "…", "projectId": "…" },
  "droppedCollaborators": [{ "userId": "…", "name": "Sam" }]
}

Those grants are the ones described under workflow collaborators: a move can carry work out of the reach of somebody who was sharing it, and the response names who so you can tell them.

OAuth scope note: the workflows:read scope also gates the broader workflow REST routes: GET /v1/workflows (flat list across all projects), GET /v1/workflows/:id, and GET /v1/workflows/:id/export — in addition to the project-scoped GET /v1/projects/:projectId/workflows. If your OAuth token will call any of these, request workflows:read in the authorization scope.

External SSO

Two public (no-auth) endpoints let a trusted external identity provider sign a user in. They are the only things mounted under /v1/sso/, and only for GET. Full setup — provider config, the assertion contract, and the account-linking rules — is in External SSO; it is off unless EXTERNAL_SSO_PROVIDERS is configured.

Method Path Purpose
GET /v1/sso/providers Public provider metadata for the login page. Returns { "providers": [{ "id", "label", "kind" }] }never a secret. { "providers": [] } when SSO is off.
GET /v1/sso/:provider The exchange endpoint (a browser redirect endpoint, not a JSON API).

GET /v1/sso/:provider behaves by what it is called with:

Status codes: 401 (bad or replayed assertion), 403 (account_exists / email_unverified / account_linked_other_provider / account_linked_other_subject — linking refused; account_linked_other_provider when the email already belongs to an account linked to a different identity provider, which is never re-stamped; account_linked_other_subjectnext release — when a same-provider assertion for the deployment’s billing account does not carry the subject that first linked it: that account links on its first verified assertion regardless of EXTERNAL_SSO_LINK_EXISTING, an unverified one is email_unverified, and every later assertion must be verified and from the same subject), 404 (unknown provider), 400 (not_assertion_provider when a native OIDC/SAML provider is hit with an assertion), 429 (per-IP rate limit). The assertion and the minted token are redacted from request logs.

4. Worked example: generate an image

End-to-end bash. Assumes you’ve copied your token into $TOKEN and have a workflow that contains a text-prompt input node and a generate-image output node.

TOKEN="ndr_..."
WORKFLOW_ID="0000-0000-0000-0000"
BASE="https://nodaro.example.com"

# 1. Discover the workflow's input shape (optional but useful).
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/api/schema?workflowId=$WORKFLOW_ID" | jq .

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

echo "Execution: $EXEC"

# 3. Poll until done.
while true; do
  STATUS=$(curl -s -H "Authorization: Bearer $TOKEN" \
    "$BASE/v1/api/status/$EXEC" | jq -r .status)
  echo "Status: $STATUS"
  case "$STATUS" in completed|failed|cancelled|timed_out|discarded) break;; esac
  # Only completed/failed executions have a /result payload; for the other
  # three read errorMessage from the /status response instead.
  sleep 5
done

# 4. Fetch the result.
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/v1/api/result/$EXEC" | jq .

A successful result response looks like:

{
  "executionId": "…",
  "status": "completed",
  "creditsUsed": 4,
  "durationMs": 12450,
  "errorMessage": null,
  "outputs": [
    {
      "nodeId": "generate-image-1",
      "label": "Generate Image",
      "type": "image",
      "url": "https://…/output.png"
    }
  ]
}

The inputs object is keyed by node ID (or, as a convenience, by unique node label). The inner key is the input field for that node type — the schema endpoint tells you what to use.

4b. Identifying your client (optional)

Send an X-Nodaro-Client header and Nodaro records it as the origin of every job the request creates, which is what the admin jobs view groups by:

X-Nodaro-Client: sdk/1.10.0

Only sdk/<version>, cli/<version> and extension/<name> are recognised (extension/<name> is the label a browser extension sends for itself); any other value is ignored rather than trusted, since the header is unauthenticated. @nodaro/sdk and @nodaro/cli send it automatically — you only need this when calling the REST API directly. Omitting it is fine; those jobs are simply recorded as generic API calls.

Browser callers need do nothing — and should NOT send this header. The Origin header already identifies the site, and Nodaro prefers it: it names the product (studio.nodaro.ai) rather than the library. @nodaro/sdk omits the header automatically when it runs in a browser, so a page never depends on the header being allowlisted server-side.

4c. Selecting a workspace (Cloud, organizations)

Accounts that belong to an organization can work inside one of its workspaces. Send the workspace’s id and the request is scoped to it:

X-Nodaro-Workspace: 6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34

The header does two things, and only these two: it decides which workspace a list returns and where a create lands. It never grants access. Reading, updating, deleting or running something you name by id is decided by that object’s own workspace, so a forgotten header cannot hide your work and a forged one cannot reach anyone else’s.

Send it only for a workspace you belong to. Anything else — a workspace you are not a member of, one that does not exist, or one whose organization is not active — is refused with 403 not_a_member; a suspended membership with 403 member_suspended; a value that is not a uuid with 400 validation_error. Omit it and you are working in your personal space, exactly as an account with no organization always does.

Two exceptions exist so a stale selection can never lock you out: on GET /v1/me and GET /v1/workspaces (and when accepting an invitation) a workspace you can no longer select is treated as if you had sent nothing, and the call succeeds. Those are the endpoints that tell you which workspaces you may select, so clear a cached selection when they stop listing it.

An API token may be bound to one workspace; it then behaves as if it sent this header on every request, and an explicit header naming a different workspace is refused with 400 token_workspace_mismatch.

Bind or unbind with PATCH /v1/api-tokens/:id. Token management is JWT-only: send your signed-in session’s Supabase JWT, not an API token — a personal token or OAuth app token is refused with 403 forbidden (“API token management is only available from a logged-in session.”).

# bind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":"6f1e6b4c-6a4e-4b7b-9d2a-2f0f0a1d9c34"}'

# unbind
curl -X PATCH https://app.nodaro.ai/v1/api-tokens/$TOKEN_ID \
  -H "Authorization: Bearer $SESSION_JWT" \
  -H "Content-Type: application/json" \
  -d '{"workspaceId":null}'

You may bind a token only to a workspace you could select with the header; anything else is refused the same way the header is. Unbinding is always allowed. The binding is returned as workspaceId when you list your tokens.

Where a create lands

Inside a workspace, a create that names no project lands in that workspace’s project, never your personal one. If the workspace has no project yet the create is refused with 409 workspace_has_no_default_project rather than guessing — name a project explicitly and it will succeed.

A project you name must belong to the workspace you are working in. One that belongs to a different workspace, or to your personal space, answers 404 Project not found — the same answer a project that does not exist gets, so the header cannot be used to discover what exists.

Creating a project inside a workspace may be restricted to its admins. When it is, members get 403 project_create_not_allowed.

Archived workspaces are read-only

Archiving a workspace keeps everything in it readable and stops new work being added. Lists behave normally. Every create — a project, a workflow, an import, a sub-workflow — answers 409 workspace_archived, as does moving work into it. Moving work out of an archived workspace stays allowed: rescuing it is the reason to open one.

When the personal space is closed

An organization can require that its members create only inside a workspace. For those accounts, a create with no workspace selected answers 403 personal_space_disabled. Send the workspace header and the same call succeeds. Accounts that belong to no organization are never affected.

The SDK sends it for you — createClient({ workspaceId }), or client.withWorkspace(id) for a client scoped to one workspace. The CLI takes --workspace, NODARO_WORKSPACE, or a saved profile selection. For the full organization surface — members, invitations, join codes, the audit log — see Organizations.

The header has no effect unless organizations are enabled on the instance you are calling; self-hosted builds ignore it.

The organization, workspace and membership endpoints themselves — creating an organization, adding people, archiving a class — are documented in Organizations.

4d. Parameter corrections (adjustments)

POST /v1/generate-image, /v1/image-to-image, /v1/edit-image, /v1/text-to-video and /v1/generate-video accept a single, model-agnostic vocabulary for aspectRatio, resolution and quality — but no model supports all of it. Rather than reject a request (a rejection mid-workflow takes every already-generated, already-billed sibling node down with it), the server corrects the value to one the chosen model accepts and tells you what it changed:

{
  "jobId": "0f1a…",
  "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."
    }
  ]
}

On the video routes

/v1/text-to-video and /v1/generate-video return adjustments in the same shape. /v1/generate-video reports each correction twice — once in adjustments (structured: field, from, to, reason) and once in its existing warnings array (the reason prose only, alongside any non-parameter warning such as voice_unsupported_for_provider). Read adjustments when you need to know which lever moved; the two never disagree.

Three video-specific rules:

A few models ignore an unsupported resolution and render a fixed default instead of the nearest band — minimax-h3 renders 2K for anything that is not 768P, and the wan-3 family renders 720p (both described under Seedance 2 video capabilities). For those the correction targets the band the provider will actually produce, so the price still matches the render. Each such model declares this in the catalog via unlistedResolutionRendersAs, which is what GET /v1/models reflects.

duration is passed through as you send it, with one exception: the LTX 2.3 models are priced on a fixed ladder of seeded durations per resolution band, so a duration between rungs is moved to the nearest one and reported in adjustments.

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 is refunded down to the length actually delivered. On any other model -1 is ignored and the model renders its default duration.

5. Sync vs async execution

By default, POST /v1/api/run is async: it returns 202 Accepted with { executionId, status: "pending" } immediately and you poll /status/:execId until done.

For short-lived workflows you can hold the connection until completion:

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

Recommended cutoff: use sync for workflows you expect to finish in under a minute (text generation, light image work). For multi-step workflows that include video rendering or upscaling, use async.

Linked-frame canvas nodes require the Studio production generation API. The canvas workflow-run endpoint and direct media requests that identify a saved linked node return HTTP 400 with sequence_execution_required. Queued workflows also check this requirement before executing their graph. See the execution boundary.

Generic saves of a dependency-aware production’s graph or settings return HTTP 409 production_capability_required, including delta updates and updates that omit its dependency fields. Use compatible Studio production operations to preserve reviewed inputs and apply revision checks.

The Cloud Studio plugin exposes GET /v1/studio/productions/capabilities for per-operation support and GET /v1/studio/productions/:id?detail=full for planned frames, candidate history, recorded acceptance, linked endpoints and pending jobs. These reads do not generate media, reconcile jobs or accept candidates. Character descriptions work without a generated portrait; image conditioning is an explicit choice. See the Studio SDK methods for generation, reconciliation and separate review actions.

When operations.saveEditorState is available, POST /v1/studio/productions/:id/ops accepts save_editor_state with an expectedVersion and a serialized editor graph. This saves ordinary editor fields while preserving protected frame and job state. The revision is checked inside the compare-and-swap loop; a stale draft returns HTTP 409 even without an outer strict flag. Retain the local draft when handling that conflict.

When operations.revisionedSharing is available, use POST /v1/studio/productions/:id/share with { shared, expectedVersion } to change link sharing at the reviewed workflow revision. The route separately checks permission to change visibility. If another edit wins before the write, it returns HTTP 409 workflow_conflict without rebasing the sharing decision. Use shared: false on the same route for revision-checked unsharing.

With operations.editableSharedCopies, the sharing body also accepts allowEditableCopy. Enabling it requires shared: true and expectedVersion, and the same owner/admin visibility authority. Authenticated link viewers may then call POST /v1/studio/productions/:id/clone to copy the saved live plan, prompts, cast descriptions, retained inputs and take history. The bin and private review notes are excluded. Destination media uses the viewer’s storage quota; frames need fresh acceptance and no generation jobs are started. Unsharing clears copy permission. Revocation blocks subsequent requests; already admitted copies remain independent. Public snapshots advertise publicView.editableCopyAllowed without exposing the editable source. The clone route rechecks permission.

Public link reads of dependency-aware productions require the matching plugin’s public projection. They return a marked settings.studio.publicView media snapshot: selected clips, accepted frames, selected previews and planned-frame labels. Private inputs, reviews, pending jobs, trash and unselected history are omitted. The snapshot is for read-only display; it is not an editable plan or a lossless clone source. Without compatible projection support, the public read returns the same 404 as an unavailable link. Ordinary public workflows retain their existing response shape.

For a linked segment, POST /v1/studio/productions/:id/generate with kind: "clip", shotId and dryRun: true verifies both accepted endpoints and returns an estimate for the normalized clip settings. Its inputHash covers those settings and endpoint pins. Send it back as expectedInputHash on the explicit generation request; a mismatch returns sequence_quote_changed (409) before submitting a job. Quoting itself creates no job and accepts no frame.

6. Webhooks (push into Nodaro)

A complementary path: instead of your server calling Nodaro to start a workflow, you can let an external system push into a workflow.

Add a Webhook Trigger node to a workflow. Save it. Nodaro mints a unique 32-byte token and exposes:

POST /v1/webhooks/<token>

This route is fully public — the token is the auth. The request body becomes the trigger payload visible to downstream nodes. Rate limited to 10 requests per minute per token. Use cases:

If you need scheduled triggers (cron-like) without an external system, use the Schedule Trigger node instead — Nodaro polls the schedule internally every 60 seconds.

7. Rate limits

Per-token, in-memory bucket:

Recommended client behaviour:

Webhook triggers (POST /v1/webhooks/:token) are rate-limited separately — 10 requests/minute per webhook trigger.

Two distinct 429 codes. The per-token bucket above (the /v1/api/* routes) returns rate_limited. Every other limiter returns rate_limit_exceeded: a per-IP limiter on a few unauthenticated endpoints — e.g. OAuth Dynamic Client Registration (POST /v1/oauth/register, 10/min/IP) and the SSO exchange — a per-caller limiter on specific authenticated routes (the route’s own section on this page states its limit, e.g. POST /v1/freecut-export 10/min; these send a Retry-After header and, on spend routes, answer 503 rate_limit_unavailable if the limiter’s store is down), and a published app’s daily run cap. Match on the HTTP 429 status for retry logic; use the code only to tell the per-token bucket from the rest.

8. Error envelope

All errors share the same shape:

{ "error": { "code": "rate_limited", "message": "Too many requests. Max 30 per minute." } }
HTTP code Extra field When / route family
400 validation_error Malformed body, bad UUID, invalid field.
400 limit_reached POST /v1/api-tokens: you already have 10 tokens (active or not). Delete one first.
400 invalid_workflow POST /v1/api-tokens / PATCH /v1/api-tokens/:id: a workflowIds entry is not a workflow in your personal space (workspace workflows cannot be scoped to a token).
401 unauthorized Missing/invalid/expired/revoked token.
402 insufficient_credits required (plus balance, except on a deployment-payer instance) (Cloud edition only) Account out of credits. On a deployment-payer instance it means the deployment’s pool is empty (“This deployment is out of credits. Contact your administrator.”): the response names required and never balance, and only the billing account can top the deployment up.
402 budget_exceeded (Cloud edition, organizations) Workspace-paid work: the workspace’s allocated budget can’t cover the reservation. Ask a workspace admin for headroom. Rollout-gated: availability may lag this document.
402 member_cap_exceeded (Cloud edition, organizations) Workspace-paid work: your per-member spending cap in this workspace is reached. Rollout-gated.
402 user_allowance_exceeded required, remaining (top-level, on the pre-run check) (Cloud edition, deployment-payer instances) Your per-user allowance on this deployment can’t cover this run — distinct from insufficient_credits, which means the deployment’s own pool is empty. Only the deployment’s billing account can raise an allowance. Fires only on a deployment that has switched allowance enforcement on; until then your allowance is shown (GET /v1/user/creditsallowance.enforced: false) but never refuses a run.
402 instance_cap_reached (OAuth tokens of a connected self-hosted instance only) The instance has spent its monthly cap on the account that connected it. Raise or remove the cap under the account’s Connected Instances; a personal API key used as the relay credential has no such cap.
403 forbidden Token isn’t authorized for this workflow (workflow scoping) — or the route is session-only and refuses API/OAuth tokens: /v1/api-tokens management (“API token management is only available from a logged-in session.”), node-preset writes, the /v1/billing/* purchase routes (checkout, load sessions, auto-recharge, purchase history, Stripe portal). Repeat the call with your browser session’s JWT.
403 member_suspended (Cloud edition, organizations) Workspace-paid work: your membership in the paying workspace is suspended. Rollout-gated.
403 not_a_member (Cloud edition, organizations) The request names a workspace you are not an active member of. Rollout-gated.
403 insufficient_scope missingScope (+ message) (OAuth tokens only) The token is missing a scope the route requires. Re-run the OAuth consent with the broader scope. See OAuth Flow §4.
403 sso_required (Deployments that restrict sign-in to their identity provider) A session JWT for an account that was not provisioned through that provider. Sign in through the provider; API tokens and OAuth app tokens are not affected.
403 edition_required required_edition: "<edition>" (+ message) Endpoint needs a higher edition than the caller has. required_edition is the minimum: "cloud" for pipeline (POST /v1/pipelines/:id/branch) + scene-helper routes; "business" for API-token management (POST /v1/api-tokens, GET /v1/api-tokens).
403 api_tokens_payer_only On a deployment-payer instance only the billing account may create a personal API token; every other user is refused. Existing tokens stay listable and revocable by their owner.
403 payer_balance_jwt_only (Deployment-payer instances only) A credit-balance read (GET /v1/credits/balance, GET /v1/user/credits, GET /v1/credits/check) authenticated as the billing account with an API token or an OAuth app token. The deployment’s pool is the operator’s number and is answered only to that account’s own browser session — see §3. No other identity is affected.
403 subscription_required (Cloud edition only) A pay-as-you-go account tried to spend from a first-party consumer surface (browser session in the studio or another Nodaro app). Payg credits are redeemable via the API/SDK/CLI/MCP — this never fires for token-authenticated calls. Rollout-gated: availability may lag this document.
404 not_found Workflow, execution, or token not found.
404 workspace_not_found (Cloud edition, organizations) The workspace named by workspace-paid work does not exist (or was deleted mid-flight). Rollout-gated.
409 workspace_archived (Cloud edition, organizations) Workspace-paid work into an archived workspace. Unarchive it or move the work. Rollout-gated.
409 retained_image_in_use A delete would remove protected image bytes, or a production has active jobs using retained images. Finish or cancel active jobs before deleting the production.
422 job_blocked A job policy registered by this deployment refused the generation before it ran. message is user-facing text written by the deployment’s policy (or by the platform, when the policy supplies none) — show it as-is. No job was created and nothing was charged. The platform does not retry a refused request; whether the same request would be judged differently is the deployment’s policy’s business. Only occurs on deployments that register a job policy — see deployment.md. Two bookkeeping inserts are the honest exception: the Suno voice-persona ownership rows and the connected-cloud LLM mirror row treat a block as best-effort — the operation proceeds and its row is simply not recorded — so a policy blocking one of those neither stops the call nor reaches you as a 422.
422 upload_blocked An upload policy registered by this deployment refused the upload before it was stored (every byte-carrying ingestion lane: POST /v1/upload*, the proxy PUT and the handoff POST). message is the deployment’s own reason — show it as-is. Nothing was written. Only occurs on deployments that register an upload policy.
429 rate_limited You’ve exceeded the per-minute bucket. Back off.
500 internal_error Server bug or downstream dependency failure. Retry with backoff.
503 price_not_configured (Cloud edition only) No pricing row exists for the requested model — the server hard-fails rather than silently mis-billing. Operator must seed the price; the call is not retryable as-is.

Treat anything in the 5xx range as transient — retry with exponential backoff. Treat 4xx as terminal — don’t retry without fixing the request.

Job failure hints (error_hint) and credit status

The table above covers request-level errors — a call that never produced a job. A job that later fails carries its own detail in error_message plus, for two classes of failure, a structured error_hint.

A provider content-policy block:

{ "kind": "safety-block", "class": "copyright" | "likeness" | "safety", "retried": boolean, "suggestedProvider"?: string }

Or, on a deployment that registers a job policy, a rejection by that policy:

{ "kind": "policy-block", "policyId": string, "reason": string, "hookPoint": "request" | "result" }

reason is user-safe text written by the deployment’s policy (or by the platform, when the policy supplies none) — show it as-is. hookPoint says whether the request was refused before the job ran or the output was rejected after it was produced (including a reviewer rejecting a job that had been held in pending_review). The reservation is refunded. (The rare exception is a job whose credits were already settled before the gate spoke — there is then nothing left to return, and no platform message claims otherwise.) Unlike safety-block, the platform does not retry a policy rejection and offers no fallback model: whether the same request would be judged differently is the deployment’s policy’s business, not the platform’s.

error_hint is null/absent on every other failure — including a request reject, where the provider answered the submission with a 4xx because the settings or input media are invalid for that model. That one has no structured hint, but its error_message says the provider rejected these settings for this model and retryable is false: change the settings or the media before re-running. A provider 5xx stays retryable however validation-shaped its wording is. For safety-block, class distinguishes a deterministic block (copyright, likeness — retrying the identical request never helps) from safety, whose filter is known to be non-deterministic for some models: retried reports whether the platform already spent its one automatic retry on the same request, and suggestedProvider — present only when the model’s catalog entry declares a fallback — is a real model id you can retry the same prompt/references on. error_hint is included on every job payload that carries error_message: GET /v1/jobs, GET /v1/jobs/:id, GET /v1/jobs/:id/status, GET /v1/jobs/status, and POST /v1/jobs/batch-status.

GET /v1/jobs/:id, GET /v1/jobs/:id/status, and GET /v1/jobs/status also carry credit_status: "reserved" | "committed" | "refunded" | null — the job’s credit reservation lifecycle, derived server-side from the usage log. null when the job has no usage log to report; never present on the plain GET /v1/jobs list or POST /v1/jobs/batch-status. A generation that ends in a safety-filter block or a policy block is always refunded — see §12 Credits. A job a deployment’s policy has held for human review reports status: "pending_review" — an in-flight status, not a terminal one; keep polling rather than treating it as a failure — with credit_status: "reserved" for the whole hold; it then resolves to completed, failed (with the policy-block hint above) or cancelled (a held job is cancellable like any in-flight job). A hold can also time out: on a deployment that sets a review deadline (JOB_HOLD_TTL_HOURS), a job nobody reviews in time is auto-rejected — failed with the policy-block hint (hookPoint: "result"), the reservation refunded and the withheld output deleted; it is never auto-approved. Without that setting a hold waits for a reviewer indefinitely.

8b. Pay-as-you-go accounts

You do not need a subscription to use the API. Buying any credit pack — or loading an arbitrary whole-dollar amount ($5–$1,000) from the Billing page; larger loads earn a better per-credit rate — activates the pay-as-you-go tier: balance responses report effectiveTier: "payg", all models are unlocked, outputs are not watermarked, and there is no daily spending cap. Credits are valid for 12 months from purchase. Pay-as-you-go credits are redeemable through the developer surfaces — API, SDK, CLI, and MCP; using the web studio requires an active subscription. Subscriptions remain available and always include a lower per-credit rate at sustained volume.

Auto-recharge (optional): in Billing you can set “when my balance drops below X credits, load $Y” — the amount is charged off-session to your saved card (any manual load saves it) at the same rate as manual loads. Three failed charges disable auto-recharge until you re-enable it. Every charge (manual or automatic) emails a Stripe receipt, and receipt links appear in the Billing page’s Credit Activity. Rollout-gated: availability may lag this document.

Two behaviors to know:

9. Characters

Character routes let you fully script character creation, identity edits, asset generation, and the portrait-approval pipeline that drives Character Studio. All routes require an authenticated bearer token (ndr_… / ndr_app_… / Supabase JWT) and are scoped to the calling user.

Lifecycle

Method Path Purpose
GET /v1/characters List characters (cursor-paginated). Query: projectId, archived=true, limit (default 100, max 500), cursor. Returns { characters, nextCursor } — see pagination below.
GET /v1/characters/:id Get full character + in-flight portrait/asset jobs.
POST /v1/characters Upsert (create if no id, update otherwise).
POST /v1/characters/:id/duplicate Fork to a new row with (copy) suffix.
POST /v1/characters/:id/restore Un-archive a soft-deleted character.
DELETE /v1/characters/:id Soft-delete (archive). Restorable.
GET /v1/characters/:id/usage List workflows that reference this character.

The upsert body is documented in backend/src/routes/characters.ts. On UPDATE, only the fields you supply are written; omitted keys are left alone so partial saves don’t clobber asset arrays a worker is concurrently appending to.

GET /v1/characters — pagination

The list is cursor-paginated. One call returns at most limit rows (default 100, max 500), so treating a single response as “all characters” silently truncates the list for anyone above that count.

{
  "characters": [ /* … */ ],
  "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA3LTMxVDEwOjAwOjAxWiIsImlkIjoi…"
}
Field Meaning
nextCursor Opaque token. Pass it back as ?cursor= to fetch the next page.
nextCursor: null You have reached the end — there are no more rows.

Keep requesting until nextCursor is null:

CURSOR=""
while :; do
  RESP=$(curl -s -H "Authorization: Bearer $TOKEN" \
    "$BASE/v1/characters?limit=100${CURSOR:+&cursor=$CURSOR}")
  echo "$RESP" | jq -r '.characters[].id'
  CURSOR=$(echo "$RESP" | jq -r '.nextCursor // empty')
  [ -z "$CURSOR" ] && break
done

Notes:

Generation

Method Path Purpose
POST /v1/generate-character Generate 1 / 2 / 4 portrait candidates.
POST /v1/generate-character-asset Generate one expression / pose / angle / lighting variant.
POST /v1/generate-character-motion Animate the character’s portrait into a motion clip.

All generation routes return at minimum { jobId }. /v1/generate-character additionally returns { jobIds: string[] } so multi-candidate runs are trackable. Pass attachToCharacterId to auto-attach the result to the character row when the job completes — no separate approve step needed for single-candidate runs.

The image-generating routes (/v1/generate-character, /v1/generate-character-asset, and the location equivalents /v1/generate-location / /v1/generate-location-asset) also accept optional quality ("medium" / "high" / "basic") and resolution ("1K" / "2K" / "4K" / "0.5 MP" / "1 MP" / "2 MP" / "4 MP"). These are credit-affecting and price exactly like /v1/generate-image (composite ids such as gpt-image:high / nano-banana-pro:4K) — a 4K / high run reserves more credits than the same model at its base tier. A value the chosen model doesn’t support is corrected to one it does (the same mechanism as “Parameter corrections” above), never a 400 — and the credits reserved match the corrected value, not the one you asked for. These four routes apply the correction but, unlike /v1/generate-image, /v1/image-to-image and /v1/edit-image, do not return an adjustments array — the corrected value is visible only in the job’s persisted input_data (GET /v1/jobs/:id), not in the route’s own response body.

Portrait approval

Method Path Purpose
POST /v1/characters/:id/approve-portrait Set the row’s source_image_url from a completed candidate job AND fire the LLM caption.
POST /v1/characters/:id/llm-caption Re-run the LLM caption against the current portrait.

approve-portrait body: { candidateJobId: <uuid> }. The candidate must be status="completed" and belong to the caller. The route returns { portraitUrl, canonicalDescription }canonicalDescription is null if the LLM caption sub-failed (portrait still set; retry via llm-caption).

Worked example: create → generate → approve

TOKEN="ndr_..."
BASE="https://nodaro.example.com"

# 1. Create the character row.
CHAR=$(curl -s -X POST "$BASE/v1/characters" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "nodeId": "scripted",
        "name": "Kira",
        "description": "young protagonist with auburn hair",
        "style": "realistic",
        "seedPrompt": "kira portrait, warm natural lighting"
      }' | jq -r .id)

# 2. Generate 4 portrait candidates, auto-attaching to the row.
JOB_IDS=$(curl -s -X POST "$BASE/v1/generate-character" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
        \"name\": \"Kira\",
        \"seedPrompt\": \"kira portrait, warm natural lighting\",
        \"count\": 4,
        \"attachToCharacterId\": \"$CHAR\"
      }" | jq -r '.jobIds | join(" ")')

# 3. Poll each job until done, then approve your favorite.
for JOB in $JOB_IDS; do
  while true; do
    STATUS=$(curl -s -H "Authorization: Bearer $TOKEN" \
      "$BASE/v1/jobs/$JOB" | jq -r .status)
    [[ "$STATUS" == "completed" || "$STATUS" == "failed" ]] && break
    sleep 3
  done
done

PICK=$(echo "$JOB_IDS" | awk '{print $1}')
curl -s -X POST "$BASE/v1/characters/$CHAR/approve-portrait" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"candidateJobId\": \"$PICK\"}" | jq .

# 4. Generate an "smile" expression off the approved portrait.
curl -s -X POST "$BASE/v1/generate-character-asset" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
        \"name\": \"Kira\",
        \"assetType\": \"expressions\",
        \"variant\": \"smile\",
        \"attachToCharacterId\": \"$CHAR\",
        \"attachToColumn\": \"expressions\",
        \"attachName\": \"smile\"
      }"

A complete walkthrough — including motion generation and using character assets as references in downstream image/video calls — is in Character Platform.

10. Objects

Object routes let you fully script object (prop / product / vehicle / etc.) creation, identity edits, asset generation, and the main-image approval pipeline that drives Object Studio. All routes require an authenticated bearer token (ndr_… / ndr_app_… / Supabase JWT) and are scoped to the calling user.

Lifecycle

Method Path Purpose
GET /v1/objects List objects. Query: projectId, archived=true; optional limit (max 500) + cursor opt into the same cursor pagination as /v1/characters and add nextCursor to the response — without limit the full legacy listing returns, unchanged. /v1/creatures, /v1/locations and /v1/faces accept the identical parameters.
GET /v1/objects/:id Get full object + in-flight asset jobs. Archived rows return uniform 404 not_found.
POST /v1/objects Upsert (create if no id, update otherwise). Optimistic-concurrency via expectedUpdatedAt.
POST /v1/objects/:id/restore Un-archive a soft-deleted object.
DELETE /v1/objects/:id Soft-delete (archive). Restorable.
DELETE /v1/objects/:id?permanent=true Permanent destroy. Row must already be archived (400 not_archived otherwise).

The upsert body is documented in backend/src/routes/objects.ts. On UPDATE, only the fields you supply are written; omitted keys are left alone so partial saves don’t clobber asset arrays a worker is concurrently appending to. Worker-owned asset buckets (angles / materials / variations / motion_clips) are intentionally dropped on UPDATE — a stale-snapshot save would clobber the worker’s atomic append_object_asset() writes.

Generation

Method Path Purpose
POST /v1/generate-object Generate 1 / 2 / 4 candidate main images.
POST /v1/generate-object-asset Generate one angles / materials / variations / custom variant. Studio-gated LLM draft when attachToObjectId set + description omitted.
POST /v1/generate-object-motion Animate the object’s main image into a motion clip (i2v). Defaults: provider kling-turbo, aspect ratio 1:1.

/v1/generate-object returns a discriminated union: { jobId } for count: 1 (default) and { jobIds: string[] } for count: 2 | 4 — branch on "jobIds" in response. The asset / motion routes always return { jobId }. Pass attachToObjectId to auto-attach the result to the object row when the job completes — no separate approval step needed for single-candidate runs.

Main-image approval

Method Path Purpose
POST /v1/objects/:id/approve-main-image Set the row’s source_image_url from a completed candidate job AND fire the LLM caption. Accepts expectedUpdatedAt for optimistic-concurrency.
POST /v1/objects/:id/llm-caption Re-run the LLM caption against the current main image. Idempotent retry — does NOT accept expectedUpdatedAt.

approve-main-image body: { candidateJobId: <uuid>, expectedUpdatedAt? }. The candidate must be status="completed" and belong to the caller. The route returns { sourceImageUrl, canonicalDescription }canonicalDescription is "" (not null) when the LLM caption sub-failed (main image still set; retry via llm-caption). The llm-caption route 502s on LLM failure and 400 main_image_required when no main image is set yet.

Worked example: create → generate → approve

TOKEN="ndr_..."
BASE="https://nodaro.example.com"

# 1. Create the object row.
OBJ=$(curl -s -X POST "$BASE/v1/objects" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "nodeId": "scripted",
        "name": "Antique Lantern",
        "description": "Weathered brass lantern with hand-engraved filigree",
        "category": "tool",
        "style": "realistic"
      }' | jq -r .id)

# 2. Generate 4 main-image candidates, deferring auto-attach.
JOB_IDS=$(curl -s -X POST "$BASE/v1/generate-object" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
        \"name\": \"Antique Lantern\",
        \"count\": 4
      }" | jq -r '.jobIds | join(" ")')

# 3. Poll each job until done, then approve your favorite.
for JOB in $JOB_IDS; do
  while true; do
    STATUS=$(curl -s -H "Authorization: Bearer $TOKEN" \
      "$BASE/v1/jobs/$JOB" | jq -r .status)
    [[ "$STATUS" == "completed" || "$STATUS" == "failed" ]] && break
    sleep 3
  done
done

PICK=$(echo "$JOB_IDS" | awk '{print $1}')
curl -s -X POST "$BASE/v1/objects/$OBJ/approve-main-image" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"candidateJobId\": \"$PICK\"}" | jq .

# 4. Generate a "gold" materials variant off the approved main image.
curl -s -X POST "$BASE/v1/generate-object-asset" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
        \"name\": \"Antique Lantern\",
        \"assetType\": \"materials\",
        \"variant\": \"gold\",
        \"attachToObjectId\": \"$OBJ\",
        \"attachToColumn\": \"materials\",
        \"attachName\": \"gold\"
      }"

A complete walkthrough — including motion generation, the Studio-gated LLM draft on generate-object-asset, the 5-tab Studio surface, and using object assets as references in downstream image/video calls — is in Object Platform.

11. Node discovery

GET /v1/nodes and GET /v1/nodes/:type let clients enumerate every node type the server has registered without hard-coding a list.

Method Path Purpose
GET /v1/nodes Return the full node registry ({ data: NodeDescriptor[] }). Responses are cached for 5 minutes (Cache-Control: public, max-age=300).
GET /v1/nodes/:type Return a single descriptor by node type string. 404 not_found when the type doesn’t exist.

NodeDescriptor fields (subset): type, label, category, outputType, creditCost (static credit cost when known — Cloud only; community and business installs have no credit system and omit the field), inputSchema (JSON Schema for the node’s config fields), providers (supported provider slugs), capabilities (feature flags the node exposes). Nodes with per-model constraints carry additional discovery fields — maxDurationSec (hard duration ceiling), sparseProviders (models whose segment-duration menu is sparse; off-menu values snap to the nearest entry), providerResolutions (per-model resolution tiers, e.g. { "minimax-h3": ["2K", "768P"] }), providerResolutionWire (display resolution → the literal wire value the API expects — send "768P", not "768p", to reach minimax-h3’s cheap tier), and soundtrack (Generate Video Pro: the deployed engine accepts the original-audio soundtrack input). The exact shape grows over time — treat unknown fields as forward-compatible.

Every AI prompt node also lists promptPrefix and promptSuffix (text) in inputSchema — see Prompt pre & post text.

Neither endpoint requires authentication; they expose only static registry metadata. No scopes required.

Model catalog

GET /v1/models — the REST twin of the MCP list_models tool, for plain SDK/HTTP clients. Public (model availability is not a secret), cached 5 minutes (Cache-Control: public, max-age=300). Returns { sections, recommendations, totalModels }: models grouped by kind (image / video / audio) and vendor family, each with capability sheets (modes, features, aspectRatios, resolutions, durations), per-variant credit pricing (Cloud only — like creditCost on /v1/nodes, editions without a credit system omit it), compact promptTips, and the doctrineCovered truth flag (true only when a sourced per-family prompt doctrine exists — gate “vendor doctrine” badges on it; never overclaim).

Query param Values Purpose
kind image / video / audio Filter to one media kind.
mode e.g. t2i, i2v, t2v, tts, video-analysis Filter by operation.
family string Vendor / lab name, e.g. Google, Bytedance.
featuredOnly boolean Featured models only.

Seedance 2 video capabilities

The video routes (/v1/text-to-video, /v1/generate-video) accept these on the Seedance 2 family — resolution / aspectRatio are pass-through strings, so a value the model doesn’t support is ignored, never a 400:

Structured references (connectedReferences) on video

POST /v1/generate-video, POST /v1/text-to-video, and POST /v1/extend-video accept an optional connectedReferences array — the SAME structured-reference shape /v1/generate-image takes — so a direct API / SDK / MCP caller gets the identical reference assembly the editor performs client-side, instead of hand-building a prose “Image N is …” guide. When present, the route assembles them server-side (via the shared video resolver the canvas and orchestrator already use):

Each ref’s url rides the same SSRF gate as the flat referenceImageUrls, so a ref pointing at a private address / non-http(s) scheme is rejected at the route boundary. See the Generate Video node page for the token syntax and worked examples.

referenceOrder on images too. POST /v1/generate-image accepts the same optional referenceOrder (parity with video) to reorder its assembled reference list and renumber the @image_N bindings.

Described references (describedReferences) — a name you have no picture for

POST /v1/generate-image, POST /v1/generate-video, POST /v1/text-to-video and POST /v1/extend-video also accept an optional describedReferences array — up to 10 entries of { name, description } (name ≤ 80 chars, description ≤ 2000). It is the channel for a subject you can NAME and DESCRIBE but have no media for: a cast role no entity is bound to yet, a character that exists only in the script, a slot a video analysis identified.

Per-use descriptions (descriptionOverride) and rail captions

Two smaller channels for the same problem — telling the model what a reference IS, for this run only:

Naming an image reference in the prompt (@<name-slug>:<index>[:<role>])

On POST /v1/generate-image a media reference (source: "wired-image" or "manual") can be addressed inline by the slug of its name, the same way characters and locations already are — so you can say where in the sentence the picture belongs instead of relying on the trailing auto-attach block.

Mentioning a reference re-seats it: its URL moves from the trailing auto-attach block into the mention block, which re-letters the references after it. That is the point of the feature — the letters follow the sentence.

Naming a creature or object in the prompt (same grammar)

source: "wired-creature" and source: "wired-object" entries use the same grammar, the same slug derivation from defaultName, the same ~lock / ~nolock sentinels, and the same hybrid-only, capped-ref and unmentionable-slug rules as the media mention above. Two things differ:

{
  "prompt": "a wide shot of @nessie:1 rising beside @dock:2:material",
  "connectedReferences": [
    { "id": "cr-1", "defaultName": "Nessie", "source": "wired-creature", "url": "https://…/nessie.png" },
    { "id": "ob-1", "defaultName": "Dock",   "source": "wired-object",   "url": "https://…/dock.png" }
  ]
}
// → "a wide shot of the creature from reference image A rising beside
//    the material from reference image B"

Reference lock (referenceLock) on generate-image

POST /v1/generate-image accepts an optional referenceLock token — an id, not text — that opts a run into the platform’s measured reference-compliance wording, prepended ahead of the scene it governs (hybrid reference format):

Free-text lock wording is deliberately not accepted: the wording is platform-owned and improves without a client release. Omitting the field keeps the platform-wide default — no lock is ever injected unrequested. Under the legacy reference format the token is accepted but inert.

Cinematic direction (direction) on generate-image

POST /v1/generate-image accepts an optional direction object: a flat map of catalog ids, one key per cinematic dimension, which the platform folds into the prompt as its own hint clauses. Send ids, not prose — the wording stays platform-owned, so a saved production picks up improved phrasing instead of freezing whatever text your client wrote the day it was saved.

{
  "prompt": "a knight on a hill",
  "provider": "nano-banana",
  "direction": {
    "shotSize": "wide-shot",
    "lightingStyle": "rembrandt",
    "style": "anime",
    "mood": ["happy", "joyful"]
  }
}

Cinematic direction on the video routes

POST /v1/generate-video and POST /v1/text-to-video accept the SAME direction object, with the same tolerance rules, the same wire bounds and the same “absent ≠ empty” semantics as generate-image above — including the truncation ordering, which sheds direction clauses before your prose or your references. Three things differ — the dimension set and how motion renders, below, and what the truncation budget has to cover (after the example).

The dimension set is the video surface. Every look dimension listed above folds here too, minus the seven stills-only ones (aperture, shutterSpeed, isoValue, postProcess, photoGenre, photographer, renderQuality), plus the motion dimensions: cameraMotion, actionFx, temporalSpeed, temporalFreeze, temporalDirection, temporalShutter, transition, loopSubject. cameraMotion folds FIRST, ahead of every look clause — and, being motion, it also READS first, in the body. A stills-only key sent to a video route is accepted and simply contributes no clause — surface is a rendering concern, not a validation one — so one client-side look map can be sent to either route unchanged.

Motion dimensions render compact, and stay in the body. Look dimensions inject their full clause; motion dimensions inject their short professional term ("cross-dissolve", not the sentence describing what a cross-dissolve does). Motion cues read better to a video model as terse directives, and video prompt ceilings are far tighter than image ones.

That same split decides WHERE a clause reads. Camera motion is part of the shot, not part of the look, so the motion clauses stay in the prompt body with your prose — ". "-joined, after it, before the structured fragment — and only the look clauses lift into the trailing [style] section described above. One dimension is never compact in the body and full in the section: the two rules read the same column, deliberately, so they cannot drift apart.

{
  "imageUrl": "https://…/frame.png",
  "provider": "seedance-2",
  "prompt": "she turns toward the window",
  "direction": {
    "cameraMotion": "dolly-in",
    "shotSize": "medium-shot",
    "timeOfDay": "dawn",
    "temporalSpeed": "slow-motion"
  }
}

The fold happens before reference assembly, so your direction clauses sit inside the body that the reference resolver frames — the identity directives for a bound character still wrap the whole description. In the hybrid reference format the resolver extends the END of that body with a bound character’s canonical role phrase, inserted ahead of the [style] section: the section stays last and carries look clauses only, so a binding never reads as one. (The legacy format PREPENDS its character block instead, ahead of everything.) jobs.input_data records both halves: prompt is what the model received, userPrompt is the text you submitted (empty string if you sent direction with no prompt at all), and direction is your ids verbatim.

Truncation is the third thing that differs — in the budget, not the rule. Video prompt ceilings are provider-specific and far lower than image ones (kling clamps at 1000 characters), so a broad direction can exceed one on its own. The shed described for generate-image applies here too: over the ceiling, the route drops hint clauses from the END of the fold order until the prompt fits, and your prose, your references and the role phrases that bind them all survive intact. subject clauses (below) are in the same budget on this surface too, folded ahead of direction and therefore shed last — and a subject-only request is budgeted exactly the same way.

Two video-specific details are worth knowing:

Two things stay outside the budget and fall back to the order-blind clamp (a bare tail cut, with no trailing ...): the opt-in identity injection (injectCharacterContext), which appends a character’s canonical description after assembly, and a body that still overflows with ZERO hints left — very long prose, or many bound references on their own. As on the image side, nothing is shed while the prompt fits, so an under-cap request is byte-for-byte what it always was.

POST /v1/extend-video deliberately has no direction field: its prompt continues an existing clip, where re-stating the look is the wrong lever.

Structured subject (subject)

POST /v1/generate-image, POST /v1/generate-video and POST /v1/text-to-video accept an optional subject object — the same doctrine as direction, one channel over: a flat map of catalog ids describing WHO is in the shot (the person, how they are styled, and the props in frame) which the platform folds into the prompt as its own clauses.

{
  "prompt": "on the seawall at dusk",
  "provider": "nano-banana",
  "subject": {
    "type": "woman",
    "age": "age-30s",
    "ethnicity": "east-asian",
    "hairBase": "base-short-straight",
    "makeup": "makeup-smoky",
    "outerwear": "outerwear-trench",
    "heldProp": "smartphone"
  }
}

POST /v1/extend-video deliberately has no subject field, for the same reason it has no direction.

Picker catalogs

GET /v1/picker-catalogs and GET /v1/picker-catalogs/:nodeType expose the valid values for parameter-picker nodes (setting, mood, person, lens, …) — the curated catalogs whose selection contributes a descriptive clause to a downstream node’s prompt. Public, no auth, same 5-minute cache as node discovery (Cache-Control: public, max-age=300).

Method Path Purpose
GET /v1/picker-catalogs Directory of every picker ({ data: PickerCatalogSummary[] }) — each { nodeType, label, catalogId, kind, valueField?, fields?, optionCount }.
GET /v1/picker-catalogs/:nodeType One picker’s catalog ({ data: PickerCatalog }). 404 not_found for an unknown type.

GET /v1/picker-catalogs/:nodeType accepts these query params (a bad value returns 400 validation_error):

Param Values Purpose
detail compact (default) / full compact: id, label, category, term, icon. full: additionally includes each option’s description and promptHint (the prompt fragment it injects).
category string Single-dim pickers: filter options to one category.
field string Return only this dimension’s field — multi-dim pickers (person / styling / framing), and the secondary parameters of a single-dim picker (transition / character-fx: position / duration / intensity; character-motion: position / pace).

A single-dim catalog carries options; a multi-dim catalog carries dimensions (one { field, label, options } per field). A single-dim catalog with secondary parameter fields beside its main picker — transition and character-fx (position / duration / intensity) and character-motion (position / pace), whose dropdowns are catalogs in their own right — carries both: options for the picker and dimensions for the secondary fields. Every option carries a term at both detail levels — the short professional phrase to inject into a prompt when you want a compact instruction ("whip pan left"), where label is display-only and promptHint is the full mechanism sentence. It is "" for a no-op (auto / none) option that injects nothing. These are the same catalogs that ship as pure data in @nodaro/shared — prefer importing the package when you can bundle it (see Parameter Picker Catalogs); the REST endpoints exist for clients that can’t.

Catalogs (server-driven projection)

GET /v1/catalogs returns every picker catalog in one call, projected to a single flat wire shape — the server-driven counterpart to the per-picker /v1/picker-catalogs/:nodeType. Its purpose is deployment curation: a self-hosted or managed deployment can register vendored catalog packs (replace / extend / deny a catalog’s options), and this endpoint reflects the registered, pack-composed set. A thin client that renders its own pickers therefore honors the deployment’s curation without shipping its own copy of the catalogs. Public, no auth, same 5-minute cache (Cache-Control: public, max-age=300).

Method Path Purpose
GET /v1/catalogs Every registered catalog ({ data: ProjectedCatalog[] }).

Query param (a bad value returns 400 validation_error):

Param Values Purpose
detail compact (default) / full compact: id, label, category, term, icon. full: additionally includes each option’s description and promptHint.

Each ProjectedCatalog is { nodeType, label, catalogId, kind, valueField?, defaultValue?, categoryOrder?, categoryLabels?, detail, options?, fields?, dimensions? } — single-dim catalogs carry options; multi-dim catalogs carry dimensions (one { field, label, options } per field); a single-dim catalog with secondary parameter fields (transition, character-fx: position / duration / intensity; character-motion: position / pace) carries both. Each option is { id, label, category?, term, icon?, description?, promptHint? }; term rides at both detail levels so a thin client can render label and inject the compact professional term without a second detail=full fetch. The shape is deliberately tag/policy-free.

Text → pickers (AI Fill)

POST /v1/text-to-picker fills pickers from a free-text scene/shot description — the “AI Fill” behind Nodaro Cine. Authenticated, credit-billed (an LLM call; same credit id as describe-to-picker).

Body: { text, targetPickers?, instructions?, origin?, llmModel?, reasoningEffort? } — omit targetPickers to analyze ALL analyzable pickers (the server fans out per family and merges). Returns { jobId, pickerJson, gaps? }: pickerJson is pickerType → dimension → chosen catalog id(s) (the same shape as describe-to-picker — hydrate pickers from it verbatim), and gaps lists attributes the text described that no catalog id represents well (surface as “we couldn’t infer X”). SDK: client.pickerCatalogs.analyzeText(...); CLI: nodaro pickers analyze "<text>".

Structured LLM output (any schema)

POST /v1/llm/structured runs one LLM call whose answer is forced to the JSON Schema you supply, validated against it, and handed back as an object. Authenticated and credit-billed under its own llm-structured feature id (priced per model tier). It is the generic primitive behind schema-authoring clients — Nodaro Studio composes a whole production plan with it.

Body: { system, input, jsonSchema, schemaName?, llmModel?, reasoningEffort?, maxRetries?, origin?, advancedMode?, temperature?, maxTokens? }. system and input are plain text (each at most 100,000 characters; input must be at least 1 character). schemaName names the forced-output tool the provider sees and caps at 64 characters. Omit llmModel and the call runs on gemini-3.6-flash, the generic llm-chat default. jsonSchema is a JSON Schema object (type: "object", at most 64 KB serialized and 20 levels of nesting) written in the keyword subset the server can convert: properties / required / additionalProperties (including the record form), items, string / number / integer / boolean, enum, const, anyOf / oneOf of concrete types (both convert to a union, below the root only), minimum / maximum / minItems / maxItems / minLength / maxLength, multipleOf, exclusiveMinimum and description. not, if / then / else, dependent* and external $ref are refused with 400, and so is anyOf / oneOf / allOf at the top level — the root must be a plain object (that is what a forced-tool schema is to the provider); put the alternatives under a property. One caveat worth reading twice: below the root, an anyOf of bare required branches — the usual “at least one of these fields” idiom — is accepted and not enforced (at the root it is refused like any other combinator). Express cross-field rules in your own validator.

Returns { jobId, output, usage: { inputTokens, outputTokens } }, where output has your schema’s shape. maxRetries (0–3, default 2) is how many times an invalid answer is fed back to the model together with its validation error before the call gives up; maxTokens must not exceed the chosen model’s own output limit (400 otherwise). The two sampling levers are not symmetric: maxTokens applies on every call — a deliberate departure from the LLM routes that put both levers behind the Advanced-mode gate — while temperature is silently ignored unless you also send advancedMode: true. Advanced mode pins the call to the vendor’s own API, where those levers take effect, and therefore bills one credit tier up; asking for it on a model with no direct lane is a 400 advanced_mode_unsupported. The call is synchronous and a single call may run several minutes: each attempt is allowed up to 240 seconds per provider lane, and a call that cannot reach its primary lane falls back to one alternate, so the ceiling is (maxRetries + 1) x 2 x 240 s — 24 minutes at the default maxRetries: 2, 32 at the maximum. Give your HTTP client a timeout sized against that, or use the asynchronous twin below and stop holding a connection open. Errors: 400 validation_error, 401, 402 (credits), 500 internal_error (the job row could not be created), 502 llm_error once the retries are spent, 503 provider_unavailable. SDK: client.llm.structured(body) — mind the client’s timeoutMs: the default 60 s is far shorter than this call can run, and even timeoutMs: 300_000 only covers a fast draft, not the ceiling above. Size it against your own maxRetries, or use the asynchronous twin below.

Asynchronous structured drafts (POST /v1/llm/structured/jobs)

The same call as a job. POST /v1/llm/structured/jobs takes the body above plus three optional fields and answers { jobId } at once. Poll GET /v1/jobs/:id/status: on completed, output_data is { output, inputTokens, outputTokens } with output in your schema’s shape; on failed, error_message says why. The row is yours to find again — GET /v1/jobs?type=llm-structured&origin=<your slug> lists every draft you started — so a client can leave and come back. (The synchronous route stores its result on a job row too, but only tells you the id with the answer.)

Credits: the LLM step is reserved at creation under llm-structured:<tier>, exactly as the synchronous route; a movie run’s analysis is reserved by the analysis route under its own id. Errors at creation: the synchronous route’s 400 / 401 / 402 / 503, plus — when videoUrl is set — the analysis route’s own answers, verbatim: 422 video_too_long / live_stream_not_supported / invalid_video_duration, or 402 for the analysis price; in every refused case nothing stays reserved — a refused analysis refunds the parent’s reservation. Cancelling the draft (POST /v1/jobs/:id/cancel) cancels a still-running analysis with it; a finished analysis stays. A retry is a new job. On an instance that proxies its LLM calls to nodaro.ai the route answers 503 provider_unavailable — use the synchronous route there. SDK: client.llm.structuredJob(body) and client.jobs.list({ type: "llm-structured", origin }).

Direct uploads

POST /v1/upload (multipart: file + type of image / video / audio) stores a file on the instance’s media host and returns its URL. Accepted audio formats include MP3, WAV, M4A/AAC, OGG, WebM and FLAC (audio/flac / audio/x-flac); size caps are enforced per media type (50 MB for audio). The SDK wraps this as client.uploads; MCP clients use prepare_audio_upload / request_audio_upload and friends.

The declared content type does not have to be the canonical one. The server resolves the part’s Content-Type before validating it: parameters are stripped (audio/webm;codecs=opusaudio/webm), well-known vendor spellings map to the format they mean (audio/vnd.dlna.adts — what Windows calls a plain .aac — → audio/aac; image/jpgimage/jpeg; video/movvideo/quicktime), and an uninformative type (application/octet-stream, or none at all) is resolved from the filename extension. A type that still resolves to nothing we accept is rejected with 400 validation_error listing the accepted formats. The resolved type is what comes back as mimeType and what the stored object is served as.

Media processing (free, synchronous)

POST /v1/media/process cuts or crops a stored file: body { sourceUrl, type: "video" | "audio", trim?: { startTime, endTime }, crop?: { x, y, width, height }, format?, deleteSource? }, answer { data: { url, thumbnailUrl, assetId, sizeBytes, mimeType, metadata } }. deleteSource: true removes the source object afterwards when it is yours and nothing else references it — the cut replaces the original. The free sibling of the priced trim-video node. SDK: client.media.process(input).

12. Credits (Cloud edition)

Three endpoints surface the caller’s credit balance and transaction history. All three are Cloud-edition only — on Community/Business they are not registered and return 404.

Method Path Query Purpose
GET /v1/credits/balance Return { total, subscription, topup, tier, effectiveTier }. total = subscription + topup. effectiveTier is the entitlement tier actually enforced — "payg" = no subscription but purchased credits (all models unlocked, no watermark, no daily cap). On a deployment-payer instance this is the caller’s own pool, which does not fund their runs — read the allowance below instead.
GET /v1/user/credits The fuller balance payload. On a deployment-payer instance it adds allowance: { granted, remaining, enforced } \| null — the caller’s per-user allowance in credits, enforced telling you whether the deployment refuses runs beyond it (false = shown, not enforced). null for the billing account itself (it holds the real pool) or when the figure could not be read; never treat null as zero.
GET /v1/credits/transactions limit (1–50, default 20), cursor (ISO timestamp for page-forward) Return { data: Transaction[], nextCursor }. Cursor is the created_at of the last row; pass it as ?cursor= on the next request. nextCursor is null when there are no more rows.

Transaction fields: id, created_at, credits_used, action, provider, status, metadata, payer ("user" or "workspace") and workspaceId (string | null). Rows with payer: "workspace" were paid by a class or team budget, not your balance.

status is the reservation’s billing lifecycle: "reserved" (credits held, not yet resolved), "committed" (charged — the run delivered), or "refunded" (the reservation was released — e.g. a provider safety-filter block, see Generate Image). The same lifecycle is exposed on job payloads as credit_status (§8), so you don’t have to look up the transaction to know whether a given job’s reservation was refunded.

metadata is an object carrying the run’s billing mechanics, projected to a fixed set of keys. Present when the ledger recorded them: model, from_sub and from_topup (which of your credit pools funded the run), is_app_run, allowance_delta, web_free_mode, status, loop_trim_refunded, surround_refine_refunded. Any other key is omitted, and metadata is always an object — {} when nothing applies — so it is safe to read without a null check. Credits, not currency, are the billing unit the API exposes.

All three routes use the same bearer-token auth as every other endpoint (ndr_… / ndr_app_… / Supabase JWT).

Top-up credits are valid for 12 months from purchase (subscription credits reset each billing cycle); spending draws subscription credits first. The /v1/billing/* routes (checkout, load sessions, auto-recharge, purchase history with receipt links, Stripe portal) are first-party-only: they reject API and OAuth-app tokens and are used from a logged-in Nodaro session — manage billing at app.nodaro.ai/billing.

On a deployment-payer instance the billing account manages the deployment’s pool and per-user allowances through /v1/deployment-billing/* (its /billing-admin page). Those routes exist only where a payer is configured (404 elsewhere) and answer 403 payer_required to anything but the billing account’s own browser session — a personal token or OAuth token gets that code even when the billing account owns it. POST /v1/deployment-billing/checkout answers 503 stripe_not_configured on a deployment that takes no card; the account is then topped up by the platform operator instead.

12b. Billing surface

Two endpoints let a client render cost and usage views without hard-coding “is this deployment metered?” — the deployment tells you which billing provider is registered and answers per-job / per-account cost lookups through it.

Method Path Auth Purpose
GET /v1/billing/surface Public (no token) Deployment-level projection — no per-user data, cacheable. Returns { data: { contract, providerId, displayUnit, canReport, canQuote, canAccount, mountCostTab, deploymentPayer } }. On a keyless / community install providerId is "none" and mountCostTab is false (no cost view). deploymentPayer is true when one billing account funds every user on the instance (see payer_balance_jwt_only, api_tokens_payer_only and user_allowance_exceeded in §8); which account that is is never exposed.
GET /v1/billing/account Bearer token Per-user account summary (AccountSummary) from the registered provider: { data: AccountSummary \| null }. data: null means the metering authority could not answer — clients MUST render that distinctly and never as a zero balance.

contract is the billing-surface contract version (an integer, currently 2). displayUnit is the unit a cost view should default to (e.g. "usd" or "credits") — it follows the registered metering authority, not the edition.

AccountSummary shape. The four base fields are always present: plan (a plain string; "unknown" is a real answer), balance (number | null), dailyAllowance (number | null), and unit. Contract v2 adds a set of optional, nullable rich fields a provider MAY expose; a provider that omits one is saying “no such concept”, and a client renders only the fields it receives:

Every money figure and per-category amount is number | null / a nullable money object: a null means unavailable, never 0. Render a null distinctly (e.g. an em dash), never as a free/zero cost.

Cost summary response (POST /v1/jobs/cost-summary). The credit field total_credits (top-level and per breakdown row) is number | null, the response carries unit — the registered provider’s display unit the credit figures are denominated in — and an unavailable count (jobs the metering authority could not price). A null total means no job in the batch had a known charge — it is NOT 0. Render a null value distinctly (e.g. an em dash), never as a free/zero cost. total_cost_usd (top-level and per row) is admin-only, like every USD figure across api/sdk/mcp: for a non-admin caller the key is absent (not null).

13. Job batch polling

The listing, plus two endpoints that poll multiple job statuses in a single round trip (useful for workflow UIs that track many concurrent jobs):

Method Path Purpose
GET /v1/jobs?limit=&cursor=&type=&origin=&attachToCharacterId= Your jobs, newest first, cursor-paginated: { data: Job[], next } (limit ≤ 100; pass next back as cursor). type matches input_data.type — the route that created the job (llm-structured, video-analysis, …); origin matches input_data.origin — the client app that sent it (studio, …). Both are exact-match and combine. A page may hold fewer than limit rows — even none — and still carry a next; page on next, never on data.length. attachToCharacterId is the per-character archive described under characters.
GET /v1/jobs/status?ids=a,b,c Comma-separated IDs, max 100. Returns { jobs: { id, status, output_data, error_message, error_hint, credit_status }[] }. Cross-user / non-existent IDs are silently omitted — reconcile locally.
POST /v1/jobs/batch-status Body { jobIds: string[] }, max 100. Returns { data: { id, status, output_data, error_message, error_hint }[] } (no credit_status on this route).

All three require jobs:read scope when using an OAuth token; admin tokens may see cross-user jobs. These endpoints are public API — they are used by the editor but are equally suited to external polling clients. input_data and output_data are public projections: server-only fields such as Recast’s private pre-watermark remux base are removed recursively for every caller, including administrators.

Video Pro segment estimates

POST /v1/credits/video-pro-estimate accepts provider, resolution, duration, aspectRatio, renderMethod, anchorMode, contextTailSec, planOnly, and the Video Pro segment controls. It reads prices without creating a job or reserving credits.

{"provider":"gemini-omni-flash","resolution":"720p","duration":12,"renderMethod":"keyframes","segmentMode":"short"}

The response is { "data": { "credits": 660, "upperBound": true } } in an example configuration with a 660-credit reservation. Read the live response for current prices. For Short/Long, upperBound identifies the pre-plan reservation limit; settlement follows the actual plan. A plan-only estimate covers the planning fee and returns upperBound: false.

segmentMode accepts short, long, or max and cannot be combined with numeric preferredSegmentSec or explicit segmentDurations. Short/Long first assign complete actions to source spans. A plan-only result’s sourceSegmentDurations and planCheckpoint can be passed back as sourceSegmentDurations and seedPlan with the same mode and generation settings. See Generate Video Pro.

13b. Generate Video Pro run control (Cloud; self-host via the nodaro.ai connection)

The segmented long-video engine (Generate Video Pro) generates one segment at a time and checkpoints between segments, so a run can be stopped gracefully and continued later:

Method Path Purpose
POST /v1/generate-video-pro/:jobId/stop Graceful stop of a processing run: the in-flight segment is abandoned (still billed — the provider keeps rendering it), remaining segments are skipped, everything completed is stitched into the job’s final video, and the untouched remainder of the reserve is refunded. Responds { jobId, stopping: true }; keep polling the job — it completes with output_data.pro.stopped = true and stoppedAtSegment. A pending job is cancelled with a full refund instead (the generic cancel response is forwarded).
POST /v1/generate-video-pro/continue Body { fromJobId, fromSegment? }. Starts a new job that reuses the parent run’s plan and delivered segments below fromSegment (1-based; default = first not-yet-delivered) and regenerates from there, billed only for the regenerated segments plus the flat pro fee. The parent must be terminal (stopped / failed with ≥1 delivered segment / completed — an explicit fromSegment on a completed run re-rolls its tail). Responds { jobId, continuedFromJobId, fromSegment, segmentCount }. Honors the Idempotency-Key header.

Both enforce ownership (404 on a foreign job) and 400 on non-pro jobs. Pricing details and worked examples: the node page’s Stopping and continuing a run.

13c. Recast (Cloud edition)

POST /v1/recast (regenerate an analyzed source video with your own cast — the engine behind recast.nodaro.ai) requires workflowId: the uuid of an existing workflow you own, which the recast run attaches to. Omitting it is a 400 workflow_id_required; an unknown or foreign id is a 404 workflow_not_found.

The full run lane (all Cloud-only; SDK: client.recast, MCP: the start_recast / get_recast_status / resolve_recast_gate verbs):

Method Path Purpose
POST /v1/recast/estimate Quote the run in credits ({ totalCredits, breakdown }) before buying. Free. Body mirrors create: analysisJobId, fidelity, resolution, segmentSec, renderMethod, interactive?, provider?.
POST /v1/recast Create the run — buys the plan. Returns { recastId }.
GET /v1/recast/:id Poll the run: { status, interactive?, capabilities?, audio? }interactive.next names the pending step or gate on interactive runs. A server with revisioned audio returns capabilities.audioLayers: 1.
POST /v1/recast/:id/start Start rendering a planned run (idempotent; the plan’s quote covered it). Returns { gvpJobId? }.
POST /v1/recast/:id/select Answer a pending pick — body { gate: "cast" \| "sheet" \| "anchors" \| "music", picks? / anchorPicks? / musicPick?, segment? / section?, finishAuto? }. The pick itself is free.
POST /v1/recast/:id/estimate-rescore Quote a revisioned Music replacement and/or complete desired mix. Free; returns { credits, audioRevision, noOp }.
POST /v1/recast/:id/rescore Apply the quoted audio operation. V2 returns { recastId, jobId }, or { recastId, noOp: true, audioRevision } without creating a job.

Interactive runs are server-driven: a platform cron advances every non-gate step, so a client only polls GET /v1/recast/:id and answers gates via /select. Gates only open for gate kinds the run’s create declared in clientCapabilities (e.g. "clientCapabilities": ["sheet-gate"]) — a client that doesn’t declare a capability never sees that gate; the platform decides it automatically instead. Pass finishAuto: true on /select to hand this and every remaining gate to the automatic critic.

Revisioned audio layers

Only enable an audio-layer UI when status includes capabilities.audioLayers: 1 and the selected completed take has an audio manifest. The manifest is server-authored:

interface RecastAudioManifestV1 {
  version: 1
  revision: string
  mode: "bed" | "replace"
  present: { music?: true; video?: true }
  layers: { music?: { url: string }; video?: { url: string } }
  bakedEffectiveGain: { music?: number; video?: number }
  pendingRescore?: {
    jobId: string
    requestId: string
    state: "pending" | "running"
    expectedAudioRevision: string
    requestedEffectiveGain: { music?: number; video?: number }
  }
}

present is the logical layer set; layers is only the subset with a browser-ready audio derivative. Do not infer that a missing derivative is absent from the downloadable video. bakedEffectiveGain is the effective integer percentage already rendered into the current result. The only generated video URL exposed to clients remains resultUrl; private remux bases are not part of this contract.

Quote and Apply use the same prospective operation:

{
  "expectedAudioRevision": "server-revision",
  "sections": [{ "index": 0, "brief": "Sparse analogue pulse" }],
  "mix": {
    "music": { "gain": 60, "muted": false },
    "video": { "gain": 85, "muted": false }
  }
}

Send at most one Music replacement: either audioUrl, or one or more sections. A mix-only request is also valid. Gains are finite linear percentages; the server rounds and clamps them to 0–200, and a muted lane has an effective gain of zero. Address only lanes in present (or Music introduced by this request); replace-mode takes do not have a Video lane. The resolved operation cannot leave every layer silent.

For Apply, add a UUID requestId and send the same expectedAudioRevision. Keep that UUID stable only while retrying the identical transport request. A successful no-op reserves no credits and creates no job. Otherwise poll status: audio.pendingRescore survives reloads and disappears when the new revision is published or the operation fails. Refresh status before another operation.

Expected validation failures are 400 (validation_error, duplicate_section, unknown_section, all_audio_silent). State conflicts are 409 (audio_layers_unavailable, audio_layer_unavailable, audio_preview_unavailable, rescore_sections_unavailable, legacy_mix_mismatch, stale_audio_revision, rescore_in_progress, idempotency_conflict). audio_preview_unavailable means the layer is logically present but its browser/ffmpeg-ready derivative is not available for an honest custom mix. A stale-revision or in-progress response includes the current revision or live job when available. Quoting never reserves credits and still returns the final price when the current balance is insufficient; the paid route can return the normal 402 insufficient_credits response.

Revisioned replacement requests should normally send the complete desired mix, even when its gains equal the current bake. Omitting mix is accepted only when the resolved output exactly matches the fixed legacy recipe: Music 35 + Video 100 in bed mode, or Music 100 in replace mode. Any other baked state returns 409 legacy_mix_mismatch instead of publishing gains that do not match the file.

Compatibility: a request with no requestId, expectedAudioRevision, or mix and exactly one of audioUrl / sections uses the legacy rescore behavior. New clients should use the revisioned contract above.

13d. Authored script import (Cloud edition)

Turn an LLM-authored screenplay JSON into a recast — no source video. All three endpoints are free.

The document. meta (durationSec, width, height, aspectRatio of "16:9" or "9:16" — width/height must agree — and a required title, which names the project), optional look, slots[] (role: person | object | background), and scenes[] (contiguous from 0, each ≤ 8s, total from 4s up to the platform run cap; over-cap documents are rejected, never truncated). Do not write sceneNumber/slotRefs/visualResolved — the server derives them and ignores supplied values, which is also why pasting a full exported analysis (the editor’s “Copy JSON”) works as-is.

The returned jobId is a standard analysis job: create a recast from it per §13c with analysisJobId + fidelity: "faithful" + rightsAttested: true.

14. Pipelines

Story-to-Video pipelines orchestrate multi-stage AI production: script → characters → objects → locations → shot list → scene images → animate + audio + edit → post merge.

Branch (re-run from stage)

POST /v1/pipelines/:id/branch

Create a new pipeline by re-running from a completed stage. The original pipeline must be in status='completed'. Upstream stages clone forward (status=’approved’), the branch stage starts running, downstream stages are created fresh by the orchestrator.

Body: { fromStage: "script" | "characters" | "objects" | "locations" | "shot_list" | "scene_images" | "animate_audio_edit" | "post_merge" }

Response (201): { pipelineId: string, clonedStages: string[], clonedEntities: number }

Errors: 400 (pipeline_not_completed, invalid_stage) · 404 (pipeline_not_found) · 403 (forbidden) · 401 (unauthorized)

Scope (OAuth): pipelines:execute

Asset rows are NOT duplicated — pipeline entities reference the same asset_ids (assets are content-addressed by R2 path; safe to share across pipelines). Chat turns (Guided Mode, Phase 1D.2) explicitly do NOT clone — the branched pipeline starts with empty chat history per chat-enabled stage.

15. Prompt Wizard

AI assistance for writing prompts for generation nodes. One endpoint, three actions — discriminated by the action field. Credit-guarded (reserves credits per call).

POST /v1/prompt-helper/wizard

Action Body Response
analyze { action, nodeType, prompt?, provider?, style?, aspectRatio?, duration?, llmModel?, reasoningEffort?, advancedMode?, temperature?, maxTokens?, nodeContext?, userPreference? } { jobId, questions }
generate { action, nodeType, selections[], originalPrompt?, ... } { jobId, prompt, recommendedModel? }
enhance { action, nodeType, prompt?, ... } (no selections) { jobId, prompt, recommendedModel? }

recommendedModel is present on generate / enhance when the wizard can suggest a provider/model for the target node type.

Errors: 400 validation_error · 401 unauthorized · 503 provider_unavailable · 502 malformed_response · 500 llm_error.

The same endpoint is wrapped by the SDK (client.promptHelper.{analyze, generate,enhance}), the MCP tools (analyze_prompt / generate_prompt / enhance_prompt), and the CLI (nodaro prompt wizard/analyze/generate/enhance).

16. Presets

Read your saved node presets and the built-in factory catalog. Read-only over the API — creating/editing presets stays in the editor. A preset’s data is captured node config; merge it into a node’s data when you build a workflow to “apply” it.

Method Path Query Purpose
GET /v1/node-presets nodeType (optional) Your custom presets (newest first).
GET /v1/node-preset-groups nodeType (optional) Your preset folders/sections.
GET /v1/node-presets/factory nodeType (required) The built-in catalog for a node type.

A custom preset has { id, nodeType, name, description?, data, groupId?, tags, sortOrder, createdAt, updatedAt }. The factory response is { data: FactoryPreset[] }, where each entry has { id, name, description?, group?, groupKind?, data }.

App settings presets

The same personal library supports app settings under logical namespaces. recast-render stores Recast generation settings; it is not an executable node type. Factory entries are read-only and personal entries remain private to their owner across devices.

Its data is a strict complete snapshot:

{
  "schemaVersion": 1,
  "provider": "seedance-2-5",
  "resolution": "480p",
  "segmentSec": "max",
  "renderMethod": "extend",
  "anchorMode": "upfront",
  "citeStyle": "bare",
  "promptTiming": true,
  "textOnly": false,
  "interactive": true,
  "anchorGates": false,
  "musicGates": true,
  "musicSource": "generated"
}

segmentSec accepts max, scenes-max (Long), or scenes (Short). resolution accepts 480p, 720p, 1080p, or 4k; renderMethod accepts extend or keyframes; anchorMode accepts upfront, progressive, or none; citeStyle accepts bare or rich; musicSource accepts generated, original, or upload. All switches are booleans. Unknown fields and schema versions are rejected on create, replacement and import. Source media, cast references, prompts, uploaded track URLs, rights attestations and results are excluded. Applying an Original or Upload music choice uses the target project’s own media; it never prepares or uploads audio automatically.

First-party authenticated clients can create with POST /v1/node-presets, rename or replace data with PATCH /v1/node-presets/:id, and delete with DELETE /v1/node-presets/:id. Programmatic-token writes remain disabled. PATCH accepts optional expectedUpdatedAt in its body; DELETE accepts it as a query parameter. Send the timestamp returned by the library: a concurrent change returns 409 conflict and requires a refresh. Applying a preset updates settings and refreshes the quote; it does not submit generation. Validate the target model’s current capabilities before generating.

A preset may carry promptPrefix / promptSuffix; the MCP generation verbs wrap the caller’s prompt with them when presetId is passed (see Prompt pre & post text).

Auth/scope: same bearer-token auth as every other endpoint (ndr_… / ndr_app_… / Supabase JWT). OAuth app tokens additionally need the presets:read scope (no-op for user / API-key auth — you own the resources).

# Your custom generate-image presets
curl -s https://app.nodaro.ai/v1/node-presets?nodeType=generate-image \
  -H "Authorization: Bearer $NODARO_TOKEN" | jq '.data[].name'

# Built-in catalog
curl -s "https://app.nodaro.ai/v1/node-presets/factory?nodeType=generate-image" \
  -H "Authorization: Bearer $NODARO_TOKEN" | jq '{count: (.data|length)}'

Favorites

Per-user favorites let you star presets (factory or custom) so they surface at the top of the editor’s preset dropdown. These routes are editor-auth / first-party: the reads also accept OAuth app tokens carrying the presets:read scope, but the writes are first-party only (no OAuth scope grants them).

Method Path Query / Body Purpose
GET /v1/node-presets/favorites nodeType (required) Your favorited preset ids for that node type, most-recent first. Returns { data: string[] }.
POST /v1/node-presets/favorites body { nodeType, presetId } Add a favorite (idempotent). Returns { data: { success: true } }.
DELETE /v1/node-presets/favorites nodeType, presetId (required) Remove a favorite. Returns { data: { success: true } }.

A favorite id is either a factory preset id (e.g. generate-image/character-board) or a user-preset uuid. Because factory ids contain a /, url-encode presetId in the DELETE query string.

# Your favorited generate-image presets (most-recent first)
curl -s "https://app.nodaro.ai/v1/node-presets/favorites?nodeType=generate-image" \
  -H "Authorization: Bearer $NODARO_TOKEN" | jq '.data'

# Favorite a factory preset
curl -s -X POST "https://app.nodaro.ai/v1/node-presets/favorites" \
  -H "Authorization: Bearer $NODARO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"nodeType": "generate-image", "presetId": "generate-image/character-board"}' | jq .

# Remove it again (url-encode the "/" in the factory id)
curl -s -X DELETE "https://app.nodaro.ai/v1/node-presets/favorites?nodeType=generate-image&presetId=generate-image%2Fcharacter-board" \
  -H "Authorization: Bearer $NODARO_TOKEN" | jq .

17. Community

The Community Library is an admin-curated catalog of shared characters, locations, and objects. Admins publish; any logged-in user browses and clones listings into their own library as independent copies. See Community Library for the feature overview, the cloning model, and the likeness/consent safety rules.

Multi-user editions only. These routes are registered on Business and Cloud instances. On a Community (single-user) instance they are not registered and return 404.

entity_type is one of character / location / object. Listing records returned by the read routes are sanitized to public columns: id, entity_type, creator_display_name, slug, title, description, category, style, tags, preview_media_url, preview_images, clone_count, favorite_count, created_at.

User routes (session auth)

All user routes require an authenticated bearer token (ndr_… / ndr_app_… / Supabase JWT) and are scoped to the calling user.

Method Path Purpose
GET /v1/community/browse List public listings. Returns { data: Listing[], nextCursor }.
GET /v1/community/detail/:slug Fetch a single listing by slug. Returns { data: Listing }; 404 not_found if missing/inactive.
GET /v1/community/favorites The listings you’ve favorited. Returns { data: Listing[] }.
POST /v1/community/listings/:id/clone Copy a listing into your library. Body { entityType }. Returns { entityType, id }.
POST /v1/community/listings/:id/favorite Toggle favorite. Returns { favorited } (true after adding, false after removing).
POST /v1/community/listings/:id/report Flag a listing for moderation. Body { reason }. Returns { ok: true }.

GET /v1/community/browse query params:

Param Type Notes
entityType character \| location \| object Filter to one asset kind.
q string Full-text search across title / description / tags.
category string Filter to a single category.
sort popular \| newest Order by most-cloned or newest. Defaults to newest.
cursor string Opaque cursor from a previous page’s nextCursor.
limit number Page size, capped at 50 (default 20).

nextCursor is an opaque token; pass it back as ?cursor= to page forward. It is null when there are no more results.

POST /v1/community/listings/:id/clone copies the listing’s assets into your own storage — the clone is an independent snapshot that survives the original being changed or taken down. Body is { entityType } (must match the listing’s kind). When called with an OAuth app token it requires the assets:write scope (no-op for user / API-key auth — you own the resources). If your account is over its storage limit the route returns 413 storage_limit_exceeded.

POST /v1/community/listings/:id/report accepts a reason of real_person_no_consent (depicts a real person without consent), inappropriate, ip_violation, or other.

# Browse the newest shared characters
curl -s "https://app.nodaro.ai/v1/community/browse?entityType=character&sort=newest" \
  -H "Authorization: Bearer $NODARO_TOKEN" | jq '.data[] | {slug, title}'

# Clone one into your library
curl -s -X POST "https://app.nodaro.ai/v1/community/listings/$LISTING_ID/clone" \
  -H "Authorization: Bearer $NODARO_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"entityType": "character"}' | jq .
# → { "entityType": "character", "id": "<new-asset-id>" }

Admin routes (admin auth)

Publishing and moderation are admin-only — these routes require an admin token. entityType in the path is one of character / location / object.

Method Path Purpose
POST /v1/admin/community/:entityType/:id/publish Publish one of your own assets to the catalog. Returns { slug, id }.
DELETE /v1/admin/community/listings/:id Unlist + deactivate a listing and purge its preview blobs. Returns { ok: true }.
GET /v1/admin/community/reports List open (unresolved) reports. Returns { data: Report[] }.
POST /v1/admin/community/listings/:id/takedown Take a reported listing down: deactivate it, resolve its open reports, purge preview blobs. Returns { ok: true }.

POST /v1/admin/community/:entityType/:id/publish body:

Field Type Required Notes
title string yes 1–120 chars.
description string no Up to 2000 chars.
category string no Up to 60 chars.
style string no Up to 60 chars.
tags string[] no Up to 20 tags, 40 chars each.
attestation true yes Must be literally true — the admin attests they have rights to share the asset.
likenessAttestation boolean conditional Required (true) for entityType === "character" — confirms any real person depicted consented and is 18+. Optional for locations/objects.

The source asset (:id) must be one the admin owns; otherwise the route returns 404 not_found. A character publish without likenessAttestation: true is rejected with 400 validation_error. See Community Library → Safety for why the likeness attestation is mandatory for characters.

# Publish a character (likeness attestation required)
curl -s -X POST "https://app.nodaro.ai/v1/admin/community/character/$CHARACTER_ID/publish" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "title": "Detective Mara",
        "description": "Noir-styled investigator",
        "category": "people",
        "tags": ["noir", "detective"],
        "attestation": true,
        "likenessAttestation": true
      }' | jq .
# → { "slug": "detective-mara", "id": "<listing-id>" }

18. Studio timeline export

Cloud edition only. Export a Studio production timeline to a portable editing-project file so you can finish the cut in an external NLE. Registered on Cloud instances; on Community/Business it is not registered and returns 404.

POST /v1/freecut-export

Serialize a timeline (your scene composites + the cut decisions between them) into either a FreeCut JSON (freecut-v1) or a FCPXML (fcpxml-v1.10) project file, upload it to your storage, and return the file URL.

This endpoint is 0 credits and rate-limited to 10 requests / minute. Auth is the same bearer token as every other endpoint (ndr_… / ndr_app_… / Supabase JWT); no scope is required.

Body:

Field Type Required Notes
format "json" \| "fcpxml" yes json → FreeCut JSON (freecut-v1, application/json); fcpxml → Final Cut Pro XML (fcpxml-v1.10, application/xml).
timeline object yes The timeline to serialize (see below).
name string no Up to 200 chars. A human label for your records.

timeline object:

Field Type Required Notes
scenes Scene[] yes ≥ 1 scene, in playback order. One video clip is emitted per scene.
musicAssetUrl string no (default "") URL of the music track. Empty string skips the music track/lane entirely.
narrationAssetUrl string no URL of a narration track. When present, emitted as a separate audio track/lane (not pre-mixed with music).
fadeOutDurationSec number no (default 0.8) Tail fade-out applied to the music clip (JSON only; FCPXML carries no fade primitive).

Scene object (each entry of timeline.scenes):

Field Type Required Notes
sceneEntityId string yes Non-empty id for the scene.
compositeUrl string (URL) yes The pre-merged scene composite video — becomes one clip on the video track.
shots Shot[] yes ≥ 1 shot. Drives the scene’s duration and, via the first/last shot’s cut_decision, its head/tail trim and out-transition.

Shot object (each entry of scene.shots):

Field Type Required Notes
shot_id string yes Non-empty shot id.
duration_seconds number (≥ 0) yes The shot’s length; the scene clip’s full duration is the sum of its shots.
cut_decision object no The transition leaving this shot + in/out trims (see below).

cut_decision object:

Field Type Required Notes
in_offset_sec number yes Head-trim into the scene composite (applied from the first shot’s cut_decision).
out_offset_sec number yes Tail-trim off the scene composite (applied from the last shot’s cut_decision).
transition_to_next "hard_cut" \| "dissolve" \| "match_cut" \| "overlap" yes Transition into the next scene. dissolve/overlap overlap the timeline by their duration; hard_cut/match_cut butt-join (no overlap).
transition_duration_sec number no Overrides the per-type default (hard_cut/match_cut → 0, overlap → 1.0, dissolve → 0.5).

Response (200):

{ "url": "https://…/exports/<userId>/freecut-<uuid>.json", "format": "json", "assetId": "<uuid-or-null>" }

Errors: 400 validation_error (the issues array carries the Zod details) · 401 unauthorized.

Concatenation note: when none of a timeline’s shots carry a cut_decision, the export is a simple concatenation — one clip per scene laid end-to-end at cumulative positions, all joins are hard cuts, and the music (if any) is a single track spanning the whole timeline. Per-shot trims within a scene are not honored; only the first and last shot’s cut_decision of each scene contribute (head trim / tail trim / out-transition), because the scene composite is already pre-merged.

TOKEN="ndr_..."
BASE="https://app.nodaro.ai"

# Export a two-scene timeline as FreeCut JSON (simple concatenation —
# no cut_decision on any shot).
curl -s -X POST "$BASE/v1/freecut-export" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "format": "json",
        "name": "My Cut",
        "timeline": {
          "musicAssetUrl": "https://…/music.mp3",
          "scenes": [
            {
              "sceneEntityId": "scene-1",
              "compositeUrl": "https://…/scene-1.mp4",
              "shots": [{ "shot_id": "s1", "duration_seconds": 4 }]
            },
            {
              "sceneEntityId": "scene-2",
              "compositeUrl": "https://…/scene-2.mp4",
              "shots": [{ "shot_id": "s2", "duration_seconds": 6 }]
            }
          ]
        }
      }' | jq .
# → { "url": "https://…/exports/<userId>/freecut-<uuid>.json", "format": "json", "assetId": "<uuid>" }

Voice, Voice Changer Pro & media endpoints

Everything the voice stack exposes is plain REST under /v1/ — the same async-job contract as the rest of the platform: POST returns { jobId }, poll GET /v1/jobs/:id/status (or batch, §13) until status is completed, read the result from output_data.

Voices & clones

Method Path Purpose
GET /v1/voices Premade voice catalog (name, voice_id, gender/accent/age metadata).
GET /v1/voices/library Search the shared Voice Library (?search=, ?gender=, ?language=, … ?page=, ?page_size=).
GET /v1/voice-clones List the voice clones you created before cloning was retired.
POST /v1/voice-clones, /v1/voice-clones/from-url Retired — voice cloning is no longer offered. Both answer 410 with error.code = "voice_cloning_retired". Use /v1/voice-design for a new custom voice.
PATCH /v1/voice-clones/:id Rename / edit an existing clone.
DELETE /v1/voice-clones/:id Delete an existing clone.
POST /v1/voice-design Design a synthetic voice from a description ({ text, voiceDescription, model?, loudness?, guidanceScale?, seed?, quality?, shouldEnhance? }) → job.
POST /v1/voice-remix Speak a text in a described voice, no cloning ({ text, voiceDescription }) → job.
POST /v1/dubbing Translate-and-revoice audio OR video ({ audioUrl \| videoUrl \| sourceUrl (exactly one), targetLanguage, sourceLanguage?, numSpeakers? (0=auto), disableVoiceCloning?, dropBackgroundAudio?, startTime?, endTime?, highestResolution?, useProfanityFilter?, targetAccent?, watermark? }) → job. Video mode delivers output_data.videoUrl + the dubbed audioUrl. Priced per minute of the dubbed span (min 1); span capped at 30 minutes (413 past it — the start/end window is the lever).

The id to use everywhere a voice is accepted is the clone’s elevenlabsVoiceId (create/list responses) or the catalog’s voice_id.

Voice Changer & Voice Changer Pro

Method Path Purpose
POST /v1/voice-changer Single-voice re-voicing of an audio track or a talking video ({ voiceId, audioUrl? \| videoUrl?, model?, stability?, similarityBoost?, style?, useSpeakerBoost?, seed?, removeBackgroundNoise? }) → job.
POST /v1/voice-changer-pro Multi-speaker recast (Cloud; self-host runs it through the nodaro.ai connection): orderedVoices maps detected speaker N to entry N (string id, per-voice settings object, or null keep-slot). output: "video" (default) renders the finished result; output: "stems" returns dry per-track stems for an interactive mix. Pass a prior analysis to skip re-detection. → job.
POST /v1/voice-changer-pro/analyze Detect the speakers WITHOUT recasting (Cloud only — the interactive analyze/mix/export flow is not relayed to self-host yet): separates voice from music once and diarizes, returning speakers (id, segments, first-appearance, word count, snippet), detected language, and the persisted stem urls. suggestTitle: true adds an LLM title. → job.
POST /v1/voice-changer-pro/export Render the final video from a mixed track set (Cloud only): { videoUrl, tracks: [{ url, gain 0–200, muted, kind?: "voice"\|"background" }] (≤16), voiceFx? }. The video stream is copied, never re-encoded; at least one track must be un-muted. → job.

Credits: recast charges per mapped speaker; analyze and export are flat-priced (see the Voice Changer Pro node page for the formula). Off Cloud, the three voice-changer-pro* routes are absent (404).

Media ingestion

Method Path Purpose
POST /v1/download-video Import a social video (YouTube/TikTok/Instagram/X/Facebook) or a direct video file link into storage ({ url, maxHeight?, sectionStartSec?, sectionEndSec?, requireAudio? }). A result with no audio stream fails unless requireAudio: false. At most 4 downloads run per account at once — a fifth answers 429 too_many_downloads. Returns { downloadId } — not a job.
GET /v1/download-video/progress/:downloadId Live progress as server-sent events ({ phase, percent, videoUrl?, error? } every ~500ms; stream ends on completed/failed).
POST /v1/video-metadata Probe duration/dimensions/title without downloading ({ url }). Direct read, not a job.
POST /v1/trim-video Trim a video ({ videoUrl, startTime?/endTime? \| keepFirstSeconds? \| keepLastSeconds? \| trim*Frames/Seconds }) → job.
POST /v1/trim-audio Trim/extract audio ({ videoUrl? \| audioUrl?, startTime?, endTime?, audioFormat?: mp3\|wav\|aac }) → job.
POST /v1/silence-detect Detect silent spans in an audio or video source, local FFmpeg, 1 credit, keyless ({ audioUrl, thresholdDb?: -35, minSilenceMs?: 700, padMs?: 120 }; output_data.json = { version, ranges: [{ startMs, endMs }], durationMs }) → job.
POST /v1/still-to-video One still image + one audio track → MP4, local FFmpeg, 0 credits ({ imageUrl, audioUrl, motion?, intensity?, resolution?, aspectRatio?, fps?, fit?, padColor? }; output duration = the audio’s duration, no duration field) → job.
POST /v1/slideshow 2–100 images + one optional audio track → MP4 slideshow, local FFmpeg, 0 credits ({ imageUrls[], audioUrl?, imageDurations?[] (null=auto), perImageDuration?, transition?, transitionDuration?, motion?, intensity?, resolution?, aspectRatio?, fps?, fit?, padColor? }; with audio the duration IS the audio’s — pinned-row mismatches scale proportionally, disclosed in output) → job.
POST /v1/save-to-storage Server-side copy of an external URL into storage ({ mediaUrl, filename?, mediaType? }) → job.

Social connections & publishing

Connect flows are popup-based and meant for the web app; publishing is available to personal tokens (OAuth apps are deliberately blocked from managing connections).

Method Path Purpose
GET /v1/social/providers Registry of supported networks with per-deployment availability: { id, label, connectKind, editor, capabilities, available, missingEnv?, setupHint? }. Unconfigured networks are listed with available: false — never hidden.
GET /v1/social/auth-url?platform= Start an OAuth connect (popup URL). 400 provider_not_configured (with the missing env var names) when the deployment lacks that network’s app credentials.
GET /v1/social/callback/:platform OAuth redirect target (public). For Facebook/Instagram logins managing multiple Pages/accounts, responds with an account picker page instead of silently connecting the first account.
POST /v1/social/connect/finalize Completes an account-picker selection ({ token, accountId }; the one-time token authorizes the call — public route, popup-internal).
GET /v1/social/connections List the caller’s connected accounts.
DELETE /v1/social/connections/:id Disconnect an account.
POST /v1/social/telegram/connect Connect Telegram by pasting a bot token ({ botToken }).
POST /v1/social/connect/custom Connect a custom_fields network ({ platform, fields }) — Bluesky, Dev.to, Hashnode, Medium, WordPress, Lemmy. Field specs come from GET /v1/social/providers (customFields); the credential is validated against the network before saving.
POST /v1/social/publish Publish now ({ platform, action, connectionId?, caption?, mediaUrl? \| mediaItems?, … }) → job. 10 credits. Retry semantics differ by failure: 503 publish_retryable means nothing was posted and the identical request is safe to re-send, while 500 publish_failed means the outcome is unknown — re-sending it can duplicate the post.
POST /v1/social/scheduled-posts Schedule a publish ({ connectionId, action, scheduledAt, caption?, media?: [{type, r2Key \| url}], … }). Media must be assets hosted on this deployment (stable refs — resolved to fresh URLs at publish time; foreign URLs are rejected). 1 credit, charged at publish.
GET /v1/social/scheduled-posts?from=&to=&status= List the caller’s scheduled posts (calendar range).
PATCH /v1/social/scheduled-posts/:id Edit while still queued/draft (409 not_editable once publishing).
DELETE /v1/social/scheduled-posts/:id Cancel a queued post (soft — history retained).

Audio primitives

Method Path Purpose
POST /v1/audio-separation Demucs stems ({ audioUrl, mode?: vocal_instrumental\|stems, quality?: auto\|fast\|best }) → job.
POST /v1/audio-isolation Voice isolation / denoise ({ audioUrl }) → job.
POST /v1/audio-fx Reverb/echo/telephone/megaphone ({ audioUrl, preset?, mix?, delayMs?, decay?, eqLow?, eqHigh? }) → job.
POST /v1/mix-audio Sum 2–20 tracks ({ audioUrls, trackVolumes? }) → job.
POST /v1/adjust-volume Level/normalize/fade ({ audioUrl? \| videoUrl?, volume?, normalize?, fadeIn?, fadeOut? }) → job.
POST /v1/combine-audio Concatenate segments ({ segments: [{ url, startTime?, endTime? }] }) → job.

Worked example: recast a multi-speaker interview end-to-end

The interactive Voice Changer Pro flow — ingest → detect → recast to stems → mix → export — over plain REST. (One-shot recast is the same second call with output omitted and no prior analyze.)

BASE=https://app.nodaro.ai
AUTH="Authorization: Bearer $NODARO_ACCESS_TOKEN"

# 0. Ingest: import the interview from YouTube (or skip if you have a URL)
DL=$(curl -s -X POST $BASE/v1/download-video -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"url":"https://youtu.be/XXXX","maxHeight":720}' | jq -r .downloadId)
curl -sN $BASE/v1/download-video/progress/$DL -H "$AUTH"   # SSE until phase=completed → videoUrl
VIDEO=# the completed event's videoUrl

# 1. Detect the speakers (charges the flat analyze price; recast not yet committed)
JOB=$(curl -s -X POST $BASE/v1/voice-changer-pro/analyze -H "$AUTH" -H 'Content-Type: application/json' \
  -d "{\"videoUrl\":\"$VIDEO\",\"suggestTitle\":true}" | jq -r .jobId)
# poll until completed, then keep the whole output_data as the analysis fast-path
ANALYSIS=$(curl -s "$BASE/v1/jobs/$JOB/status" -H "$AUTH" | jq .data.output_data)
echo $ANALYSIS | jq '.speakers[] | {id, firstStartSec, wordCount, snippet}'   # pick voices per speaker

# 2. Recast to dry stems, reusing the analysis (no re-detection, re-recast as often as needed)
JOB=$(curl -s -X POST $BASE/v1/voice-changer-pro -H "$AUTH" -H 'Content-Type: application/json' \
  -d "{\"videoUrl\":\"$VIDEO\",\"orderedVoices\":[\"Rachel\",null,\"Aria\"],
       \"output\":\"stems\",\"analysis\":$ANALYSIS}" | jq -r .jobId)
STEMS=$(curl -s "$BASE/v1/jobs/$JOB/status" -H "$AUTH" | jq .data.output_data)  # per-track stem urls

# 3. Mix in your UI (levels / mutes / fx are free to iterate), then render once
curl -s -X POST $BASE/v1/voice-changer-pro/export -H "$AUTH" -H 'Content-Type: application/json' -d "{
  \"videoUrl\": \"$VIDEO\",
  \"tracks\": [
    { \"url\": \"<voice stem 0>\", \"gain\": 100, \"muted\": false },
    { \"url\": \"<voice stem 1>\", \"gain\": 90,  \"muted\": false },
    { \"url\": \"<background stem>\", \"gain\": 70, \"muted\": false, \"kind\": \"background\" }
  ],
  \"voiceFx\": { \"preset\": \"hall\", \"wetDryMix\": 25 }
}"   # → { jobId }; the completed job's output_data.videoUrl is the finished video

The same chain is one method per step in the SDK (client.voices.analyzeclient.voices.recast({ output: "stems", analysis })client.voices.exportMix) and one command per step in the CLI (nodaro voice analyzevoice recast --output stems --analysis-filevoice export — see the CLI reference).

19. SDK alternative (TypeScript)

The same backend is fronted by a typed TypeScript client:

npm install @nodaro/sdk
import { createClient, StaticTokenAuth } from "@nodaro/sdk"

const client = createClient({
  baseUrl: "https://nodaro.example.com",
  auth: new StaticTokenAuth(process.env.NODARO_TOKEN!),
})

// Inspect a workflow.
const schema = await client.workflows.schema(workflowId)

// Run it (async — kick off + poll yourself).
const exec = await client.workflows.run(workflowId, {
  inputs: { "text-prompt-1": { text: "a cat at sunset" } },
})

// Or sync — wait up to 120s.
const result = await client.workflows.runAndWait(workflowId, {
  inputs: { "text-prompt-1": { text: "a cat at sunset" } },
  timeoutSeconds: 120,
})

console.log(result.outputs)

The SDK works identically with API tokens and OAuth tokens — pass either to StaticTokenAuth. It also has supabaseAuth for browser apps. See SDK Quickstart and the SDK Reference for the full surface.

Character LoRA training

Cloud edition only. Trains a Flux LoRA on Replicate for a character so generate-image can route through the trained model for highest-fidelity identity match. See Character Training for the user-facing feature doc.

POST /v1/characters/:id/train — start training

Reserves 1,500 credits and submits a training to Replicate. Requires the character to have ≥ 4 reference photos across: source_image_url, reference_photos, expressions, poses, angles, body_angles, lighting_variations.

character_sheet is excluded from the training-image count. Its composite views (front/side/back) overlap with angles/body_angles and its DB column shape cannot be reduced to a simple URL list, so the training helper ignores it entirely.

Response (202):

{ "jobId": "uuid", "trainingId": "<replicate-id>", "triggerWord": "TOK_<slug>_<6hex>" }

Errors:

Rate-limited to 3 / minute per token.

GET /v1/characters/:id/training — poll status

Response:

{
  "status": "untrained" | "queued" | "training" | "succeeded" | "failed" | "cancelled",
  "trainingId": "<replicate-id>" | null,
  "error": "<message>" | null,
  "trainedAt": "ISO8601" | null,
  "version": "nodaroai/char-<id>:<hash>" | null,
  "triggerWord": "TOK_<slug>_<6hex>" | null,
  "imageCount": 12 | null
}

DELETE /v1/characters/:id/lora — tear down

Cancels any in-flight training (refunds reserved credits), deletes the Replicate model (nodaroai/char-<characterId>), and nulls out the LoRA columns on the character row.

Response: { "ok": true }

Routing decision

When you call POST /v1/generate-image with a prompt that @mentions a single trained character (and that character is wired upstream of the node), the orchestrator transparently swaps:

The credit identifier becomes flux-lora-character (20 cr). Multi-character mentions fall back to the selected provider + ref injection.

Cine shots — share → remix records

Small persisted records of a builder state (picker selections, prompts, chosen models, entity refs) behind an opaque short id, powering /s/:id share links and one-click remixing.

Endpoint Auth Purpose
POST /v1/shots Bearer Create; body carries mode, selectionState (verbatim { pickerNodeType → valueId \| { field: valueId } }), optional freeText / negativePrompt / assembledPrompt / perModelPrompts / models / entityRefs / resultUrls, and visibility (default private). Returns { id }.
GET /v1/shots/:id public Read for the share page / remix hydration. The id is the capability; private shots return 404 to everyone but their owner. Rate-limited per IP.
PATCH /v1/shots/:id owner Update any subset — including flipping visibility to public (the explicit “make shareable” action).
DELETE /v1/shots/:id owner Delete.

Rules: resultUrls accept only plain public http(s) URLs (signed URLs are rejected — their tokens must not leak into a shareable record). Records carry schemaVersion; hydrators should skip-with-note on ids referencing catalog entries that no longer exist rather than fail the remix.

See also

9. OpenAPI spec & other languages (Go, Rust, Python, …)

The REST surface works from any language — bearer token, JSON in/out, the error envelope from section 8. For typed clients, a machine-readable OpenAPI 3.1 spec is served live:

https://app.nodaro.ai/v1/openapi.json

It is a curated subset covering the automation core: workflows (run / executions), jobs (status polling), node discovery (/v1/nodes), the flagship generation endpoints (/v1/generate-image, /v1/generate-video — every node type follows the same POST /v1/{node-type} shape), OAuth token exchange, and credit cost lookup. Generate a client:

# Go
oapi-codegen -generate types,client -package nodaro https://app.nodaro.ai/v1/openapi.json

# Rust
openapi-generator generate -i https://app.nodaro.ai/v1/openapi.json -g rust -o nodaro-rs

# Python
openapi-generator generate -i https://app.nodaro.ai/v1/openapi.json -g python -o nodaro-py

Per-node request fields beyond the flagship pair are documented in the node catalog (every page also exists as raw .md).

3D scenes

GET /v1/3d-scene/capabilities reports Basic support and an optional advanced capability. When Advanced is unavailable, advanced is null. An explicitly selected unavailable engine returns 503 SCENE_CAPABILITY_UNAVAILABLE before Basic credit checks; it never silently substitutes Basic authoring. Existing requests without an engine field, or with engine: "basic", retain Basic behavior. Advanced engines own their admission and quote requirements.

Editable clay scenes use POST /v1/3d-scene/generate and POST /v1/3d-scene/edit, returning job IDs. Render via POST /v1/render-video/plan with planType: "3d-scene". See Generate 3D Scene and Edit 3D Scene for inputs and revision behavior.

The node-slug aliases POST /v1/generate-3d-scene and POST /v1/edit-3d-scene use the same validation, authorization and credit handling. POST /v1/render-video also accepts a planType and plan, dispatching to the same composition renderer; requests without planType retain the template format. These paths support generic SDK node execution.

The generate node advertises scene3d-embed-v1 in GET /v1/nodes when this deployment includes the interactive 3D preview embed. Clients can check this capability before offering the embedded editor.

3D Render Pro

Two endpoints, ONE paid job.

POST /v1/pro-3d-render/quote prices a request without starting it and answers {quoteId, expiresAt, maxCredits, breakdown, pricingVersion, capabilitiesVersion, normalizedInputHash}. It reserves nothing and spends nothing; maxCredits is a ceiling, not a charge.

POST /v1/pro-3d-render takes the same body plus that quoteId and an Idempotency-Key header (8–255 characters), and returns { jobId }. Admission re-checks the quote against the request, so a body edited between the two calls is refused rather than run at an unquoted price; an expired or stale quote is refused before anything is reserved.

The body’s source is a strict discriminated union — exactly one of:

sourceJobId is required for Basic scenes retained only in job history. It is optional for retained revisions, including manual edits, which the engine authorizes through current scene permissions.

Other fields: engine (blender-cloud default, blender-local where paired), localConnectionId, quality, style (clay), maxRepairPasses (0–2, default 2), durationSeconds / fps / aspectRatio (including 21:9), acceptedSceneSchemaVersions, plus the usual workflowId / nodeId / forcePrivate context. There is no model or reasoning-effort field: the planner is fixed and server-owned.

Source timing is not silently overridden. A scene source already has its own duration, fps and aspect ratio — omit those fields to keep them. Sending them is an explicit re-time request, and an incompatible one is rejected.

acceptedSceneSchemaVersions is checked against what the source PRODUCES: a prompt or local-export source mints a v2 manifest, so a client that omits 2 is refused for free. A scene source inherits its revision’s version, so a retained v1 scene still renders for a v1-only client.

The completed job’s output_data carries videoUrl (the standard resolved video field), scenePlan, sceneRevisionId, posterAssetId, an optional shotStills (one still per shot — {shotIndex, frame, assetId, url}, ordered by shotIndex, at no extra credit cost), an optional sourceArtifactId, validation ({status, reportAssetId, warnings[]}), renderer and metadata ({width, height, fps, frames, duration}).

A run that authored additionally reports its own account of the answer, in optional fields: validation.warnings[] entries coded SCENE_AUTHORING_ASSUMPTION (the planner’s assumptions, with any normalization the engine applied), metadata.summary (its one-or-two-sentence description of what it authored — on a repaired run, of the repair), top-level repairPasses (repairs actually run, 0 when the scene was accepted first time) and top-level admissionRetries (pre-build planner retries — a recipe the compiler would not admit, re-asked with no build and no repair pass spent, counted apart from repairPasses and never folded into it), top-level mechanicalPasses (repairs the engine applied itself from the compiler’s own remedy with no planner call, counted apart from repairPasses because they spend their own quoted allowance rather than one of your repairs; each also carries a REMEDY_AUTO_APPLIED warning naming the assertion and the change) and restoredAssertions (mandatory assertions put back after a planner answer re-shaped one the feedback had not named — {op, path, value?, assertionId, reason}, each also an ASSERTION_RESTORED warning). A render-only export authored nothing: it reports no summary and omits the counts rather than claiming 0.

The one case where mechanicalPasses is a subset of repairPasses is a run quoted before that allowance existed, whose quote carries no mechanical line. The discriminant is the quote, not the result — read the quote you were given rather than deriving the accounting from the two counts. The same fields appear on a generate-3d-scene job served by an advanced engine; the deterministic Basic lane carries none of them. Warning codes are open-ended — read code and treat an unrecognized one as informational. See 3D Render Pro.

A completed authoring job may have been delivered without the visual review’s approval, in which case metadata.review carries a verdict and verdict says which of two ways.

{ verdict: "refused", objections[], observed? } — the repair budget was spent, every mandatory check passed, and the review still objected, so the scene was delivered with the refusal attached rather than withheld. Each objection is { category, what, correction?, frames[] }, with no severity field because only blocking findings become objections, and validation.warnings[] carries one SCENE_REVIEW_REFUSED entry per objection, tagged with a shotId where the cited frames fall inside one shot.

{ verdict: "unavailable", reason, attempts, objections[], observed? } — the review produced no usable verdict. reason is "provider" when it never reached its provider and "unusable" when the provider answered with nothing usable. A repair cannot answer either, so instead of spending one the run asks the review once more — after a bounded pause for an unreachable provider, at once for an unusable answer, and not at all when the provider broke after it had already streamed usage — and, if there is still no usable verdict, delivers the assertion-passing scene unreviewed: nobody judged it. attempts is how many times the review was asked. validation.warnings[] leads with one SCENE_REVIEW_UNAVAILABLE entry, then one SCENE_REVIEW_REFUSED per surviving objection — a review is batched, so objections on this arm are whichever batches answered usably first and are not a verdict on the scene. A retry is never billed on top of the asking before it: an asking that reported no usage is unbilled, and the second asking of an unusable answer is not charged again. The delivery bills as the refused one does.

Three readings that look right and are not: validation.status is still passed on both (the mandatory checks did pass); objections may be empty, which reports a refusal that named nothing actionable; and an empty objections under an unavailable verdict is not approval, it is silence. Test for metadata.review itself, then branch on verdict. A visual refusal alone no longer fails the job; SCENE_QUALITY_FAILED now means a mandatory check failed or the compiler refused the recipe, and the retained draft is on the failed job — where a deployment does not deliver unapproved scenes, an unreviewed one is retained there too, with SCENE_REVIEW_UNAVAILABLE leading its warnings and no metadata block to carry a verdict. See When a scene that passed is delivered unapproved.

A FAILED node in a workflow run carries what its run retained. In GET /v1/workflow-executions/:id and on its SSE stream, a node’s entry in nodeStates carries output when it completed — and ALSO when it failed and the run retained a structured result. A refused 3D-scene authoring node reports status: "failed" with its error, and output.plan holding the draft revision it published; the job row behind it still carries the full output_data described above. This is additive: a client that does not read the field sees exactly what it saw before. Two rules for one that does — gate on the FIELD, not on the status (a pending or running node never has one, and a future node type may start retaining), and never read a present output as success. The node failed; it simply failed holding something.

Availability is per deployment. GET /v1/3d-scene/capabilities reports a pro block with available plus the engines, quality profiles, styles, aspect ratios and repair-pass ceiling a client may OFFER; GET /v1/nodes omits the type where it is unavailable, and both routes answer 503 SCENE_CAPABILITY_UNAVAILABLE. A deployment with no configured credit price answers 503 price_not_configured before anything is reserved. See 3D Render Pro.

Scene versions and binary assets

New-scene requests may select retained GLBs with inputAssets on POST /v1/3d-scene/generate, or source.inputAssets for a Pro prompt source. Each selector is {id, revisionId, assetId, label?}; the list is limited to eight unique logical and artifact IDs. Image/video references remains a separate field. The server authorizes the exact revision pin and resolves immutable byte metadata before quoting or reserving credits. Caller receipts and URLs are rejected. Imports require an advanced engine with import support; Basic and unavailable import lanes refuse without starting a generation.

Scene3DPlan is a discriminated union: schema version 1 stores primitives and keyframes; version 2 stores semantic entities, immutable GLB assets, baked camera samples and contiguous shots. Read schemaVersion before accessing version-specific fields. Basic authoring continues to produce version 1. Clients requesting an optional engine declare acceptedSceneSchemaVersions and check capabilities.

Version 2 manifests contain asset IDs, byte lengths and SHA-256 digests. They do not contain storage keys or public download URLs. A retained revision provides:

Binary requests use normal bearer authentication. Playback requires permission to view the revision’s workflow; native source requires edit permission. Personal revisions are owner-only. A deleted or inaccessible revision returns 404, and responses use Cache-Control: no-store. Internal authoring recipes and repair checkpoints are not downloadable through the revision routes — the one exception is a refused run’s recipe, which is reached through its delivery and is described below. A revision with pending source materialization does not advertise an outdated native source file.

An export that retains delivery evidence exposes it separately from its source:

The retained recipe needs edit. A source-json descriptor is listed, and its bytes served as application/json, only to a caller with edit access to the job’s workflow — the same access the native .blend source requires, and for the same reason: a collaborator invited to watch a workflow reads what a run produced, not the authoring input behind it. A reader with less sees no such descriptor and gets 404 on the bytes, never a 403 that would confirm one exists. This is the ONE place a source-json is readable: a delivered scene’s own recipe is pinned by its revision, and the revision routes list no checkpoint kinds. Reading it costs no credits, like every other delivery read. The failed job’s output_data.validation.sourceRetained says whether there is one to fetch before you ask.

Delivery reads require current access to both the delivery workflow and the source workflow; personal sources remain owner-only. Deleting the source revision does not remove delivered evidence or waive its source permissions. Delivery metadata never includes storage keys or native source files, and no delivery descriptor is a URL.

POST /v1/3d-scene/revisions/:revisionId/edits saves deterministic v2 overlays without a generation job or LLM charge. It requires edit access to the retained scene and workflows:write for OAuth apps. Send newRevisionId, the base expectedContentHash, operations, and optional lockedObjectIds. Reuse the same new revision ID and body when retrying a transport failure. The response is { scenePlan, changeSummary }; a stale digest or conflicting revision ID returns

  1. The request accepts operations, not uploaded geometry or a replacement manifest. Saving creates an immutable revision; the caller separately selects it in its workflow, checking that the active revision has not changed meanwhile.

Character Motion metadata

Character Motion catalog options include optional motion metadata at both compact and full detail. It carries authored requires, startPose/endPose, endVisibility, handsAfter, needsFreeHands, kind, fixedPace, counterpart, search aliases, and deprecated/replacementId. Missing fields mean unknown. Preserve retired IDs when loading saved workflows; hide them from new choices. See Character Motion for composition, naming, review and advisory-diagnostic behavior. client.pickerCatalogs.get("character-motion") exposes this as PickerOption.motion; the structural type is CharacterMotionMetadata from @nodaro/shared.