---
title: Apps API
description: Publish a workflow as an App, then call it over HTTP with an API key. The caller pays for the run. Make it private to keep it yours.
updated: 2026-10-04
---

An **App** is a workflow you have published. Once it is published, anyone holding an API key with the `apps:execute` scope can run it with one POST and read the result back with a GET. The key's owner pays for the run, not you. If you want an app only you can call, publish it as [private](#private-apps). This page covers the HTTP side. For the editor side (subdomains, sign-in, pricing for visitors) see [Publishing Apps](/docs/publishing-apps).

The flow has four steps:

1. Publish the workflow and get its slug.
2. Find the input keys the app accepts.
3. `POST /api/v1/apps/{slug}/execute` to start a run.
4. `GET /api/v1/apps/{slug}/executions/{executionId}/poll` until it finishes. The execute response hands you this path as `pollUrl`.

## 1. Publish

Publish from the **Publish** button in the editor, or over the API with a key that has the `workflows:write` scope:

```bash
curl -X POST https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/publish \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Kawarimi",
    "description": "Prompt in, image out.",
    "customSlug": "kawarimi"
  }'
```

```json
{
  "slug": "kawarimi",
  "url": "https://kawarimi.wireflow.ai",
  "publishedAt": "2026-10-02T12:00:00.000Z",
  "publishSettings": {
    "title": "Kawarimi",
    "exposedInputs": ["node-prompt"],
    "dailyCreditBudget": 100,
    "creditsPerRun": 0,
    "visibility": "public"
  }
}
```

Things worth knowing:

- Any workflow owner can publish. There is no admin gate.
- `customSlug` must be 3 to 60 characters: lowercase letters, numbers and hyphens, starting and ending with a letter or number. A slug already used by another app or collection returns `409`. If you send no `customSlug` on a first publish, the slug is the workflow name plus the first 10 characters of the workflow id. A workflow that was published before keeps its slug.
- `exposedInputs` is the list of input node ids the app accepts. If you leave it out or send an empty list, every Input node in the workflow is exposed.
- `visibility` is `"public"` (the default) or `"private"`. See [Private apps](#private-apps). Anything else returns `400`.
- Every publish call rebuilds the app settings from the request body. Send your `title`, `description`, `exposedInputs`, `exampleOutputs` and `dailyCreditBudget` again when you update an app, or they reset (`dailyCreditBudget` goes back to 100). `visibility` is the exception: leave it out and the app keeps whatever it had, so a plain re-publish cannot turn a private app public.
- The app runs the saved workflow at call time, so edits to the workflow take effect on the next run.
- `DELETE /api/v1/workflows/{id}/publish` unpublishes the app. The slug stays reserved for you.

## 2. Find the input keys

Every input has a **key**, and the key is what you send in `inputs`. Two ways to read them:

**Anyone, no key** (a public app). The public app config lists the exposed inputs. For a private app only the owner gets it: send an API key with the `apps:execute` scope, or be signed in as the owner. Everyone else gets `404`.

```bash
curl https://www.wireflow.ai/api/v1/apps/kawarimi
```

```json
{
  "title": "Kawarimi",
  "description": "Prompt in, image out.",
  "inputs": [
    {
      "nodeId": "node-prompt",
      "key": "prompt",
      "label": "Prompt",
      "type": "text",
      "placeholder": ""
    }
  ],
  "ownerName": "Your Name",
  "publishedAt": "2026-10-02T12:00:00.000Z"
}
```

The public config never includes an input's saved value, only its id, key, label, type and placeholder. A saved value can be a credential, and this endpoint needs no key.

**The owner.** `GET /api/v1/workflows/{id}/publish` (needs `workflows:write`) returns `inputNodes` with `nodeId`, `label` and `type` for every Input node, plus the current `publishSettings`. It does not include keys, so read those from the public config above.

Where a key comes from:

- The key the author set on the Input node, if there is one.
- Otherwise the node's label, or its name if it has no label. A node with neither is read by its id, so `input-1760000000000` becomes `input_1760000000000`.
- Either way only the letters A to Z and the digits 0 to 9 are kept, lowercased. Anything else is a separator, a run of separators becomes one underscore, and the result is cut to 64 characters. `Reference Images` becomes `reference_images`. Accented letters drop out: `Café Menu` becomes `caf_menu`.
- If two inputs would end up with the same key, or the result is empty, the input uses its exact node id instead. A label with no A to Z letters or digits, such as one written in Japanese or Cyrillic, ends up here.

A key does not change when the author deletes a node and adds a new one with the same label, which a node id does. It does change if the label is renamed, and an input that fell back to its node id changes with the node. The workflow owner gets the same keys from `GET /api/v1/workflows/{id}/run`.

One rare case: if an input's key is also the node id of a different node, the node id wins on execute, so the config lists that input under its own node id instead.

After a successful publish, the Publish dropdown in the editor also shows a ready-made curl call with the real slug and node ids filled in. Those calls work as they are. Swap in the keys if you want calls that survive a re-created node.

## 3. Execute

```bash
curl -X POST https://www.wireflow.ai/api/v1/apps/kawarimi/execute \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"inputs": {"prompt": "a fox in a paper boat"}}'
```

The key needs the `apps:execute` scope. A full-access key has it.

**Request body**

| Field    | Type                      | Description                                                                                                     |
| -------- | ------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `inputs` | `object` of key to string | Keys are the `key` values from step 2. Node ids from the same list also work. Values are the text or image URL. |

**Response** (`200`, the run has started, it has not finished)

```json
{
  "executionId": "cm1exec123",
  "status": "RUNNING",
  "pollUrl": "/api/v1/apps/kawarimi/executions/cm1exec123/poll",
  "pollToken": "..."
}
```

`pollUrl` is the path of the poll route for this run. Put the host in front of it (`https://www.wireflow.ai`). It carries no credential, so authenticate the poll as described in step 4.

`pollToken` is a short-lived token for reading this one run back without the key. It is valid for 24 hours. It is left out of the response if the server could not sign one, so do not depend on it. With the same API key you do not need it.

### What the inputs do

- Send each input under its `key`. A key that matches no exposed input returns `400` with `code: "unknown_input"`. The message names the key and the body lists `validKeys`. Nothing runs and nothing is charged. A long key is shortened to 64 characters in the reply, and at most 20 are named.
- A value sent under a key must be a string of at most 2000 characters, and an image input must be an `https://` URL. A value that breaks a rule returns `400` (`invalid_input_type`, `input_too_long` or `invalid_media_input`) and names the key. Under a key, nothing is cut or skipped without an error.
- **Video and audio inputs cannot be set through the Apps API yet.** An Import node that takes a video or audio file (the config lists its `type` as `video` or `audio`, and an Import node with no type yet is listed as `text` and refused the same way) has no way to receive a value here: the step that places your value only delivers images and text, so the run would use the author's saved file and still be charged. A value sent under the key of such an input returns `400` with `code: "unsupported_media_input"`, and nothing is charged. The real fix is tracked in [#2550](https://github.com/wireflowINC/wireflow/issues/2550).
- Sending the key and the node id of the same input in one call returns `400` (`ambiguous_input`), because there is no telling which value you meant.
- `inputs` has to be an object. A string, a number, `true` or a list with items in it returns `400` (`invalid_inputs`). Leaving it out, or sending `null`, `false`, `0`, `""` or `[]`, runs the app with no inputs.
- Node ids that exist in the workflow keep working exactly as before, so existing integrations do not change. A value sent under a node id is converted to a string and cut to 2000 characters, a non-https image URL is skipped and the app runs with the author's saved image, a value for a video or audio input is accepted and has no effect (the same #2550 gap), and a node id that is not an exposed input is ignored. One thing did change: a node id that matches no node in the workflow at all is now `unknown_input`, like any other unknown key. It used to be ignored, so a stale id ran the app on the author's saved value.
- Other Input node types take the string as their text value. An empty string (or only spaces) counts as no value, so the app runs with the author's saved text for that input. That holds whether you send it under a key or a node id.
- Utility nodes you list in `exposedInputs` (a TikTok import, for example) also accept a value, by key or by node id. The value is set as the node's `url`. The public config does not list these nodes. Use the node id, or read the key from `GET /api/v1/workflows/{id}/run` as the owner.
- An empty or unreadable JSON body is treated as no inputs.

### Who pays

The owner of the API key pays the credits for every node in the run. The app creator is not charged. A `creditsPerRun` price set on the app is a charge on signed-in visitors using the web page, and it is not applied to API key calls.

The key owner must have more than 0 credits to start, otherwise the call returns `402`.

### Without a key

A call with no `Authorization` header is treated as a visitor run on the public page. The app owner pays, a limit of 3 requests per minute per IP applies, and the app's `dailyCreditBudget` (100 credits a day by default) caps total public usage. Use a key for anything programmatic. A header that does not start with `Bearer sk-` is also treated as a visitor run, so check your key is sent exactly as in the examples.

## 4. Poll

```bash
curl https://www.wireflow.ai/api/v1/apps/kawarimi/executions/cm1exec123/poll \
  -H "Authorization: Bearer sk-your-api-key"
```

Authenticate with one of:

- An API key with `apps:execute` that belongs to the **same account** as the key that started the run. It does not have to be the exact same key. A key owned by someone else gets `404`.
- The `pollToken`, as the `x-wireflow-run-token` header or the `?t=` query parameter.

**Response while running**

```json
{ "status": "RUNNING", "outputs": [] }
```

**Response when done**

```json
{
  "status": "COMPLETED",
  "outputs": [
    {
      "nodeId": "node-image",
      "type": "image:generate",
      "urls": ["https://cdn.wireflow.ai/outputs/abc.png"]
    }
  ]
}
```

Each item in `outputs` has `nodeId` and `type`, and either `urls` (media), `text`, or both. Text-only items can also carry a `frameUrl`. Outputs come from the nodes the author picked as example outputs when publishing. If none were picked, they come from the workflow's last nodes (nodes with no outgoing wire).

`status` is the stored run status. Keep polling while it is `RUNNING` (or `PENDING`). `COMPLETED` and `FAILED` are the normal endings, and `CANCELLED` and `TIMEOUT` can also appear. A failed run adds an `error` string:

```json
{ "status": "FAILED", "outputs": [], "error": "The app encountered an error" }
```

Poll every few seconds.

## Private apps

A private app is for its owner only. It is how you package a pipeline as your own API: publish it private, call it with your own key (or from your own MCP client), and nobody else can find it, open it or run it.

Publish it private with `visibility`, or flip the **Private** switch in the Publish dropdown:

```bash
curl -X POST https://www.wireflow.ai/api/v1/workflows/YOUR_WORKFLOW_ID/publish \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"title": "Kawarimi", "customSlug": "kawarimi", "visibility": "private"}'
```

`GET /api/v1/workflows/{id}/publish` returns the current audience as a top-level `visibility`. An app published before this existed reads as `public`. To make it public again, publish with `"visibility": "public"`.

**Who the owner is.** The account that published the app, and nobody else. A teammate, a collaborator with edit access and a visitor on a link-shared board are not the owner here, and they get the same `404` a stranger gets. They keep whatever access they had to the workflow itself: the canvas, and the workflow's own run door `POST /api/v1/workflows/{id}/execute`, which admits anyone who can edit it (`POST /api/v1/workflows/{id}/run` is owner-only and always was). Over MCP, someone who could read or run the workflow only because it was published loses `get_workflow`, `clone_template` and `run_workflow` on it once it is private. The reason is billing: a signed-in run through the app page is paid by the app's owner, and a public edit link makes everyone an editor.

**What a stranger gets.** `404 {"error": "App not found"}` from every door, byte for byte what a slug that does not exist returns. It is never a `403`, so neither the status nor the body of the answer says whether the slug exists. That covers the app page (`{slug}.wireflow.ai`, including its title in the page metadata), `GET /api/v1/apps/{slug}`, `POST /api/v1/apps/{slug}/execute`, the upload, access and register routes, the run history, collection pages and the collection API (a private app is left out of the list), and the MCP `run_app` tool. A bad input from a stranger is a `404` too, not the `400` that lists the app's input keys. Nothing is charged.

**What the owner does.** Everything, with the same calls: by session on the page, or with a key holding `apps:execute` over the API. The key must belong to the owner. On `GET /api/v1/apps/{slug}` a bad key, a key without the scope or someone else's key counts as no key and gets the `404`. On `POST /api/v1/apps/{slug}/execute` a bad or under-scoped key still gets its usual `401` first, before the app is looked up, so that answer says nothing about the slug.

Things worth knowing:

- A **game** is playable by anyone with the link, so a game cannot be private. Publishing one with `visibility: "private"` returns `400`. A private **site** (a compositor page) is fine: only you can open it.
- Private is about the app, not the board. Link sharing and the `template` tag are separate settings, and Private does not change them. If you want the graph hidden as well, leave link sharing on Private.
- A run someone started before you made the app private stays readable by whoever started it: with their own session, or with the run token until it expires (24 hours). Nobody can start a new one.
- Calling a private app costs the key owner's credits, which are yours, like any other key call.

### From MCP: `run_app`

The MCP server has a `run_app` tool that runs an app by slug, for any app that is public and for your own private ones:

```json
{ "slug": "kawarimi", "inputs": { "prompt": "a fox in a paper boat" } }
```

It takes the same `inputs` keys as the HTTP call and goes through the same code, so the key rules, the refusals and the pricing are identical. It returns an `executionId`, a `pollUrl` and, when the server could sign one, a `pollToken`. Poll with `get_execution` (needs `executions:read`), or `GET` the `pollUrl` with the token as `?t=` or the `x-wireflow-run-token` header. The credits are the connected account's. It needs the `workflows:execute` permission, which is in the default grant. A parameter the tool does not have is refused, not dropped, so an input passed at the top level instead of inside `inputs` cannot silently run the app on its saved values. Someone else's private app, and a slug that does not exist, both come back as `App not found`.

## Errors

Errors from the Apps routes are `{ "error": "message" }`. Some add a `code` and extra fields, listed below.

The publish route uses two shapes. A `401` for a bad or missing key returns the nested form `{ "error": { "type", "message", "code" } }` described in [Errors](/docs/api/errors). The checks inside the route (`404` workflow not found, `400` bad slug or mode, `409` slug taken) return the flat form `{ "error": "message" }`.

| Status | Route   | Meaning                                                                                                                                                                                                                                                 |
| ------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401`  | execute | Bad key: `Invalid API key`, `API key has expired`, `Account suspended`, or `API key missing required scope: apps:execute`.                                                                                                                              |
| `402`  | execute | `Insufficient credits. Please top up your account.` The key owner has no credits.                                                                                                                                                                       |
| `404`  | execute | `App not found`. The slug does not exist, the app is unpublished, or the app is private and the caller is not its owner. All three look the same.                                                                                                       |
| `404`  | poll    | `Execution not found`. The id is wrong, belongs to another app, or the caller did not start it. All three look the same.                                                                                                                                |
| `429`  | execute | `Too many requests. Please try again later.` Limit is 20 requests per minute per API key. Honor the `Retry-After` header (seconds).                                                                                                                     |
| `429`  | execute | `This app has reached its daily usage limit. Please try again tomorrow.` Only on calls with no key: the app's `dailyCreditBudget` for the day is spent.                                                                                                 |
| `400`  | execute | `code: "team_blueprint_in_published_app"` with the `nodeId`. Only on calls with no key, when the app contains a team blueprint.                                                                                                                         |
| `400`  | execute | A node is wired to a context blueprint. The body carries `code: "context_blueprint_output_wired"` and the `nodeIds`.                                                                                                                                    |
| `400`  | execute | `code: "unknown_input"`. A key matches no exposed input. The body has `unknownKeys` and `validKeys`.                                                                                                                                                    |
| `400`  | execute | `code: "invalid_input_type"`, `"input_too_long"` or `"invalid_media_input"`. A value sent under a key is not a string, is over 2000 characters, or is an image that is not an `https://` URL. The body has `keys`, and `maxChars` for the length limit. |
| `400`  | execute | `code: "unsupported_media_input"`. A value was sent under the key of a video or audio input, which the Apps API cannot deliver yet ([#2550](https://github.com/wireflowINC/wireflow/issues/2550)). The body has `keys`. Nothing is charged.             |
| `400`  | execute | `code: "ambiguous_input"` (two names for one input) or `code: "invalid_inputs"` (`inputs` is not an object).                                                                                                                                            |
| `503`  | execute | `This app is temporarily unavailable. The creator has been notified.` Returned when the run was refused for lack of credits or an unavailable plan feature. For a key run those are the key owner's credits and plan, whatever the message says.        |
| `500`  | execute | `Server configuration error`, `Failed to start execution`, or `Failed to run app`.                                                                                                                                                                      |

Any other failure from starting the run is passed through with its own status and `error` text.

## Limits

This is how the API behaves today.

- A value sent under a key is a string of at most 2000 characters. A value sent under a node id is cut to 2000 characters.
- A key follows the Input node's key or label, so renaming the label changes it. A node id changes whenever a node is deleted and re-created.
- Video and audio inputs cannot be set through the Apps API yet ([#2550](https://github.com/wireflowINC/wireflow/issues/2550)). A value under a key is refused, and under a node id it is accepted and has no effect.
- Execute returns once the run has started. There is no option to wait for the result in one call. Poll the `pollUrl` it returns.
- 20 execute calls per minute per API key.
- Any caller with an `apps:execute` key can run any public app. To limit an app to yourself, publish it as [private](#private-apps).
- Run results are readable by the key (or token) that started the run, not by the app creator through this route.

---

Documentation index: fetch https://www.wireflow.ai/llms.txt for the full list of Wireflow docs.
