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 credits one run costs
"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 },
{ "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 and the optional allowedValues. |
node | The whole node's default block. Skip it in a custom form, or render every field of the node. |
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. |
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." }
}
}
}
}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. - 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. - 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. |
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: that run is refused with 400 locked_field.
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,
"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,
"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.
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.
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 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, to start runs, and jobs:read, to poll them. 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 malformed slug. | Fix the request body. |
400 | locked_field | An override names a destination of an outbound node, such as a Webhook Output address. | Remove it. The app decides where it sends and fetches. |
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. |
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, or the app is deactivated. | 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
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.