Embed a MiniApp
Run a published Nodaro MiniApp in your product with its embed code, or with your own form on the REST API, from reading its inputs to runs, results and errors.
To embed a MiniApp is to run an already-published Nodaro app from your own product. You can paste the app's embed code into a page, or build your own form on the REST API: read the app's inputs, start a run, poll it and show the result. The app itself stays in Nodaro; your product collects the inputs and displays the outputs.
Two ways to embed
| Embed code | Your own interface | |
|---|---|---|
| Effort | Paste one iframe tag. | Build a form and a small server route. |
| Look | The Nodaro app view. | Your own design. |
| Who the run acts as | The viewer, with their own Nodaro session. | The account behind your token. |
Paste the embed code
Open the app's embed settings
In Nodaro, open MiniApps, choose My MiniApps, and click Embed on the app's card.
Allow your domain
Under Allowed Embed Domains, type your site's origin, such as https://example.com, and click Add. Embedding is blocked until at least one domain is added.
Copy the code into your page
Click Copy Embed Code and paste the tag into your page:
<iframe src="https://app.nodaro.ai/embed/your-app-slug" width="100%" height="600" frameborder="0" allow="clipboard-write"></iframe>Build your own interface
The rest of this page builds a custom web or mobile interface for a published app on the REST API. It is self-contained, so you can also hand it to an AI code generator: add .md to this page's URL for a Markdown copy.
Three things to know first
| Base URL | https://app.nodaro.ai on Nodaro Cloud, or the address of your own install. |
| App identifier | The slug: the last part of the published app's URL. For https://app.nodaro.ai/app/my-cool-app, the slug is my-cool-app. |
| Run shape | Asynchronous. POST /v1/app/{slug}/run answers at once with a runId, and you poll GET /v1/app/{slug}/runs/{runId} for the result. The run does not call you back. |
Two public endpoints, which need no token, tell you everything you need for the form:
GET /v1/app/{slug}returns the app's inputs and metadata.GET /v1/nodes/{type}returns the fields of one node type.
Step 1: Read the app's inputs
curl https://app.nodaro.ai/v1/app/<slug>The fields that matter for your interface:
{
"id": "uuid",
"name": "Headline Generator",
"description": "...",
"iconUrl": "https://...",
"version": 3, // the latest version
"estimatedCredits": 5, // the listed price: a run, with any Render final after it
"perMinuteCredits": 0, // per minute of the recording the app is given; 0 when the price does not depend on it
"perItemCredits": 0, // per item beyond the saved items of a list the user fills; 0 when none
"maxEstimatedCredits": 9, // "up to": the most a run can cost over the user's choices (with
"maxPerMinuteCredits": 0, // maxPerMinuteCredits and maxPerItemCredits); present only when it
"maxPerItemCredits": 0, // differs from the "from" figures above
"estimatedCreditsIsEstimate": true, // present when the price holds a metered LLM call's estimate: show "~5"
"runEstimatedCredits": 5, // the run alone: the listed price without its Render final part
"runPerMinuteCredits": 0, // ...and its per-minute and per-item parts
"runPerItemCredits": 0,
"editPlanPerMinute": false, // true when an Edit Plan node is charged per started minute of the recording
"maxRunsPerUserPerDay": null, // or a number
"thumbnailNodeId": "node-abc", // the node whose output is the main result
"snapshotNodes": [ // the workflow's nodes
{
"id": "node-abc",
"type": "generate-image", // use it in step 2
"data": {
"prompt": "default prompt", // the current value of each field
"aspectRatio": "1:1"
}
}
],
"snapshotEdges": [ /* the connections, rarely needed by your interface */ ],
"snapshotSettings": {
"presentationSettings": {
"inputItems": [ // the form schema
{ "type": "field", "id": "item-1", "nodeId": "node-abc", "field": "prompt", "allowedValues": null, "maxLength": 1200 },
{ "type": "field", "id": "item-2", "nodeId": "node-abc", "field": "aspectRatio", "allowedValues": ["1:1", "16:9", "9:16"] }
]
}
},
"versions": [{ "version": 3, "id": "...", "createdAt": "..." }]
}inputItems is the form the publisher designed. Walk it, go into every group, and collect every field item: that is your form.
type | Render it as |
|---|---|
field | A form input. Read nodeId, field, the optional allowedValues and the optional maxLength, a text input's character limit. |
node | The whole node's default block. Skip it in a custom form, or render every field of the node. A Text node exposed whole can carry maxLength too. |
output | A live preview of a result. Ignore it while you build the form, and show it after the run. |
richtext | Static Markdown the publisher wrote. Render it as it is. |
group | A container with its own items. Walk into it; groups do not nest. |
Step 2: Learn each field's type
inputItems gives you pairs of nodeId and field, but no field type: text, number, choice. Look up the node type to learn more:
curl https://app.nodaro.ai/v1/nodes/generate-image{
"data": {
"type": "generate-image",
"label": "Generate Image",
"category": "ai-image",
"description": "...",
"outputType": "image",
"providers": ["nano-banana-pro", "gpt-image-2", "flux"], // shortened
"capabilities": ["supports-reference-image", "supports-negative-prompt"]
}
}Not every node descriptor has a complete inputSchema. When it is missing, infer the control with these rules, in order:
- The input item has
allowedValues: a select with those options. - The current value in
snapshotNodes[i].data[field]is a boolean: a toggle. - The current value is a number: a number input, or a slider when the field name ends in
duration,intensity,strength,scaleortemperature. - The current value is a string, and the field name is
prompt,description,text,content,captionormessage: a multi-line text area. - The current value is a URL, and the node type starts with
upload-: a file upload that submits the public URL of the file. - Anything else: a single-line text input.
These field names cover most exposed fields:
| Field name | Control | Notes |
|---|---|---|
prompt, negativePrompt, text, description | Text area | |
model, provider, voice, style, tone | Select | Options come from allowedValues or providers. |
aspectRatio | Select | Usually 1:1, 16:9, 9:16, 4:3 and 3:4. |
resolution | Select | Usually 1K, 2K and 4K, or 720p, 1080p and 4k. |
quality | Select | Usually medium and high. |
duration, nFrames, seed, temperature | Number | |
enableTranslation, addAudio, headless, loop | Toggle | |
imageUrl, videoUrl, audioUrl, referenceImage | URL | Upload the file first, then submit its URL. |
Over MCP, get_app_inputs types part of this for you. An exposed slider comes back as type: "number", with its own min, max and step. A field that the publisher limited to a list of values comes back as type: "select", with those options. A field from the node's quick settings comes back as a select, a number or a boolean; see Model and quick-setting fields. Every other field comes back as type: "text", so look up the node for it. A flat input for a slider takes a number or a numeric string.
Sliders and toggles. A slider or a toggle starts at the node's saved value, in snapshotNodes[i].data[field]. When the node has no saved value, the app shows the field's default: the value a new node gets. A run sends a field only after the user changes it. Until then, the node's own value applies. Do the same in your interface: prefill the control, and send the field only when your user changes it. The Text to Speech voice settings accept a number or a numeric string, and a value outside the setting's range is clamped into it.
Model and quick-setting fields
Every setting on a node's bottom bar in the editor can be exposed on an app: the model, and for most generation nodes the resolution, duration, aspect ratio, Draft and similar settings. For example, Video to Video can expose its Model, Resolution and Draft, and Generate Video its Duration, Resolution, Aspect and Draft beside its Model. The run-count setting, Runs, is never offered.
- The key is the node's own data field, such as
provider,v2vResolution,durationordraft. Send the value ininputOverrides[nodeId][field], in the type the node stores:8for a duration, not"8", andtruefor Draft. - A fixed list is enforced. Some settings offer the same values whatever else the node is set to, such as the models a node offers, a Suno model or a 3D scene's aspect. A value outside the list is refused with
400 validation_error(Invalid value for provider: …. Allowed: …). The publisher can restrict these lists further withallowedValues. - A range is enforced. A setting that takes any number in a range, such as a 3D scene's Length or a Video Mask's Threshold, refuses a number outside it with
400 validation_error. - Some choices depend on the model. Which resolutions, aspects and durations a setting offers depends on the node's model. The app's card offers the choices of the model the run will use, and moves a choice the new model does not take when the model changes. Do the same in your form: MCP
get_app_inputslists every model's values for such a field, andsnapshotNodes[i].data.provider, or the value your form sends, says which model runs. - Draft renders at 480p. On Seedance models, the app's card also sets the resolution to 480p when Draft is turned on, and the server renders a draft at 480p whatever resolution is sent.
Clear a field
A field set to null in inputOverrides is cleared for that run: the node runs as if it had no value there, neither null nor the creator's saved value, so the node's own default applies. The app's card does this for a setting the chosen model does not have. For example, switching Video to Video to Runway Aleph, which has no resolution setting, clears the resolution. The card also turns Draft off this way. A cleared field is never refused by allowedValues or a fixed list.
As an MCP or SDK flat input, null is dropped before it reaches the node, so the node's saved value applies. Use inputOverrides to clear a field.
Lottie slot fields
A Motion Graphics node on the Lottie engine can expose its named slots, such as colors, texts and numbers, as app inputs. They appear as field items whose field starts with slot:, such as slot:primaryColor. Users change them on every run for 0 credits: when an app exposes slot fields, every run reuses the published animation and only swaps the slot values.
| Slot value | Control | Value to submit |
|---|---|---|
| A color, as an RGBA array of numbers from 0 to 1 | Color picker | A hex string, such as "#00ff00" |
| A string | Text input | The string |
| A number | Slider | The number |
A slot value is not a plain node field: it lives in the node's motionPlan.slotValues. To set slots through raw inputOverrides, replace the node's whole motionPlan with a copy of the published plan whose slotValues carry your changes, with colors as RGBA arrays. A partial slotValues patch would drop the rest of the plan. The Nodaro app and the SDK build this for you.
{
"inputOverrides": {
"node-mg1": {
"motionPlan": {
// ...the published node's motionPlan, unchanged...
"slotValues": { "primaryColor": [0, 1, 0, 1], "nameText": "Acme Inc." }
}
}
}
}Video URL inputs
A Video URL node can be an app input, so the app takes an episode as a link instead of an upload. get_app_inputs lists the field as type: "video", and a flat input is the link:
{ "inputs": { "episode": "https://www.youtube.com/watch?v=VIDEO_ID" } }- What the field takes. A link to the page where a video is, or any other public
httporhttpslink to a video. A link to a video's page is downloaded, and any other link is used as it is. A value that is not a web link, or that points at a private address, is refused with400 locked_fieldbefore anything runs or is billed. A link for a Video URL node that the app does not expose is refused the same way. - On the app's page. A link to a video's page shows the content rights box under the field. The app's user ticks the box to confirm the declaration for their link, and nothing is fetched before that. A changed link unticks the box. When only Content Recipe reads the link, nothing is imported and no box is shown. Dubbing dubs the file, so a link that Dubbing reads is downloaded like a link that a node watches.
- The server downloads the video. Before the first node runs, the server fetches what the nodes after the link read. A node that watches the video gets the video. Transcribe and Suno Cover get only the sound. Dubbing gets the video, unless it dubs its own Source Link. Content Recipe takes the page address, so nothing is fetched for it.
- A long video needs a part. From a site whose video length Nodaro reads first, a video of 4 minutes or more, or one whose length cannot be read, is downloaded only as a part. Name the part on the node in
inputOverrides, in seconds:{ "<nodeId>": { "sectionStartSec": 600, "sectionEndSec": 1200 } }. Without a part, the run is refused before anything is billed. A live stream is refused too, and a failed download refuses the run with the reason. - Raw overrides. In
inputOverrides, the link goes in the node'syoutubeUrlfield. - Content rights. The server's fetch is an import of the link. Send the confirmation for that link and that run as the node's
rightsConfirmationIdininputOverrides, fromPOST /v1/content-rights/confirm. One confirmation opens one download within an hour, so it is never kept with saved inputs. A run without one is refused only when the deployment requires a confirmation. See Content rights. - Price. The steps after the link that are charged by the length of the video are priced per minute of that recording, in
perMinuteCredits.
UGC video inputs
An app built on the UGC video nodes, on Nodaro Cloud, has two inputs of its own: the creator card and the video length.
The creator card. A node item for a UGC Creator is the creator card: AI creator or My photo, Woman or Man, and for My photo an upload of the runner's photo. The card writes three fields of the node, so a run sends them on the creator's node id:
{
"inputOverrides": {
"<creator node id>": {
"source": "photo",
"gender": "woman",
"photoUrl": "https://example.com/runner.jpg"
}
}
}sourceissampled, for an AI creator, orphoto.genderiswomanorman.photoUrlis a public image URL, for My photo only.
MCP get_app_inputs lists the card as three keys: the creator's own key for source, and the same key with _gender and _photo_url, both optional. An app run never waits for a creator to be picked: it draws one. A My photo run that gives no photo of the runner's own is refused with 400 ugc_photo_required before anything runs or is billed.
The video length. The Video length (seconds) of UGC Script, targetDurationSec, can be a field item. It is a slider from 4 to 300 seconds, or the lengths the app allows in allowedValues, such as [15, 20, 25]. In inputOverrides, send it as a number: 20, not "20". A flat input takes either.
Nothing else on a UGC node can be set by a run. Any other field on a UGC node, such as a kept result, a script or a clip, is refused with 400 locked_field. See A UGC video in an app.
Step 3: Build the form
// Adapt to your framework.
type FormField = {
nodeId: string
field: string
label: string // "aspectRatio" becomes "Aspect ratio"
control: "text" | "textarea" | "number" | "toggle" | "select" | "upload"
options?: Array<string | number | boolean>
defaultValue: unknown
}
async function buildForm(slug: string, baseUrl: string): Promise<FormField[]> {
const app = await fetch(`${baseUrl}/v1/app/${slug}`).then((r) => r.json())
const nodesById = new Map(app.snapshotNodes.map((n) => [n.id, n]))
const fields: FormField[] = []
const walk = (items) => {
for (const it of items ?? []) {
if (it.type === "group") walk(it.items)
if (it.type !== "field") continue
const node = nodesById.get(it.nodeId)
const current = node?.data?.[it.field]
fields.push(toFormField(it, node, current))
}
}
walk(app.snapshotSettings?.presentationSettings?.inputItems ?? [])
return fields
}- Key the values by node id, not by label. The run body is
{ "inputOverrides": { "<nodeId>": { "<field>": value } } }. - Nodaro enforces
allowedValues. A value outside the list is refused with400 validation_error. So is a value outside the fixed list of a quick-setting field. - Nodaro enforces
maxLength. A text longer than the limit is refused with400 input_too_long. Putmaxlengthon the text area and show a counter. The app's price assumes this limit for a speech input. - Nodaro enforces picker limits. The publisher can limit a picker, such as Setting, Mood or Person, to part of its catalog, in
snapshotSettings.presentationSettings.cardMeta[nodeId]. A single-dimension picker usespickerAllowedValues, and a multi-dimension picker usespickerAllowedValuesByField({ field: [ids] }). A value outside the list is refused with400 validation_erroron every run of the app, API runs included. To withdraw a pick, leave the field out or send an empty value. Do not send the catalog default. - Prefill the defaults from
snapshotNodes[i].data, so a user who changes nothing still gets a meaningful run. - Show
estimatedCredits, so users know the cost before they run. WhenperMinuteCreditsorperItemCreditsis not 0, the price grows with the length of the recording or the number of list items: show a fixed part plus those parts.estimatedCreditsincludes any Render final, andrunEstimatedCreditswith its per-minute and per-item parts is the run alone. WhenmaxEstimatedCreditsis present, the price depends on what the user picks: show it as a range, such as "from 5 – up to 9". "Up to" is the most a run can cost over every exposed field, at the publisher's allowed values where set, each model's own options, and a reference clip the user supplies at its longest. A version published before this range existed carries nomax*fields until it is published again. WhenestimatedCreditsIsEstimateistrue, the price holds an LLM call's estimate. That call is charged for the tokens it actually uses, so show the price as~5, not5. - Show the daily limit when
maxRunsPerUserPerDayis set, such as "2 of 5 runs used today", to avoid a surprise429.
Step 4: Start a run and poll it
Start the run with a token:
curl -X POST https://app.nodaro.ai/v1/app/<slug>/run \
-H "Authorization: Bearer $NODARO_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"inputOverrides": {
"node-abc": { "prompt": "a cat astronaut", "aspectRatio": "16:9" }
}
}'The answer is 202 Accepted:
{ "executionId": "exec-uuid", "runId": "run-uuid", "status": "pending" }| Optional body field | What it does |
|---|---|
version | Runs a given version of the app. The default is the latest. |
inputs | The app's inputs as a flat map of input names to values, as the SDK and the CLI send them. inputOverrides is applied over inputs, field by field, and wins where both set the same field. A field that is a number, such as an exposed slider, takes a number or a numeric string: "0.4" arrives as 0.4. |
runId | Attaches the run to a draft run you created before. You can ignore it for a first build. |
inputOverrides can also set node fields the app does not expose, such as promptPrefix; see Prompt pre and post text. It can never change where an outbound node sends or fetches, such as a Webhook Output address, or which workflow a Sub-Workflow node runs: that run is refused with 400 locked_field.
An app with a creator's fee. On Nodaro Cloud, a run of an app with a creator's fee is checked against the runner's balance before anything is written or run. The balance must cover the app's listed run price with the fee:
- The fee is the listed price it applies to, plus the flat fee, plus the percentage of that price, rounded up to a whole credit.
- The flat fee counts in full, even when the listed price is 0.
- A run the balance cannot cover is refused with
402 insufficient_credits. The message names the run's price with the fee, the fee, and the balance. - The creator running their own app pays no fee and is not checked.
On a run paid by an organization workspace, the workspace pays the fee when its organization approved the app. Otherwise the runner pays the fee:
- An approved app's run is refused with the workspace's own
402 budget_exceededwhen the budget's headroom cannot cover the listed price with the fee. - An app the organization has not approved is refused with
402 insufficient_creditswhen the runner's own balance cannot cover the fee. The workspace still pays the run itself.
The fee is settled once the run completes, and only what its payer then holds is collected. That is at most the runner's balance, which never goes below zero for the fee, or at most the workspace budget's headroom. The creator is credited exactly the amount collected. The rest is collected from nobody and paid to nobody. The creator's earnings record keeps the fee that was asked (flatFee, percentFee) beside what was collected (totalEarned). See Workspaces.
Then poll the run:
curl https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
-H "Authorization: Bearer $NODARO_API_TOKEN"{
"id": "run-uuid",
"executionId": "exec-uuid",
"status": "pending|running|completed|failed",
"creditsUsed": 0, // the run's total, every attempt included
"thumbnailUrl": null, // set when the run completes
"execution": {
"status": "pending|running|completed|failed",
"nodeStates": {
"node-abc": {
"status": "completed",
"output": { // the shape depends on the node's outputType
"url": "https://.../result.png",
"imageUrl": "...",
"videoUrl": "...",
"audioUrl": "...",
"resultUrl": "...",
"text": "..."
}
}
},
"totalNodes": 5,
"completedNodes": 5,
"failedNodes": 0,
"totalCreditsUsed": 5, // this attempt's own
"errorMessage": null,
"completedAt": "2026-05-07T..."
}
}- Poll about every 2 seconds. Stop when
execution.statusiscompletedorfailed. - Show progress as
completedNodesout oftotalNodeswhile the run is going. - On failure, show
execution.errorMessage.
A run's owner can change only its name, inputValues, hiddenNodes and nodeStates, with PATCH /v1/app/{slug}/runs/{runId}. The server ignores every other field, and a PATCH that names none of these four answers 400 bad_request.
Run views carry your own edits of the run's results as nodeStateEdits, the map that the PATCH sets with nodeStates. The PATCH replaces that whole map. Send every edit already stored, merged with yours, or the edits you leave out are lost.
Step 5: Show the result
The main result is the output of the node thumbnailNodeId names. Read its URL from execution.nodeStates[thumbnailNodeId].output, trying these keys in order: url, imageUrl, videoUrl, audioUrl, resultUrl, then text for a text result, which you show as text rather than as a link.
Render it according to the node's outputType, from GET /v1/nodes/{type}:
outputType | Render it as |
|---|---|
image | An image |
video | A video player with controls |
audio | An audio player with controls |
text | Preformatted text or Markdown |
data | A JSON viewer, or opaque data |
When thumbnailNodeId is null, use the last node in the workflow's order, or show every output that is not empty.
Render the final of a run
When an app's Apply EDL render makes a Preview, the app's page offers Render final on it. From code, send POST /v1/app/{slug}/runs/{runId}/render-final with { "renderNodeId": "<render node id>" }. The server renders that render at Final for this run only, then runs every node after it, once. Nothing before the render runs or is billed again.
The answer is 202 with { executionId, runId, status: "pending" }. An Idempotency-Key header of 8 characters or more makes a retry of the same request answer 200 with "deduped": true, and start nothing.
- Who pays. The final is charged to the person who runs the app, at the nodes' own prices, as a second run outside the app run. The creator's fee on the app does not apply. The final adds no row to the run history and does not count toward the app's daily run limit.
- Your review counts. An Edit Plan review stored on the run with the
PATCHabove is applied to the final. - Where the results appear. Once the final has results,
GET /v1/app/{slug}/runs/{runId}shows them inexecution.nodeStates, over the Preview's states, and marks each with"fromRenderFinal": true. The run also carriesfinalExecution: { id, status, completedNodes, totalNodes, errorMessage, completedAt, creditsUsed }, ornullwhen no final was asked for. The run's owncreditsUsedstays the app run's. - A Tighten Episode node is finished the same way. A Tighten Episode node with Review before render on holds its own Preview, so name the node itself as
renderNodeId. The final renders the episode at Final with your review of its edit, then adds its captions and music bed, with no Tighten Episode fee. The nodes after it already ran on the Preview in the app run, and run again in the final. Only a Preview take is finished: a take that is Final, or one that ran once per list item, is refused withrender_final_not_preview. - A failed final is not shown.
finalExecutionreports itsstatusanderrorMessage, and the views show the Preview again.
| Status | Code | Meaning |
|---|---|---|
| 400 | not_a_render | renderNodeId is not a render node of the app. |
| 400 | preview_render_nested | The app is a component. |
| 402 | insufficient_app_credits | Your credits cannot cover the final. |
| 404 | not_found | No such run of yours under this app. |
| 409 | continuation_not_completed | The run is a draft with no run yet, or it has not completed. |
| 409 | already_running | A final of this run is still rendering. Its id is in executionId. |
| 503 | run_finals_unavailable | The run's earlier finals could not be read just now. Try again: nothing was started. |
Each refusal comes before anything is written or billed.
Retry a failed run
A failed app run is continued from where it stopped, not started over. Send POST /v1/app/{slug}/runs/{runId}/resume. This is the Retry button on the app's page. Only the steps the run did not complete run again. Every step it completed hands on its result, and is not charged again. A UGC Clip that failed renders only the clips it did not finish.
- Send
{ "dryRun": true, "version": <n> }first. The answer is200withsteps(the steps that run again),estimatedCredits(what the Retry is expected to charge, the creator's fee included),reusedClips,sufficientandavailable.sufficientsays whether your balance and your app credits cover the Retry.appCreditsAllowanceis present only when your app credits are what stops it: the Retry would then be refused withinsufficient_app_credits. Nothing starts. - Show the steps and the price to the user.
- Send the same body without
dryRunonce the user agrees. The answer is202with{ executionId, runId, status: "pending", nodeIds }. Poll the run as in Step 4.
- The run's own version and inputs. The Retry runs the app version the run ran, with the inputs the run applied. It takes no inputs of its own. Send
"version"with the version your interface runs. Without it, the app's latest version is used. - Start over instead. To run with other inputs or on the app's current version, start the run over:
POST /v1/app/{slug}/runwith"runId": "<runId>". Every step runs again and is charged again. - One Retry at a time. Pressing it twice at once starts one Retry: the second answer is
200with"deduped": true. A press while that Retry is pending answers409 already_running, which names it. A Retry of a Retry continues from what the latest one completed. - The creator's fee is settled once, on what the Retry runs. Like an app run, the Retry draws on the runner's app credits.
- Credits across attempts. The run's
creditsUsedis its total across attempts: what the failed run spent plus what each Retry spent. It stays up to date while a Retry runs.execution.totalCreditsUsedis the current attempt's spend alone. Starting the run over begins a new total. - Analytics count a run once. In the app's analytics, a run counts by how its latest finished attempt ended. A run that failed and then completed on Retry is one successful run. A Retry adds no run. Each attempt's credits count on the day that attempt ended.
- From the SDK, the CLI and MCP. The SDK calls the Retry as
client.apps.resume, the CLI asnodaro apps run-retry, and the MCP server asretry_app_run.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | A malformed run id or body, or the run of a component. A component's run is continued by the run that called it. |
| 400 | continuation_workflow_mismatch / continuation_version_mismatch | The run's execution ran another workflow, or another version of it. This is rare. Start the run over. |
| 400 | locked_field | An input the run applied sets a field the app does not let a runner set. |
| 400 | preview_review_required | The Retry would stop at a Preview without the app page's mark. |
| 402 | insufficient_app_credits / insufficient_credits | The runner's app credits or balance cannot cover the Retry. |
| 404 | not_found | No such run of yours under this app. |
| 404 | continuation_not_found | The run's execution is not yours. |
| 409 | app_run_version_unavailable | The version the run ran is no longer available. Start the run over. |
| 409 | app_run_version_changed | The app is on another version than the run (appVersion, runVersion). Start the run over, or send the run's version. |
| 409 | continuation_not_failed | The run did not fail: it completed, or it was stopped. Start it over. |
| 409 | already_running | The run is still running (executionId), or it was started again elsewhere. |
| 409 | continuation_subset_required | Every step of the run completed. There is nothing to retry. The status is 400 when the app runs no step at all. |
| 503 | billing_unavailable | Billing is briefly unavailable for a workspace run. Try again shortly. |
When a Retry is refused with a 409, offer to start the run over.
Delete a run
curl -X DELETE https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
-H "Authorization: Bearer $NODARO_API_TOKEN"A delete moves the run to the user's archive and answers { "success": true, "archived": true }. Nothing is destroyed: an automation that deletes the wrong run cannot lose the user's data. Restoring a run and deleting it for good are possible only in the Nodaro app, so your integration should not try either.
Authenticate
Every run needs a bearer token. Choose the kind by who should own and pay for the runs:
| You are building | Use |
|---|---|
| A personal tool, an agency dashboard or an internal automation | A personal API token. |
| A SaaS where customers connect their own Nodaro accounts | OAuth. |
| A public tool that runs an app for anonymous visitors | A personal API token. You pay, so limit the runs per visitor on your side. |
Personal API token. Your account owns and pays for every run. Create the token in Settings › API Tokens with Create Token, copy it once, and store it as a server secret. It lasts until you revoke it. See Authentication.
OAuth. Each user's own account owns and pays for their runs. Register a developer app, send the user to the consent screen with the scopes workflows:execute and jobs:read. workflows:execute starts runs, retries them, renders their final, and renames or deletes them. jobs:read polls a run and lists runs, and workflows:execute or assets:read alone is accepted for these too. Loading the app with GET /v1/app/{slug} needs no scope. Exchange the code on your server for an access token that lasts 90 days. If a call answers 403 insufficient_scope, send the user through consent again with the broader scopes.
Keep the token on your server
The token must never reach the browser. Bundlers inline every environment variable with a VITE_ or NEXT_PUBLIC_ prefix into the JavaScript they ship, where anyone with developer tools can read it and spend your credits.
[Browser] --HTTPS--> [Your server or edge function] --HTTPS--> Nodaro API
(holds NODARO_API_TOKEN)| Stack | Where the token lives |
|---|---|
| Lovable with Supabase | A Supabase Edge Function secret: supabase secrets set NODARO_API_TOKEN=... |
| Next.js | A server route handler, with process.env.NODARO_API_TOKEN and no NEXT_PUBLIC_ prefix |
| SvelteKit, Remix, Nuxt | A server-only environment variable, such as $env/static/private in SvelteKit |
| Vercel or Netlify edge function | A project environment variable that is not exposed to the client |
| Cloudflare Worker | A Worker secret: wrangler secret put NODARO_API_TOKEN |
Because your browser code talks only to your own server, you do not need to add your domain to Nodaro's allowed origins. If you do call Nodaro from the browser with OAuth, register your origin in the developer app's Allowed origins.
Errors
Errors have the shape { "error": { "code": "...", "message": "..." } }.
| Status | Code | Cause | What to do |
|---|---|---|---|
400 | validation_error | A malformed inputOverrides, a value outside allowedValues or a picker's allowed list, or a malformed slug. | Fix the request body. |
400 | input_too_long | A text input is longer than the field's maxLength. | Shorten the text to the limit that the item declares. |
400 | locked_field | An override names a destination of an outbound node, such as a Webhook Output address, or a field that a UGC node does not take from a run. | Remove it. The app decides where it sends and fetches, and a UGC node takes only the creator card's fields and the video length. |
400 | ugc_photo_required | A UGC Creator is set to My photo, and the run gives no photo of the runner's own. | Ask for the runner's photo, or switch the card to AI creator. |
401 | unauthorized | The token is missing, expired or revoked. | Create a new token, or authorize again. |
402 | insufficient_app_credits | The account behind the token has too few credits. | Add credits or change the plan. |
402 | insufficient_credits | An app that makes a UGC video: the balance does not cover what the video can hold while its clips render. | Add credits, or make a shorter video. |
402 | insufficient_credits | An app with a creator's fee: the balance does not cover the app's listed run price with the fee. On a workspace run of an app the organization has not approved, the runner's own balance does not cover the fee. The creator's own run is not checked. | Add credits. |
402 | budget_exceeded | A workspace run of an app with a creator's fee that the organization approved: the workspace budget cannot cover the app's listed run price with the fee. | Ask a workspace admin for more headroom. |
403 | insufficient_scope | The OAuth token lacks a scope, named in missingScope. | Authorize again with the broader scopes. |
404 | not_found | The slug or the run does not exist, the app is deactivated, or the run's execution does not belong to the run's owner. | Check the slug, and show "App unavailable". |
429 | rate_limit_exceeded | The app's daily run limit per user is reached. | Show "Daily limit reached". |
500 | internal_error | A server fault. | Retry once after a pause, and report it if it persists. |
Reference templates
A Supabase Edge Function that proxies the app
// supabase/functions/nodaro-run/index.ts
import { serve } from "https://deno.land/std@0.224.0/http/server.ts"
const BASE = Deno.env.get("NODARO_BASE_URL")! // for example https://app.nodaro.ai
const TOKEN = Deno.env.get("NODARO_API_TOKEN")! // ndr_...
const SLUG = Deno.env.get("NODARO_APP_SLUG")! // my-cool-app
serve(async (req) => {
const url = new URL(req.url)
// Public probe: no token is sent, because the endpoint is public.
if (req.method === "GET" && url.pathname.endsWith("/schema")) {
const r = await fetch(`${BASE}/v1/app/${SLUG}`)
return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
}
// Start a run.
if (req.method === "POST" && url.pathname.endsWith("/run")) {
const { inputs } = await req.json()
const r = await fetch(`${BASE}/v1/app/${SLUG}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ inputOverrides: inputs }),
})
return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
}
// Poll a run.
const m = url.pathname.match(/\/runs\/([0-9a-f-]{36})$/)
if (req.method === "GET" && m) {
const r = await fetch(`${BASE}/v1/app/${SLUG}/runs/${m[1]}`, {
headers: { "Authorization": `Bearer ${TOKEN}` },
})
return new Response(await r.text(), { status: r.status, headers: { "content-type": "application/json" } })
}
return new Response("Not found", { status: 404 })
})Browser code: read, run, poll
// 1. On mount: read the schema and build the form (steps 1 to 3).
const schema = await fetch("/functions/v1/nodaro-run/schema").then((r) => r.json())
const inputItems = schema.snapshotSettings?.presentationSettings?.inputItems ?? []
// 2. On submit: send the values keyed by node id.
const start = await fetch("/functions/v1/nodaro-run/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputs: { "node-abc": { prompt: form.prompt, aspectRatio: form.aspectRatio } },
}),
}).then((r) => r.json())
// 3. Poll every 2 seconds until the run ends.
let last = start
while (last.status !== "completed" && last.execution?.status !== "completed"
&& last.status !== "failed" && last.execution?.status !== "failed") {
await new Promise((res) => setTimeout(res, 2000))
last = await fetch(`/functions/v1/nodaro-run/runs/${start.runId}`).then((r) => r.json())
}
// 4. Read the result.
const out = last.execution?.nodeStates?.[schema.thumbnailNodeId]?.output ?? {}
const heroUrl = out.url ?? out.imageUrl ?? out.videoUrl ?? out.audioUrl ?? out.resultUrl
const heroText = out.textIn this template, the browser sends values keyed by node id, and the edge function passes them on as inputOverrides.
Checklist before you generate interface code
GET {BASE}/v1/app/{slug}returned the input schema and the defaults.- For each
nodeIdininputItems, you know itstypefromsnapshotNodes. - For each node type,
GET {BASE}/v1/nodes/{type}told you its category,outputTypeand models. - You built the list of form fields with the rules of step 2.
- You know the result node,
thumbnailNodeIdor the last node, and itsoutputType. - You have a personal API token or developer app credentials.
- The secret lives on your server.
Frequently asked questions
How do I embed a Nodaro MiniApp on my website?
Open MiniApps, choose My MiniApps and click Embed on the app's card. Add your site's domain under Allowed Embed Domains, click Copy Embed Code and paste the iframe into your page. Embedding is blocked until at least one domain is added.
Which endpoints do I need to build my own MiniApp interface?
GET /v1/app/{slug} and GET /v1/nodes/{type} to learn the inputs, both public. Then POST /v1/app/{slug}/run to start a run, and GET /v1/app/{slug}/runs/{runId} to poll it, both with a token.
Does Nodaro call my server when a MiniApp run finishes?
No. A MiniApp run does not call you back. Poll the run about every 2 seconds until its status is completed or failed.
Who pays for the runs of an embedded MiniApp?
The account the run acts as. With a personal API token, your account pays for every run. With OAuth, each user's own account pays for their runs.
Can I delete a MiniApp run through the API?
Yes. DELETE on the run archives it, and the user can recover it in the Nodaro app. Restoring and deleting for good are possible only in the Nodaro app.
Related
Embeds
Apps (MiniApps)
OAuth apps
Authentication
TypeScript SDK
Last updated on
Embeds
The two ways to put Nodaro inside your own product, a MiniApp that runs a published workflow and the stateless 3D scene viewport, and when to use each one.
Embed the 3D scene viewport
Frame Nodaro's 3D scene viewport in your app and drive it with postMessage, from the handshake and state messages to edit events, asset transport and limits.