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
- Sign in to your Nodaro instance.
- 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/apidirectly by URL, signed in as the billing account. - Click Create token.
- 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, max120.
- Name — a label for your records (e.g.
-
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:
- Without
?assertion=— redirects (302) to the provider’sinitiateUrl(the login-button entry point), or400 no_assertionif none is configured. - With
?assertion=<jwt>— verifies the assertion (HS256 signature,aud,exp, server-enforced max lifetime, single-usejti), applies the account-linking rules, mints a one-time Supabase login token, and redirects (302) to/sso?sso_token=<token>&next=<same-origin-relative-path>. The browser’s/ssolanding exchanges that token for a session. - A
?next=value is honoured only when it is a same-origin relative path; anything else falls back to/projects(open-redirect guard).
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_subject — next
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."
}
]
}
adjustmentsis absent when nothing needed correcting — a valid request’s body is exactly{ "jobId": "…" }.tois absent when the model has no such lever at all and the value was dropped (e.g. anaspectRatiosent torecraft-upscale).- Credits are reserved against the corrected values. A
gpt-image-2request forauto+2Krenders and bills at 1K, becauseautoonly renders 1K. - The per-model option lists are in
GET /v1/models; a workflow saved through the API or MCP is corrected the same way at write time.
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:
- An off-list value snaps to the NEAREST option, never the cheapest or the
first. A
4krequest on a model that tops out at 1080p renders 1080p (not the 480p floor), and a portrait9:21becomes9:16(not landscape16:9). - An omitted
resolutionis sent at the band it is priced at. Where the platform declares a model’s default band, that band is what you are billed for and what the provider is asked for — the request no longer leaves the field unset for the provider to fill with something else. The value is echoed in the job’sinput_data, so you can always see what was actually sent. - Spelling is canonicalised before pricing.
4Kis read as4k, so it prices and renders the 4K band rather than silently falling back.
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
- The server polls the execution every 5 seconds for up to
timeoutseconds (default 120, max 600). - If the workflow finishes in time: returns the same payload as
/v1/api/result/:execId—statusiscompleted,failed,cancelled,timed_outordiscarded, andoutputs[]is filled in. - If it doesn’t: returns
202with{ executionId, status: "pending" }and you fall back to polling.
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:
- A Stripe webhook that triggers an “onboarding video” workflow.
- A GitHub webhook that triggers a “release notes summary” workflow.
- A no-code tool (Zapier, n8n, Make) firing on schedule.
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:
- Default
30requests/minute, configurable up to120per token. - Counts only the mutating / heavy calls:
POST /v1/api/runandGET /v1/api/workflows. Read-only poll routes (/status/:execId,/result/:execId,/schema) do not consume the bucket. - Bucket resets once per minute; a 429 response carries
{ error: { code: "rate_limited", message: "Too many requests. Max N per minute." } }.
Recommended client behaviour:
- For polling, sleep 2–5 seconds between
/statuscalls. The execution state changes at second-scale, not millisecond-scale. - On 429, exponentially back off (e.g. 5 → 10 → 20 seconds) before retrying.
- If you need a higher limit, raise the
rateLimitfield on the token (max 120). Beyond that, mint multiple tokens and shard across them.
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/credits → allowance.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:
- Outputs are public by default. Private mode is a subscription feature (Standard plan and above) — pay-as-you-go outputs appear in the public gallery like free-tier outputs do. (Jobs created through the MCP server are always private, regardless of tier.)
- Media retention follows account activity. Your generated files are stored while your account is active; after roughly 3 months without a purchase or any credit spend, files older than 60 days may be cleaned up. Returning and spending credits stops future cleanup.
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:
- The cursor is a keyset over
(created_at, id)— the ordering iscreated_at DESC, id DESC. Pairing the id with the timestamp is what keeps rows created in the same transaction (identicalcreated_at) from being skipped at a page boundary. - The cursor is opaque: do not parse, construct, or persist it across
releases. Only ever echo back a
nextCursorthe server gave you. - A malformed cursor is a
400 validation_error, not a silent reset to the first page. archived=truepages the same way, over the archived set.- Rows created while you are paging (newer than page 1’s boundary) are not picked up by the walk — restart from no cursor to see them.
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:
seedance-2(full) supportsresolution: "4k"andaspectRatio: "adaptive"(plus"21:9").seedance-2-fastandseedance-2-miniare 480p / 720p only (no 1080p, no 4K);seedance-2-5is 480p / 720p / 1080p (no 4K; 1080p added 2026-08-17).seedance-2-5(Seedance 2.5) generates up to 30s in one call — every other Seedance 2 SKU stops at 15s — and raises the reference caps to 30 images / 10 videos / 10 audio (audio ≤ 30s per clip). With a start frame it always renders at that frame’s aspect ratio; an explicitaspectRatiois coerced toadaptiverather than sent.minimax-h3(MiniMax Hailuo 3) takes the same reference fields with the same 9 / 3 / 3 caps and renders atresolution: "2K"(default) or"768P"(the cheaper per-second rate) — any other resolution value renders and bills as 2K.aspectRatio: "adaptive"is the default; a pure text-to-video call needs a concrete ratio (adaptive coerces to 16:9 there). Reference audio must ride with an image or video reference; input images beyond the first 5 add a per-image surcharge.wan-3/wan-3-prime(Wan 3.0 / Wan 3.0 Prime) takereferenceImageUrls(≤ 10),referenceVideoUrls(≤ 5) andreferenceAudioUrls(≤ 5); every reference video and audio clip is 1–15 s with a ≤ 15 s combined cap per media type (the per-clip audio cap is the only one checked before submission; the video caps are the provider’s and surface mid-run). On the Wan wire the reference arrays are mutually exclusive with the start/end frame fields (imageUrl/endFrameUrl), so — exactly as on Seedance 2 and Hailuo 3 — sending both folds rather than fails: the frame is appended toreferenceImageUrlsafter your own images (their ordinals are unchanged) and named in the prompt as the opening/closing frame. The two are never sent together and the call is not rejected.durationis a whole number of seconds from 2 to 30 (default 5); with a reference video wired, input duration + output duration must be ≤ 30 s.resolutionis480p/720p/1080p— send the lowercase display value like every other model; the provider’s own enum is uppercase and the platform normalizes it for you, and an omitted or unsupported value renders and bills at 720p.aspectRatiodefaults to"adaptive"and the model’s own set isadaptive/16:9/4:3/1:1/3:4/9:16(no21:9). Prime is the faster, pricier SKU on an identical schema. Reference-video runs bill output seconds only — there is no input-duration surcharge on Wan 3.0.gemini-omni-flashtakes exactly the same fields asgemini-omni-videoon the same 720p / 1080p / 4K tiers, the same 4 / 6 / 8 / 10durationmenu (an omitted duration renders and bills as 8 s), the same 16:9 / 9:16-onlyaspectRatio, and the same 7-unit input quota (each image 1 unit, a source video 2). It is the cheaper, faster SKU of the pair.- Frames + references coexist (Seedance 2 and Hailuo 3 — not Wan 3.0,
whose arrays are exclusive, see above). When any reference (image / video /
audio) is wired alongside a start/end frame, the frames become
prompt-directed
Image Nreferences rather than pinned endpoints — the resolver decides the mode, there is no toggle. - Reference videos are billed
unit × (input + output)duration. The runtime ffprobes eachreferenceVideoUrlsclip and scales the per-second-refrate (see the Generate Video node for the live per-resolution rates) by the input-video duration plus the output duration, so longer source clips reserve proportionally more credits.
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):
- Unmentioned wired references auto-attach. Each ref’s
urlis appended to the worker’sreferenceImageUrls(deduped, and capped at the provider’s image-ref limit — references beyond the limit are dropped before numbering, so an@image_Ndirective never binds a reference that wasn’t sent) and gets a per-ref directive —@image_N (reference): <label>for images/objects/locations, a “Use these characters:” identity bullet forwired-characterrefs. {image:N:label}tokens inpromptexpand tothe <label> from @image_N, numbered against the attached references (front-of-list order).{ref:<id>}/{ref:<id>:label}address a reference by its own id. Write theidyou gave the entry inconnectedReferencesand the platform substitutes its@image_Nseat after it has numbered the references (flatreferenceImageUrlsfirst, then unmentioned wired characters, then the other entries, in order) — so you never computeNyourself, and a later change to the numbering can’t misbind your picture. Ids are opaque and may contain:and/(an image URL is a fine id). A token whose reference was not attached (capped out, or a provider without reference support) drops to its label, else the entry’sdefaultName, else nothing — it never reaches the model raw.{ref:}follows the reference throughreferenceOrder;{image:N}keeps its literalN. The canvas editor keeps writing{image:N}.referenceOrder(an optional string array of stable ref ids) reorders the reference list and renumbers the@image_Nbindings to match.identityLock(per-reference, opt-in — default off). EachconnectedReferences[]entry may carryidentityLock?: { enabled: boolean; text?: string }. Withenabled: true, the prompt builder prepends a short identity-lock fidelity line for that reference (pinning its exact identity);textoverrides the built-in per-source wording, and{ref}in that text is the placeholder for the reference’s binding (reference image Aon image,@image_Non video). Left off — the default — nothing identity-locking is injected. Honored when the route assembles in the hybrid reference format. CLI callers pass it inside theconnectedReferencesJSON via--params-file(no dedicated flag). See the Reference Roles guide for the role-label + lock model.- Provider-gated, per-provider caps. Only models with verified image-reference
support attach references; on any other model the
{image:N}tokens are stripped to their bare labels and nothing is attached. Supported models and their image-reference caps: Seedance 2 family (9), HappyHorse Ref2V (9), Gemini Omni / Kling 3 Omni / Grok i2v (7), VEO 3.1 Fast and Lite (veo3.1/veo3_lite, 3). VEO 3.1 Quality (veo3) is not on the list — the provider serves reference-to-video on Fast and Lite only, so references sent withveo3are ignored and the run proceeds with its frames. This set is kept in lock-step with the model catalog by a drift guard, so it can’t silently fall out of sync. - References-only runs (no
imageUrl). OnPOST /v1/generate-videothe start frame is optional whenever at least one supplied reference kind is supported by the selected provider (per the caps above) — e.g. Kling 3 Omni withreferenceImageUrlsalone. VEO 3.1 Fast / Lite references-only runs are auto-normalized toREFERENCE_2_VIDEO, so passinggenerationTypeis not required;veo3(Quality) carries no references, so a references-only run on it still needsimageUrl. Reference kinds the provider can’t carry don’t lift the requirement: a provider with no reference support still needsimageUrl, and e.g. audio-only references on an images-only model are rejected with a 400 rather than silently dropped. An end frame alone is accepted the same way on the models that fold it into their references (Seedance 2.x, MiniMax Hailuo 3, Wan 3) — sendendFrameUrlonce, without repeating the same picture inreferenceImageUrls. That membership is the exported predicatevideoProviderFoldsLoneEndFrame(provider)in@nodaro/shared— the same one this route reads, so a client can gate its own UI on it instead of hand-mirroring the family list. image_requiredvsvalidation_erroron a missing start frame. WhenPOST /v1/generate-videogets noimageUrl, noREFERENCE_2_VIDEOgenerationTypeand no reference kind the provider can carry, the response depends on the model. Any model whose catalogmodesomittext-to-video(e.g. Kling 3 Omni, HappyHorse Ref2V, Hailuo 2.3 — the set is derived, soGET /v1/modelsis the authority, not this list) returns400 image_requirednaming the model; every other provider returns400 validation_errortelling you to usePOST /v1/text-to-videofor a prompt-only run. Theimage_requiredmessage also says whether references are an alternative for that model — derived from the same reference caps the guard above uses, so a model with no image-reference capability is told it needs a start frame.- Backward compatible. Omit
connectedReferencesand the route behaves exactly as before — a pre-assembledprompt+ flatreferenceImageUrlspass through unchanged.connectedReferencesfeeds the image channel only;referenceVideoUrls/referenceAudioUrlsstay as explicit flat inputs. - Extend Video: Seedance 2 Extend only, with one seat reserved. On
POST /v1/extend-videobothconnectedReferencesand the flatreferenceImageUrlsare accepted whenproviderisseedance-2-extend— the only extend transport with a reference path; any other extend provider refuses them with a 400 rather than silently dropping. The image budget is the Seedance 2 cap minus one (up to 8 user references): the extension pipeline itself occupies one reference seat with the source’s last frame (the continuation anchor), appended AFTER the user references so@image_1…@image_8ordinals never shift. The source’s 2-second tail rides as@video_1and is already priced into the extend rates — reference images add no extra credits.
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.
referenceOrderon images too.POST /v1/generate-imageaccepts the same optionalreferenceOrder(parity with video) to reorder its assembled reference list and renumber the@image_Nbindings.
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.
- No url, so nothing is attached. A described reference consumes no
image-reference budget and claims no
@image_Nseat; the numbering of yourconnectedReferencesis untouched. - It reaches the model as prose — one
<Name> — <description>.line per entry, alongside the reference directives the route assembles. Correlation is BY NAME: leave the name in your prompt text (Natalie walks down the pier.) and the line tells the model who Natalie is. Do NOT write an@name:1mention for a described reference — that grammar addresses an attached image. - It is a reference channel on its own. Send
describedReferenceswith noconnectedReferencesat all and the route still assembles — that is the normal case for a story that has been written before any entity exists. - Entries missing a name or a description are dropped, and a repeated name renders once. An empty list behaves exactly like omitting the field.
- On
POST /v1/extend-videodescribed references are NOT gated on the reference-capable transport the wayconnectedReferencesare: they carry no url, so every extend provider accepts them.
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:
descriptionOverride(perconnectedReferences[]entry, ≤ 2000 chars). The reference’s identity description for THIS run. It fills the description slot the assembled directive already has, winning over the entity’s stored canonical description and over the entry’s owndescription(which stays the reference’s label). Where the hybrid reference format renders no description at all, it adds onereference image A — <text>./@image_1 — <text>.line — so the model is told once, never twice. Omit it and the entity’s own wording is used, exactly as before.referenceVideoCaptions/referenceAudioCaptions(POST /v1/generate-videoandPOST /v1/text-to-video, each entry ≤ 500 chars). Captions for the video / audio rail references, index-aligned withreferenceVideoUrls/referenceAudioUrls—referenceVideoCaptions[0]describesreferenceVideoUrls[0]. Each renders as@video_N: <caption>./@audio_N: <caption>., and the list is bounded by the number of rail references that actually ship, so a caption can never bind a slot the payload dropped. Leave an entry blank to skip it without breaking the alignment.
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.
- The slug is derived from the entry’s
defaultName, lower-cased with every run of non-alphanumerics collapsed to a single-("Old Town"→old-town). There is no separate slug field to set, and nothing changes on the wire: give the reference a name and it becomes mentionable. - Grammar:
@<name-slug>:<index>renders that reference’s binding at the position you typed it (reference image C);@<name-slug>:<index>:<role>renders the role phrase (the background from reference image C). The<index>is a correlation number only — it is never echoed into the prompt, and the platform binds by its own numbering walk, so you never compute a seat. - Roles are the curated media set —
object,person,face,clothes,background,style,pose,texture— or any custom single-word role, which passes through verbatim. Omit the role and the entry’s owndefaultRoleapplies; with neither, you get the bare binding. ~lock/~nolockwork here exactly as on character and location mentions (@town:1:background~lock): a trailing sentinel forces that reference’s identity lock on or off for that mention.- Precedence is character → location → image → creature → object. A name shared by a character and an image resolves as the character. Duplicate names within one kind bind first-wins.
- A name that can’t be a slug is never mentionable —
"3D Render"slugs to3d-render, which starts with a digit and is outside the grammar, so no token can bind it. Rename the reference to mention it. - Honored when the route assembles in the hybrid reference format. Under the legacy format the token stays literal text and the reference auto-attaches exactly as before. A token whose reference was capped out by the provider’s image-reference limit likewise stays literal.
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:
- The roles are the entity sets — creature:
creature,anatomy,markings,pose,color,style; object:object,shape,material,color,texture,style— or any custom single-word role, verbatim. Omit the role and the entry’sdefaultRoleapplies; with neither you get the source default,the creature from …/the object from …. - A mention SUPPRESSES that entry’s trailing auto-attach phrase. Unlike a
media reference, an unmentioned creature or object already contributes a
trailing line (
the creature from reference image D). Without a mention the entity’s name sits in your prose as plain text while its binding dangles at the end — two halves of one intent. Mention it and the phrase renders once, inline, where you typed it.
{
"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):
"standard"— default-deny + likeness + compose (the population winner in the platform’s measurements). Reach for it on multi-reference composition."multi-person"— adds the face clauses (never alter face structure, never blend faces). Reach for it when two or more faces are in play and elements cross between them; on a single subject it is dead weight.
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"]
}
}
- The keys are the platform’s own picker field names —
shotSize,angle,coverage,composition,vantage,pose,compositionEffect,cameraFormat,lens,aperture,shutterSpeed,isoValue,timeOfDay,lightingStyle,lightingDirection,lightingRatio,colorTemperature,colorLook,atmosphere,postProcess,style,mood,aesthetic,photoGenre,photographer,renderQuality,setting,era,backdrop. (The registry also defines motion dimensions —cameraMotion,actionFx, thetemporal*keys,transition,loopSubject— which fold on the video routes; see the next section.) Valid ids come fromGET /v1/picker-catalogs— the same catalogs the canvas pickers read. One caveat on deployments that register catalog packs: that endpoint returns the pack-composed catalogs while the fold reads the base catalogs, so a pack-added id is listed and accepted but renders no clause. - Single id or an array. Multi-pick dimensions (
mood,aesthetic,photographer,atmosphere,postProcess,composition,lightingStyle) honor up to their own cap; a single-pick key given an array takes the first entry. Exceeding a dimension’s cap truncates rather than 400ing. The two wire bounds do reject with avalidation_error: at most 8 entries per key, and at most 100 characters per id. - Absent ≠ empty. A missing key means “no hint”, never a default. An empty
string or an empty array contributes nothing, and a
directionthat renders no clause leaves yourpromptbyte-for-byte untouched. - Unknown keys and unknown ids are skipped silently, not rejected — a newer client on an older API degrades to fewer hints rather than erroring, which is why you should deploy the platform before the client that starts sending new dimensions.
-
The clauses land in a trailing
[style]section, not inline in your prose. The assembled prompt is your prompt (plus anysubjectclauses and thestructuredfragment,". "-joined, in that order) — then a blank line, then:[style]: <film line> <scene line>The film line carries the dimensions that describe the capture —
cameraFormat,colorLook,style,era(and the legacycameraFormatId) — and the scene line every other selected dimension. Both are". "-joined, and a line with nothing on it is omitted entirely. Telling the model structurally which sentences are the LOOK stops a broad direction from burying the shot in a dozen same-register sentences. - Fold order is the platform’s, not your object’s key order: it decides
which clause is rendered first within a line, and it is also the order things
shed in (below). Repeated identical clauses collapse to one. A direction that
renders no clause emits no section at all — no header, no extra newline —
so your
promptcomes back byte-for-byte untouched. - Over the provider’s prompt cap, direction clauses are the first thing to
go — never your references or your prose. A maximal
directionrenders more than a low-cap model accepts (seedream = 3000 characters), so the assembler sheds hint clauses from the END of the fold order, one at a time, until the prompt fits: the dimensions the platform folds first outlive the ones it folds last.subjectclauses (below) fold ahead of every direction clause and therefore shed last — who is in the shot outlives how it was lit. That fold order is the platform’s own compatibility order, not a ranking of which dimension matters most — survival is position, not importance. Everything else outranks a hint — reference directives and the role phrases that bind them,@-mention-resolved text, your prose, thestructuredfragment, and the appendedStyle:/Avoid:lines all survive intact. Nothing is shed while the prompt fits, so an under-cap request is byte-for-byte what it always was. Only when the body still overflows with ZERO hints left — very long prose, or many bound references on their own — does the last-resort tail clamp cut mid-text and end the prompt with.... Send the dimensions that carry the shot rather than everything at once and you will never reach either stage. The video routes shed the same way, at much tighter caps and with one extra thing in the budget; see the next section.
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:
- The budget includes the reference framing. The fold runs before reference
assembly (above), and the resolver then ADDS text — the
Use these characters:block, or, in the hybrid format, the lock lines and the canonical role phrases that end the body. None of it is sheddable and all of it counts, so the shed is decided on the length AFTER framing. Reference text is never what gets dropped. - The ceiling is the one the negative prompt leaves. For a provider with no
native
negative_promptparameter the negative is folded into the prompt as anAvoid: …suffix whose room is reserved FIRST, so the base prompt’s real ceiling iscap − suffix. The shed budgets against that number, which means a longernegativePromptcosts you hint clauses rather than prose. The suffix is separated by a blank line when the prompt ends with the[style]section, so the negative never reads as a look clause.
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"
}
}
- The keys are the platform’s own picker field names, exactly as with
direction— every Person dimension field (type,age,ethnicity,frame,faceShape,eyeColor,hairBase,hairColor,skinTone,lipState,distinctiveFeature, …), every Styling dimension field (makeup,hairCut,jewelry,outfit,top,bottom,outerwear,footwear,fabric,wardrobeState, …), the three prop keysheldProp,materialandanimal, and the one number below. Valid ids come fromGET /v1/picker-catalogs— theperson,styling,held-prop,materialandanimalcatalogs. subjectanddirectionnever overlap. The two key sets are disjoint by contract, so one selection can never emit two clauses.poseridesdirection.customAgeis the one number. Send"age": "age-custom"with"customAge": 34for a literal age in years; it is rounded and clamped to 0–120. Withoutage-customit contributes nothing.- Single id or an array, with per-dimension caps the catalogs and the
prop rows define:
jewelry,wardrobeStateanddistinctiveFeaturetake 3;ethnicity,regionalAesthetic,hairColor,eyeColor,lipState,eyeState,skinTexture,hairState,heldPropandmaterialtake 2 (two held props read as two clauses, two materials fold into one “made of A and B”); every other dimension —animalincluded — is single-pick. Over a dimension’s cap the extra ids are dropped, not rejected. The wire bounds that DO reject with avalidation_errorare the same shape as direction’s: at most 8 entries per key and 100 characters per id, plus at most 128 keys in the object and 64 characters per key. - Unknown keys are stripped and unknown ids are skipped, never rejected —
the same version-skew tolerance as
direction, and the same reason to deploy the platform before the client that starts sending new fields. Whatjobs.input_data.subjectrecords is your bag after that strip, i.e. exactly the ids that folded. - A deployment-registered person pack folds too. Unlike
direction(whose getters read the base catalogs), the person fold reads the pack-composed taxonomy, so a pack dimension’s field is accepted and renders its clause. - Fold order is the platform’s, and
subjectlands ahead ofdirection: the subject is the noun phrase the cinematography then modifies. Subject clauses are BODY text — they read in your prose, never in the[style]section, because who is in the shot is not part of the look. Person arrives as ONE comma-joined clause and Styling as another — those catalogs are authored as compound clauses, not sentences. Overlapping selections collapse rather than doubling (picking the Personlip-state-bold-redand the Stylingmakeup-bold-lipsemits the lipstick once). - Verbosity is per surface. generate-image folds the full clause for every dimension; the video routes fold the short professional term, because the start frame already carries the subject’s identity into the clip.
- Absent ≠ empty, and a
subjectthat renders no clause leaves yourpromptbyte-for-byte untouched. Under a provider’s prompt cap — on generate-image and on both video routes alike — subject clauses ARE shed candidates, exactly like direction clauses, but they are the LAST hints to go: the whole direction fold leaves before the first subject clause does, and neither channel is ever shed before your prose, your references or thestructuredfragment (see the direction sections above). On the video routes the same reference framing and negative-prompt reservation are inside the budget, so a subject-only fold is budgeted exactly like a direction one.
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.)
label(≤ 120 chars) — a display label stored on the job, for run lists.videoUrl— draft from a video. The platform analyzes it first throughPOST /v1/video-analysis(the same probe, duration policy, tiers and price as calling it yourself; the analysis is a separate job you also own,output_data.analysisJobId) and appends the analysis — compact JSON with its server-derived fields removed — to yourinputbefore the LLM call. While the analysis runsoutput_data.stageis"analyzing"andprogressmirrors it; then"drafting". The finished row carriesanalysisJobIdandanalysisCreditsbeside the output.videoAnalysis—{ llmModel?, selectionMode? }, handed to the analysis route unchanged (its tier names and vocabulary; only withvideoUrl).
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=opus → audio/webm), well-known vendor
spellings map to the format they mean (audio/vnd.dlna.adts — what Windows
calls a plain .aac — → audio/aac; image/jpg → image/jpeg;
video/mov → video/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:
periodStart— ISO start of the current usage period.generations— count of generations this period.spent— money spent this period, as{ amount, currency }(an ISO-4217 currency code);null= unavailable.payg— pay-as-you-go state{ enabled, reserve, rate, monthlyCap }(monthlyCapis a{ amount, currency }money figure); omitted/nullwhen the provider has no PAYG concept.daily— structured daily cap{ limit, used, remaining, resetsAt }(resetsAtis an ISO-8601 instant).limit: 0is a real value meaning “blocked”, never “no limit”; whendailyis present, prefer it over the scalardailyAllowance.reserveValue— money value of the PAYG reserve, as{ amount, currency };nullwhen not priced.byCategory— per-category usage rows{ category, count, amount, spent }[], whereamountisnumber | nullandspentis a{ amount, currency }money figure ornull.
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.
POST /v1/video-analysis/import/validate— body{ "script": { … } }. Returns{ valid, errors: [{ path, message, hint? }], warnings: [] }. The errors are written for an LLM repair loop: fix eachpathusing itshintand repeat untilvalid: true.POST /v1/video-analysis/import— body{ "script": { … }, "rightsAttested": true }. Stores the validated document as a completed analysis job; returns{ jobId, created, warnings, json }, wherejsonis the document with the server-derived fields (sceneNumber,slotRefs,visualResolved) — always use it, never your input, as the document of record. Re-importing an identical script returns the samejobId(created: false).rightsAttested: trueis required (403rights_attestation_requiredotherwise): authored recasts render Faithful — exactly as written, so the assertion that the script is your own work is the gate.GET /v1/video-analysis/authoring-skill— the generated authoring guide (markdown): the field contract, enum vocabularies, bounds, audio rules, and a validated worked example. Hand it to the LLM that writes your script.
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? } |
analyze— turns a rough idea into guided questions. Each question is{ category, label, options[], selected, allowCustom, multi? }. Omitpromptto build the questions from scratch.generate— builds a single optimized prompt from the chosen answers. Each selection is{ category, value, isCustom }.originalPromptis woven in when supplied.enhance— one-shot “improve this prompt”: skips the questions round-trip and returns the optimized prompt directly.
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>" }
url— the R2 URL of the uploaded project file.format— echoes the requestedformat("json"or"fcpxml").assetId— the id of theassetsrow created for the file, ornullif the asset-row insert failed (the file upload still succeeded, sourlis valid).
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.analyze → client.voices.recast({ output: "stems", analysis })
→ client.voices.exportMix) and one command per step in the CLI
(nodaro voice analyze → voice recast --output stems --analysis-file →
voice 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-imagecan 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_sheetis excluded from the training-image count. Its composite views (front/side/back) overlap withangles/body_anglesand 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:
400 insufficient_training_images— fewer than 4 deduped URLs available409 already_training_or_not_found— a training is already in flight (atomic CAS guard; double-click safe)503 public_url_not_configured—PUBLIC_URLnot set in this instance503 webhook_not_configured—REPLICATE_WEBHOOK_SECRETnot set502 training_dispatch_failed— Replicate rejected the request; reservation is refunded and the orphan zip in R2 is cleaned up
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:
provider→replicatemodel→flux-lora-characterreferenceImageUrls→[]- Prompt →
TOK_<slug>_<6hex>, <your prompt with @-tokens stripped>
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
- Character Training — user-facing feature doc
- OAuth Flow — third-party app authorization
- SDK Quickstart — TypeScript client walkthrough
- SDK Reference — full method index
- Architecture — how requests flow through the system
- Deployment — self-hosting your own instance
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:
{kind:"prompt", prompt, references?}— author a new scene, then render it.{kind:"scene", revisionId, sourceJobId}— render-only export of that exact revision, with no authoring or build charge. AddingeditPromptrevises the scene first; omit the field for a plain export, since an empty string is a different request.{kind:"local-export", exportId, connectionId}— a completed export from a paired desktop Blender, where that is available.
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:
GET /v1/3d-scene/revisions/:revisionId— its scene manifest and asset descriptors.GET /v1/3d-scene/revisions/:revisionId/assets/:assetId— authorized playback assets.GET /v1/3d-scene/revisions/:revisionId/source— the editable native source, when retained.
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:
GET /v1/3d-scene/deliveries/:jobIdreturnssourceKind, the exactsceneRevisionId, source digests, mode, and opaque descriptors — the poster, the validation report, and oneshot-stillper shot where the render produced a contact sheet. One kind has no scene behind it:refused-authoring, published by a 3D Render Pro run whose recipe the compiler refused on every pass. Nothing compiled, so no revision exists and no frame was rendered —sceneRevisionIdandsourcePlanSha256arenull, and there is no poster. What that delivery lists is the refusal report and, when the run had a recipe to keep, asource-jsondescriptor (usagecheckpoint): the planner’s last admitted recipe. See 3D Render Pro.GET /v1/3d-scene/deliveries/:jobId/assets/:assetIdreturns those bytes with bearer authentication, range support, andCache-Control: no-store.
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
- 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.