# 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.

Source: https://nodaro.ai/docs/developers/embed/miniapps

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:

```html
<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

```bash
curl https://app.nodaro.ai/v1/app/<slug>
```

The fields that matter for your interface:

```jsonc
{
"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:

```bash
curl https://app.nodaro.ai/v1/nodes/generate-image
```

```jsonc
{
"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 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](https://nodaro.ai/docs/nodes/video/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.

```jsonc
{
"inputOverrides": {
"node-mg1": {
"motionPlan": {
// ...the published node's motionPlan, unchanged...
"slotValues": { "primaryColor": [0, 1, 0, 1], "nameText": "Acme Inc." }
}
}
}
}
```

### Step 3: Build the form

```ts
// 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`.
- **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 `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:

```bash
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`:

```json
{ "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](https://nodaro.ai/docs/concepts/prompt-pre-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:

```bash
curl https://app.nodaro.ai/v1/app/<slug>/runs/<runId> \
  -H "Authorization: Bearer $NODARO_API_TOKEN"
```

```jsonc
{
"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.status` is `completed` or `failed`.
- **Show progress** as `completedNodes` out of `totalNodes` while 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

```bash
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](https://nodaro.ai/docs/developers/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](https://nodaro.ai/docs/developers/api/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.

```text
[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

```ts
// supabase/functions/nodaro-run/index.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

```ts
// 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 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.
