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

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 codeYour own interface
EffortPaste one iframe tag.Build a form and a small server route.
LookThe Nodaro app view.Your own design.
Who the run acts asThe 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 URLhttps://app.nodaro.ai on Nodaro Cloud, or the address of your own install.
App identifierThe 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 shapeAsynchronous. 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.

typeRender it as
fieldA form input. Read nodeId, field, the optional allowedValues and the optional maxLength, a text input's character limit.
nodeThe 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.
outputA live preview of a result. Ignore it while you build the form, and show it after the run.
richtextStatic Markdown the publisher wrote. Render it as it is.
groupA 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:

  1. The input item has allowedValues: a select with those options.
  2. The current value in snapshotNodes[i].data[field] is a boolean: a toggle.
  3. The current value is a number: a number input, or a slider when the field name ends in duration, intensity, strength, scale or temperature.
  4. The current value is a string, and the field name is prompt, description, text, content, caption or message: a multi-line text area.
  5. The current value is a URL, and the node type starts with upload-: a file upload that submits the public URL of the file.
  6. Anything else: a single-line text input.

These field names cover most exposed fields:

Field nameControlNotes
prompt, negativePrompt, text, descriptionText area
model, provider, voice, style, toneSelectOptions come from allowedValues or providers.
aspectRatioSelectUsually 1:1, 16:9, 9:16, 4:3 and 3:4.
resolutionSelectUsually 1K, 2K and 4K, or 720p, 1080p and 4k.
qualitySelectUsually medium and high.
duration, nFrames, seed, temperatureNumber
enableTranslation, addAudio, headless, loopToggle
imageUrl, videoUrl, audioUrl, referenceImageURLUpload 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, duration or draft. Send the value in inputOverrides[nodeId][field], in the type the node stores: 8 for a duration, not "8", and true for 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 with allowedValues.
  • 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_inputs lists every model's values for such a field, and snapshotNodes[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 valueControlValue to submit
A color, as an RGBA array of numbers from 0 to 1Color pickerA hex string, such as "#00ff00"
A stringText inputThe string
A numberSliderThe 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 http or https link 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 with 400 locked_field before 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's youtubeUrl field.
  • 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 rightsConfirmationId in inputOverrides, from POST /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"
    }
  }
}
  • source is sampled, for an AI creator, or photo.
  • gender is woman or man.
  • photoUrl is 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 with 400 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 with 400 input_too_long. Put maxlength on 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 uses pickerAllowedValues, and a multi-dimension picker uses pickerAllowedValuesByField ({ field: [ids] }). A value outside the list is refused with 400 validation_error on 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. When perMinuteCredits or perItemCredits is not 0, the price grows with the length of the recording or the number of list items: show a fixed part plus those parts. estimatedCredits includes any Render final, and runEstimatedCredits with its per-minute and per-item parts is the run alone. When maxEstimatedCredits is 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 no max* fields until it is published again. When estimatedCreditsIsEstimate is true, the price holds an LLM call's estimate. That call is charged for the tokens it actually uses, so show the price as ~5, not 5.
  • Show the daily limit when maxRunsPerUserPerDay is set, such as "2 of 5 runs used today", to avoid a surprise 429.

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 fieldWhat it does
versionRuns a given version of the app. The default is the latest.
inputsThe 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.
runIdAttaches 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_exceeded when the budget's headroom cannot cover the listed price with the fee.
  • An app the organization has not approved is refused with 402 insufficient_credits when 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.status is completed or failed.
  • Show progress as completedNodes out of totalNodes while 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}:

outputTypeRender it as
imageAn image
videoA video player with controls
audioAn audio player with controls
textPreformatted text or Markdown
dataA 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 PATCH above is applied to the final.
  • Where the results appear. Once the final has results, GET /v1/app/{slug}/runs/{runId} shows them in execution.nodeStates, over the Preview's states, and marks each with "fromRenderFinal": true. The run also carries finalExecution: { id, status, completedNodes, totalNodes, errorMessage, completedAt, creditsUsed }, or null when no final was asked for. The run's own creditsUsed stays 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 with render_final_not_preview.
  • A failed final is not shown. finalExecution reports its status and errorMessage, and the views show the Preview again.
StatusCodeMeaning
400not_a_renderrenderNodeId is not a render node of the app.
400preview_render_nestedThe app is a component.
402insufficient_app_creditsYour credits cannot cover the final.
404not_foundNo such run of yours under this app.
409continuation_not_completedThe run is a draft with no run yet, or it has not completed.
409already_runningA final of this run is still rendering. Its id is in executionId.
503run_finals_unavailableThe 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.

  1. Send { "dryRun": true, "version": <n> } first. The answer is 200 with steps (the steps that run again), estimatedCredits (what the Retry is expected to charge, the creator's fee included), reusedClips, sufficient and available. sufficient says whether your balance and your app credits cover the Retry. appCreditsAllowance is present only when your app credits are what stops it: the Retry would then be refused with insufficient_app_credits. Nothing starts.
  2. Show the steps and the price to the user.
  3. Send the same body without dryRun once the user agrees. The answer is 202 with { 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}/run with "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 200 with "deduped": true. A press while that Retry is pending answers 409 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 creditsUsed is its total across attempts: what the failed run spent plus what each Retry spent. It stays up to date while a Retry runs. execution.totalCreditsUsed is 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 as nodaro apps run-retry, and the MCP server as retry_app_run.
StatusCodeMeaning
400validation_errorA malformed run id or body, or the run of a component. A component's run is continued by the run that called it.
400continuation_workflow_mismatch / continuation_version_mismatchThe run's execution ran another workflow, or another version of it. This is rare. Start the run over.
400locked_fieldAn input the run applied sets a field the app does not let a runner set.
400preview_review_requiredThe Retry would stop at a Preview without the app page's mark.
402insufficient_app_credits / insufficient_creditsThe runner's app credits or balance cannot cover the Retry.
404not_foundNo such run of yours under this app.
404continuation_not_foundThe run's execution is not yours.
409app_run_version_unavailableThe version the run ran is no longer available. Start the run over.
409app_run_version_changedThe app is on another version than the run (appVersion, runVersion). Start the run over, or send the run's version.
409continuation_not_failedThe run did not fail: it completed, or it was stopped. Start it over.
409already_runningThe run is still running (executionId), or it was started again elsewhere.
409continuation_subset_requiredEvery step of the run completed. There is nothing to retry. The status is 400 when the app runs no step at all.
503billing_unavailableBilling 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 buildingUse
A personal tool, an agency dashboard or an internal automationA personal API token.
A SaaS where customers connect their own Nodaro accountsOAuth.
A public tool that runs an app for anonymous visitorsA 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)
StackWhere the token lives
Lovable with SupabaseA Supabase Edge Function secret: supabase secrets set NODARO_API_TOKEN=...
Next.jsA server route handler, with process.env.NODARO_API_TOKEN and no NEXT_PUBLIC_ prefix
SvelteKit, Remix, NuxtA server-only environment variable, such as $env/static/private in SvelteKit
Vercel or Netlify edge functionA project environment variable that is not exposed to the client
Cloudflare WorkerA 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": "..." } }.

StatusCodeCauseWhat to do
400validation_errorA malformed inputOverrides, a value outside allowedValues or a picker's allowed list, or a malformed slug.Fix the request body.
400input_too_longA text input is longer than the field's maxLength.Shorten the text to the limit that the item declares.
400locked_fieldAn 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.
400ugc_photo_requiredA 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.
401unauthorizedThe token is missing, expired or revoked.Create a new token, or authorize again.
402insufficient_app_creditsThe account behind the token has too few credits.Add credits or change the plan.
402insufficient_creditsAn 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.
402insufficient_creditsAn 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.
402budget_exceededA 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.
403insufficient_scopeThe OAuth token lacks a scope, named in missingScope.Authorize again with the broader scopes.
404not_foundThe 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".
429rate_limit_exceededThe app's daily run limit per user is reached.Show "Daily limit reached".
500internal_errorA 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.text

In 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 nodeId in inputItems, you know its type from snapshotNodes.
  • For each node type, GET {BASE}/v1/nodes/{type} told you its category, outputType and models.
  • You built the list of form fields with the rules of step 2.
  • You know the result node, thumbnailNodeId or the last node, and its outputType.
  • 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.

Last updated on

On this page