# Workflows

> Build, run, publish and monitor workflows, decide their approvals and start them from webhooks.

A workflow is an automated process made of steps: a trigger starts a run, and the steps
after it call tools, run assistants, branch, loop, send HTTP requests, wait for a person's
approval and set the result. The workflow API lets your application list workflows, run
them with live progress, read past runs, decide approvals, edit and publish workflows, and
start published workflows from a public webhook URL.

Every call runs as the user who owns the API key, with that user's permissions,
organizations and credits. Runs started through the API execute the workflow **draft**
(the current definition); published versions are run by schedules, webhooks and booster
record triggers.

## Scopes and access

| Scope | Allows |
|---|---|
| `ayeto.workflow` | List and read workflows, run them (also streamed), cancel runs, list and read runs, the catalog, validation, schedule preview, revision history, export, approvals (list, count, read, decide). |
| `ayeto.workflow.write` | Create, update and delete workflows, delete runs, deployment info (contains webhook secrets), publish, activate, regenerate webhook secrets, restore a revision, import, copy, the builder assistant. |
| `*` | Everything. |

The scopes do not include each other: a key that both reads and edits workflows needs
both. A key without the required scope gets `401` with `API key is invalid`; see
[authentication](authentication.md#scopes).

On top of the scope, each workflow checks who may do what:

| Who | May |
|---|---|
| Owner (the creator, or a member of the owning group) | Everything. |
| User it is shared with for writing | Read, edit, run, publish, export, copy and delete it. |
| User it is shared with for reading | Read it, validate it, see its revisions. Run, export or copy it only when the share grants the `workflow.run`, `workflow.export` or `workflow.copy` permission. |

A workflow that belongs to an organization runs in that organization (its credits and
storage), so the user must be a member with write access there.

## Concepts

### Definition

The definition of a workflow is a directed acyclic graph: a list of `nodes` and a list of
`edges`.

```json
{
  "nodes": [
    {
      "id": "start",
      "type": "trigger.manual",
      "name": "Start",
      "params": {
        "input_schema": {
          "type": "object",
          "properties": {"customer": {"type": "string"}, "question": {"type": "string"}},
          "required": ["question"]
        }
      },
      "position": {"x": 0, "y": 0}
    },
    {
      "id": "answer",
      "type": "assistant.run",
      "name": "Draft an answer",
      "params": {
        "assistant_id": "3f6c1a2e-8b4d-4e7a-9c1f-2d5e6a7b8c90",
        "prompt": "Answer {{ input.customer }}: {{ input.question }}"
      },
      "position": {"x": 0, "y": 160}
    },
    {
      "id": "result",
      "type": "output",
      "name": "Result",
      "params": {"value": {"answer": "{{ steps.answer.output.text }}"}},
      "position": {"x": 0, "y": 320}
    }
  ],
  "edges": [
    {"source": "start", "target": "answer", "source_handle": null},
    {"source": "answer", "target": "result", "source_handle": null}
  ]
}
```

| Field | Type | Description |
|---|---|---|
| `nodes[].id` | string | Identifier of the node, unique in the workflow: letters, digits and `_`, at most 64 characters, not starting with a digit. Later steps refer to its result as `{{ steps.<id>.output }}`. |
| `nodes[].type` | string | Node type, see [node types](#node-types). |
| `nodes[].name` | string | Label shown in the editor. |
| `nodes[].params` | object | Parameters of the node type; their JSON schema is in the [catalog](#catalog). |
| `nodes[].position` | object | `{x, y}` on the editor canvas, or `null`. Has no effect on runs. |
| `edges[].source` | string | Node the edge leaves. |
| `edges[].target` | string | Node the edge enters. |
| `edges[].source_handle` | string | Output of the source node the edge leaves: `null` for the default output, a named output (`true` / `false` of a condition, a case of a switch, `item` / `done` of a loop, `approved` / `rejected` / `timeout` of an approval), or `error` to continue when the source step fails. |

A definition has at most 200 nodes, at least one trigger, no cycles and no edges into a
trigger. Use [validate](#validate-a-workflow) to check it.

String parameters are templates (Jinja syntax, evaluated in a sandbox). They can read:

| Variable | Content |
|---|---|
| `input` | The run input (the output of the trigger). |
| `steps.<id>.output` | Result of an earlier step; also `steps.<id>.success`, `.status`, `.text`, `.error`, `.file_ids`. |
| `workflow` | `{id, name}`. |
| `run` | `{id}`. |
| `now` | Start time of the run (ISO 8601, UTC). |
| `loop` | Inside a loop body: `item`, `index`, `count`, `parent`. |

A parameter that is a single `{{ expression }}` keeps the native type of the value (an
object stays an object); an undefined variable is an error of the step.

### Node types

The full list, with the JSON schema of every node's parameters and the tools a `tool.call`
step can use, comes from the [catalog](#catalog). An overview:

| Type | Category | What it does |
|---|---|---|
| `trigger.manual` | trigger | Starts a run on demand (app, API). Optional `input_schema` describes the run input. |
| `trigger.schedule` | trigger | Starts the published workflow on a cron schedule. |
| `trigger.webhook` | trigger | Starts the published workflow when its [webhook URL](#webhooks) is called. |
| `trigger.booster_record` | trigger | Starts the published workflow when a record of a booster panel database is created, updated or deleted. |
| `tool.call` | action | Calls an AI tool with given parameters. |
| `assistant.run` | action | Runs a task on an assistant in a new conversation. |
| `http.request` | action | Sends an HTTP request to a public URL. |
| `notify` | action | Sends an e-mail to the user the run goes as. |
| `file.to_text` | action | Reads files (documents, images, audio) as text. |
| `human.approval` | logic | Waits for a person to approve or reject, optionally with a form. |
| `condition` | logic | Continues on `true` or `false` by an expression. |
| `switch` | logic | Continues on one of several named outputs, or `otherwise`. |
| `ai.condition` | logic | Asks a classification model a yes/no question; continues on `true` or `false`. |
| `ai.choice` | logic | Asks a classification model which of several options fits. |
| `loop` | logic | Runs the steps behind its `item` output once per item of a list, then continues on `done`. |
| `transform` | logic | Builds a value from earlier results. |
| `output` | logic | Sets the result of the run. Has no outgoing edges. |

### Draft and published version

The `definition` stored on the workflow is the **draft**. Every change of it is kept as a
[revision](#revisions). Runs started through the API (and from the app) run the draft.

[Publishing](#publish-a-workflow) copies the draft into a new immutable version (`1`, `2`,
...) and activates the workflow. While the workflow is active, its automatic triggers
(schedule, webhook, booster record) start runs of the **published version**, as the user
who published or last activated it (`run_as`). Editing the draft afterwards does not change
what the triggers run until you publish again.

### Run statuses

A run records the input, the state of every step and the result. Its `status`:

| Status | Meaning |
|---|---|
| `queued` | Started by a trigger, or a paused run whose waiting step got its result; waits for a background worker. |
| `running` | Executing. |
| `waiting` | Paused: a step waits for something outside the run (a person's approval). Other branches have finished. The run continues in the background once the step gets its result. |
| `success` | Finished. |
| `failed` | A step failed without an `error` edge, a limit was exceeded, or the run could not start. |
| `cancelled` | Cancelled while `waiting` or `queued`. |

Steps run as soon as all steps before them are done, so independent branches run in
parallel. A step whose incoming edges are all inactive (an untaken branch) is `skipped`.
A failed step with an `error` edge continues there; without one, the run fails (steps
already running finish first).

Runs are deleted after a retention period (90 days by default) together with the
conversations and files their steps created. Paused runs are kept until they end.

### Approval flow

A `human.approval` step opens an approval and pauses the run. The people who may decide it
are the user the run goes as and members of the workflow's organization named in the step;
they are notified in the app and by e-mail. An approval shows a title, a message and
optionally a form (a flat JSON schema with prefilled values). The decision (approve with the
form data, or reject) becomes the output of the step and the run continues on `approved` or
`rejected`. An approval nobody decides in time expires: the run continues on `timeout` when
that output is connected, otherwise the step fails. See [approvals](#approvals).

### Files in run input

A field of the manual trigger's `input_schema` declared as
`{"type": "object", "format": "file"}` holds one file. Send it in the run input either
inline as base64:

```json
{
  "invoice": {
    "filename": "invoice-1042.pdf",
    "mime_type": "application/pdf",
    "data": "data:application/pdf;base64,JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGg..."
  }
}
```

or as the id of a file the user already has (`"invoice": "7b1e2c3d-..."` or
`{"file_id": "7b1e2c3d-..."}`). `data` is plain base64 or a data URL; `name` may be used
instead of `filename` and `content_type` instead of `mime_type`. The upload size limit and
the storage quota apply.

In the run, the value becomes the file's attachment metadata, which is also how steps
return files:

```json
{
  "file_id": "7b1e2c3d-4a5b-4c6d-8e7f-9a0b1c2d3e4f",
  "filename": "invoice-1042.pdf",
  "content_type": "application/pdf",
  "file_url": "https://ayeto.ai/api/v1/public/file/read?id=7b1e2c3d-4a5b-4c6d-8e7f-9a0b1c2d3e4f&secret=...",
  "size": 48213
}
```

`file_url` downloads the file. Files sent as base64 belong to the run and are deleted with
it; a file named by its id stays the user's and is not deleted with the run. The same base64 form is accepted in the file fields of an
[approval form](#decide-an-approval) and inside [webhook](#webhooks) bodies.

### Limits

The defaults below can differ per deployment.

| Limit | Default |
|---|---|
| Nodes per workflow | 200 |
| Run input | 200,000 characters of JSON |
| Steps executed per run (every loop item counts) | 500 |
| Run time (pauses for approvals do not count) | 1,800 seconds |
| Steps executing at the same time in one run | 4 |
| Items per loop | 100 |
| Stored output of one step | 200,000 characters of JSON (2,000 inside a loop body) |
| Queued runs per workflow | 50 |
| Shortest schedule interval | 5 minutes |
| Approval time to decide | 72 hours by default, at most 720 |
| Run retention | 90 days |
| Revisions kept per workflow | 100 |

## Workflow object

The workflow returned by the read, create and update endpoints. It also has the
[common fields](conventions.md#common-fields) (`id`, `created`, `updated`, `permissions`,
...).

| Field | Type | Description |
|---|---|---|
| `name` | string | Name. |
| `description` | string | Description. |
| `organization_id` | UUID | Organization the workflow belongs to, `null` for a personal workflow. Fixed at creation. |
| `definition` | object | The draft [definition](#definition). |
| `sharing` | array of object | Users it is shared with: `{user_email, write, scopes}`. `scopes` may grant read shares `{"workflow.run": true, "workflow.export": true, "workflow.copy": true}`. |
| `group_sharing` | array of object | Groups it is shared with: `{group_id, write, scopes}`. |
| `last_run` | object | Latest finished or paused run: `{run_id, status, finished_at, credits}`, or `null`. Read-only. |
| `published_version` | integer | Latest published version, `null` when never published. Read-only. |
| `published_at` | timestamp | When it was last published. Read-only. |
| `active` | boolean | The automatic triggers of the published version are on. Read-only; see [activate](#activate-or-deactivate). |
| `run_as` | UUID | User the automatic runs go as. Read-only. |

## List and read workflows

| | |
|---|---|
| Endpoints | `POST /api/v3/workflow/find`<br>`POST /api/v3/workflow/count`<br>`POST /api/v3/workflow/get?entity_id=<workflow id>` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

`find` returns the workflows the user owns or that are shared with them, as an array of
[workflows](#workflow-object), newest first unless sorted otherwise. `count` returns the
number of matching workflows as an integer. Both take the usual
[query body](conventions.md#querying-lists) (`filters`, `sort_key`, `sort_order`,
`limit_from`, `limit_to`); send `{}` for everything. Each workflow comes with its full
definition, so page through large lists.

`get` returns one [workflow](#workflow-object); the id is a query parameter and the body
is empty.

Useful filters: `["organization_id", "==", "<organization id>"]`,
`["organization_id", "==", null]` (personal), `["active", "==", true]`,
`["name", "regex", "invoice"]`.

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/find" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": [["active", "==", true]], "sort_key": "updated.timestamp", "sort_order": 1, "limit_from": 0, "limit_to": 20}'
```

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/get?entity_id=5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a" \
  -H "uni-api-key: $AYETO_API_KEY"
```

```json
{
  "id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "name": "Customer question",
  "description": "Drafts an answer to a customer question",
  "organization_id": null,
  "definition": {"nodes": ["..."], "edges": ["..."]},
  "sharing": [],
  "group_sharing": [],
  "last_run": {"run_id": "c2a1b0d9-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "status": "success", "finished_at": 1759401234567, "credits": 0.42},
  "published_version": 3,
  "published_at": 1759300000000,
  "active": true,
  "run_as": "0f1e2d3c-4b5a-4968-8776-655443322110",
  "created": {"timestamp": 1759000000000, "user_id": "0f1e2d3c-4b5a-4968-8776-655443322110"},
  "updated": {"timestamp": 1759400000000, "user_id": "0f1e2d3c-4b5a-4968-8776-655443322110"}
}
```

A workflow that does not exist or is not visible to the user returns `404`.

## Create a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/create` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `name` | string | Yes | Name. |
| `description` | string | No | Description. |
| `organization_id` | UUID | No | Create it in this organization; the user needs write access there. Omit for a personal workflow. |
| `definition` | object | No | The [definition](#definition). Default: a single manual trigger with id `start`. |
| `sharing` | array of object | No | User shares, see the [workflow object](#workflow-object). |
| `group_sharing` | array of object | No | Group shares. |

Returns the created [workflow](#workflow-object). The definition is stored as given, even
when it is not valid; [validate](#validate-a-workflow) it before running. Creating a workflow
also records its first revision and creates the user's [builder assistant](#builder-assistant).

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/create" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Customer question", "description": "Drafts an answer to a customer question"}'
```

## Update a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/update` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `id` | UUID | Yes | The workflow. |
| `name` | string | No | New name. |
| `description` | string | No | New description. |
| `definition` | object | No | New draft [definition](#definition); replaces the whole definition. |
| `sharing` | array of object | No | Replaces the user shares. |
| `group_sharing` | array of object | No | Replaces the group shares. |

Only the fields you send change. The organization, publishing state and last run cannot be
changed here. Every change of the definition is recorded as a [revision](#revisions); the
published version stays as it was until you [publish](#publish-a-workflow) again. Returns
the updated [workflow](#workflow-object).

To change one node, read the workflow, modify its `definition` and send it back. Two
clients updating the same workflow at once overwrite each other's definition.

## Delete a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/delete?entity_id=<workflow id>` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

Deletes the workflow with all its runs (and the conversations and files they created), its
revisions, published versions, triggers and builder assistants. Returns the deleted id as a
JSON string.

## Catalog

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/catalog` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

Returns what workflows can be built from: every node type with the JSON schema of its
parameters, and the tools a `tool.call` step can call. No request body.

| Field | Type | Description |
|---|---|---|
| `node_types[].type` | string | Node type, e.g. `http.request`. |
| `node_types[].title` | string | Display name. |
| `node_types[].description` | string | What the node does and what its output looks like. |
| `node_types[].category` | string | `trigger`, `action` or `logic`. |
| `node_types[].is_trigger` | boolean | The node starts runs. |
| `node_types[].terminal` | boolean | The node ends a branch (no outgoing edges). |
| `node_types[].handles` | array of string | Named outputs; empty means one default output. Every non-trigger node also has the `error` output. For a `switch`, the outputs are its case names plus `otherwise`. |
| `node_types[].params_schema` | object | JSON schema of `params`. |
| `tools[].name` | string | Tool name for the `tool` parameter of `tool.call`. |
| `tools[].display_name` | string | Display name. |
| `tools[].description` | string | Description. |
| `tools[].category` | string | Category. |
| `tools[].icon` | string | Icon name. |
| `tools[].input_schema` | object | JSON schema of the tool's `params`. |

## Validate a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/validate` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |

Validates the draft as the calling user (including whether the user can use the assistants
and booster panels it refers to).

```json
{
  "valid": false,
  "issues": [
    {"severity": "error", "message": "Node 'answer' has no output 'true' (outputs: default, error)", "node_id": "answer", "edge": {"source": "answer", "target": "result", "source_handle": "true"}},
    {"severity": "warning", "message": "Node 'draft' is not reachable from any trigger and never runs", "node_id": "draft", "edge": null}
  ]
}
```

`valid` is `false` when there is at least one issue with severity `error`; errors block runs
and publishing, warnings do not.

## Run a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/run` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

Runs the draft as the calling user and answers when the run has finished or paused.

### Request

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `input` | object | No | The run input. For a manual trigger it should match its `input_schema`; keys listed in `required` must be present. At most 200,000 characters as JSON. |
| `trigger_node_id` | string | No | Trigger to start from. Default: the first `trigger.manual`, otherwise the first trigger of any type. |

Starting from a schedule, webhook or booster record trigger runs the draft by hand, which is
useful for testing: pass the input the trigger would produce, e.g.
`{"body": {...}, "query": {}, "headers": {}}` for a webhook trigger.

The user needs run access to the workflow (see [scopes and access](#scopes-and-access)).
Before the run starts, the draft is [validated](#validate-a-workflow); an invalid draft is
rejected with `422`. A missing required input key does not reject the request: the run is
created and fails at its trigger step (`Missing input: <keys>`).

The request returns when the run ends, which may take minutes (up to the run time limit).
For long runs or live progress, use [streaming](#stream-a-run). When a step waits for an
approval, the response comes back with `status: "waiting"`; follow the run with
[get run](#runs).

### Response

`200 OK` with the [run](#run-object).

### Examples

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/run" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "workflow_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "input": {"customer": "Jana Novak", "question": "When will order 1042 be delivered?"}
  }'
```

```python
import json
import os
import requests

response = requests.post(
    "https://ayeto.ai/api/v3/workflow/run",
    headers={"uni-api-key": os.environ["AYETO_API_KEY"]},
    json={
        "workflow_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
        "input": {"customer": "Jana Novak", "question": "When will order 1042 be delivered?"},
    },
    timeout=1900,
)
response.raise_for_status()
run = response.json()
if run["status"] == "success":
    print(json.loads(run["output_json"]) if run["output_json"] else None)
else:
    print(run["status"], run["error"])
```

```javascript
const response = await fetch("https://ayeto.ai/api/v3/workflow/run", {
  method: "POST",
  headers: {
    "uni-api-key": process.env.AYETO_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    workflow_id: "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    input: { customer: "Jana Novak", question: "When will order 1042 be delivered?" },
  }),
});
if (!response.ok) throw new Error((await response.json()).detail);
const run = await response.json();
const output = run.output_json ? JSON.parse(run.output_json) : null;
console.log(run.status, output ?? run.error);
```

### Run object

A run has the [common fields](conventions.md#common-fields) plus:

| Field | Type | Description |
|---|---|---|
| `workflow_id` | UUID | The workflow. |
| `organization_id` | UUID | Organization the run went in, or `null`. |
| `version` | integer | Published version the run executed; `null` for a run of the draft. |
| `trigger_node_id` | string | Trigger the run started from. |
| `trigger_type` | string | Its node type, e.g. `trigger.manual`, `trigger.webhook`. |
| `trigger_depth` | integer | Number of booster record triggered runs in a chain before this one. |
| `automatic` | boolean | Started by a trigger (schedule, webhook, booster record), not by a person. |
| `input_json` | string | The run input as JSON text. |
| `status` | string | See [run statuses](#run-statuses). |
| `started_at` | timestamp | When the run started executing; `null` while queued. |
| `active_since` | timestamp | Start of the current execution (after a pause, the resume). |
| `finished_at` | timestamp | When it ended, or `null`. |
| `steps` | array of [step](#step-object) | State of every step that started or was skipped, in execution order. |
| `output_json` | string | The result (value of the last `output` step reached) as JSON text; empty when there is none. |
| `error` | string | Why the run failed. |
| `credits` | number | Credits the run spent so far. |

`created.user_id` is the user the run went as.

### Step object

| Field | Type | Description |
|---|---|---|
| `node_id` | string | The node. |
| `node_type` | string | Its type. |
| `status` | string | `running`, `waiting`, `success`, `failed` or `skipped`. |
| `started_at` | timestamp | Start, `null` for a skipped step. |
| `finished_at` | timestamp | End. |
| `handle` | string | Output the run continued on: `null` (default), a named output, or `error`. |
| `output_json` | string | Output of the step as JSON text, capped at 200,000 characters (2,000 inside a loop body). |
| `output_truncated` | boolean | The output was cut at the cap; `output_json` is then not valid JSON. |
| `text` | string | Human-readable result or status text. |
| `error` | string | Why the step failed. |
| `file_ids` | array of UUID | Files the step produced. |
| `conversation_id` | UUID | Conversation an `assistant.run` step created; readable through [conversations](conversations.md). |
| `iteration` | array of integer | For a step inside a loop body, the item indexes from the outermost loop in; empty otherwise. A step inside a loop appears once per item. |

Example:

```json
{
  "id": "c2a1b0d9-8e7f-4a6b-9c5d-4e3f2a1b0c9d",
  "workflow_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
  "organization_id": null,
  "version": null,
  "trigger_node_id": "start",
  "trigger_type": "trigger.manual",
  "trigger_depth": 0,
  "automatic": false,
  "input_json": "{\"customer\": \"Jana Novak\", \"question\": \"When will order 1042 be delivered?\"}",
  "status": "success",
  "started_at": 1759401220011,
  "active_since": 1759401220011,
  "finished_at": 1759401234567,
  "steps": [
    {
      "node_id": "start", "node_type": "trigger.manual", "status": "success",
      "started_at": 1759401220020, "finished_at": 1759401220031, "handle": null,
      "output_json": "{\"customer\": \"Jana Novak\", \"question\": \"When will order 1042 be delivered?\"}",
      "output_truncated": false, "text": "", "error": "", "file_ids": [], "conversation_id": null, "iteration": []
    },
    {
      "node_id": "answer", "node_type": "assistant.run", "status": "success",
      "started_at": 1759401220040, "finished_at": 1759401234400, "handle": null,
      "output_json": "{\"text\": \"Dear Jana, order 1042 ships on Friday ...\", \"data\": null, \"files\": []}",
      "output_truncated": false, "text": "Dear Jana, order 1042 ships on Friday ...", "error": "",
      "file_ids": [], "conversation_id": "8e2f4a6c-1b3d-4e5f-8a7b-9c0d1e2f3a4b", "iteration": []
    },
    {
      "node_id": "result", "node_type": "output", "status": "success",
      "started_at": 1759401234410, "finished_at": 1759401234420, "handle": null,
      "output_json": "{\"answer\": \"Dear Jana, order 1042 ships on Friday ...\"}",
      "output_truncated": false, "text": "", "error": "", "file_ids": [], "conversation_id": null, "iteration": []
    }
  ],
  "output_json": "{\"answer\": \"Dear Jana, order 1042 ships on Friday ...\"}",
  "error": "",
  "credits": 0.42,
  "created": {"timestamp": 1759401220000, "user_id": "0f1e2d3c-4b5a-4968-8776-655443322110"}
}
```

## Stream a run

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/run/stream` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

Same request as [run a workflow](#run-a-workflow), but the response streams the events of
the run as they happen. The run executes on the server to its end even when the client
disconnects; read the result afterwards with [get run](#runs).

Access and validation errors (no access, invalid draft, input too large) are returned as
a normal HTTP error before the stream starts.

### Wire format

The response (`Content-Type: text/event-stream`) is a sequence of JSON objects written
one after another without separators (not SSE `data:` lines); see
[streaming](streaming.md). Each object is a chunk:

```json
{"timestamp": 1759401220012, "data": {"type": "run_start", "run_id": "c2a1b0d9-8e7f-4a6b-9c5d-4e3f2a1b0c9d", "node_id": null, "node_type": null, "status": "running", "handle": null, "text": "", "error": "", "output_json": "", "iteration": []}, "error": null}
```

- `data` is a run event (below).
- `data: ""` is a keep-alive chunk, sent about every second while nothing happens. Ignore it.
- A chunk with `error` set (`{"status", "text", "detail"}`) reports an error that ended the
  stream.

### Events

Every event has the fields `type`, `run_id`, `node_id`, `node_type`, `status`, `handle`,
`text`, `error`, `output_json` and `iteration`; which of them carry a value depends on the
type.

| `type` | Meaning | Fields with values |
|---|---|---|
| `run_start` | The run started. | `status: "running"` |
| `step_start` | A step started. | `node_id`, `node_type`, `status: "running"`, `iteration` |
| `step_progress` | Progress text of a running step (a tool or an assistant working). | `node_id`, `node_type`, `text`, `iteration` |
| `step_waiting` | A step waits (an approval). Other branches go on. | `node_id`, `node_type`, `status: "waiting"`, `text`, `output_json` (for an approval `{approval_id, expires_at, assignees}`) |
| `step_end` | A step ended or was skipped. | `node_id`, `node_type`, `status` (`success`, `failed`, `skipped`), `handle`, `text`, `error`, `output_json`, `iteration` |
| `run_waiting` | The run paused; **the stream ends** without `run_end`. | `status: "waiting"`, `text` (ids of the waiting steps, comma-separated) |
| `run_end` | The run ended; the last event. | `status` (`success`, `failed`), `error`, `output_json` |

Events of parallel branches and loop items interleave; use `node_id` and `iteration` to
tell them apart.

### Examples

```bash
curl -N -X POST "https://ayeto.ai/api/v3/workflow/run/stream" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"workflow_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "input": {"question": "When will order 1042 be delivered?"}}'
```

Python, splitting the stream into JSON objects with `raw_decode`:

```python
import json
import os
import requests

decoder = json.JSONDecoder()

def events(response):
    """Yield the run events of a streamed response."""
    buffer = ""
    for piece in response.iter_content(chunk_size=None, decode_unicode=True):
        buffer += piece
        while True:
            buffer = buffer.lstrip()
            if not buffer:
                break
            try:
                chunk, end = decoder.raw_decode(buffer)
            except json.JSONDecodeError:
                break  # incomplete object, wait for more data
            buffer = buffer[end:]
            if chunk.get("error"):
                raise RuntimeError(chunk["error"]["detail"])
            if chunk.get("data"):
                yield chunk["data"]

with requests.post(
    "https://ayeto.ai/api/v3/workflow/run/stream",
    headers={"uni-api-key": os.environ["AYETO_API_KEY"]},
    json={"workflow_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a", "input": {"question": "When will order 1042 be delivered?"}},
    stream=True,
    timeout=(10, 120),
) as response:
    response.raise_for_status()
    response.encoding = "utf-8"
    for event in events(response):
        if event["type"] == "step_end":
            print(f"{event['node_id']}: {event['status']} {event['error']}")
        elif event["type"] == "run_waiting":
            print("paused, waiting for:", event["text"])
        elif event["type"] == "run_end":
            print("run", event["status"], event["output_json"] or event["error"])
```

JavaScript, splitting the stream by tracking braces outside strings:

```javascript
async function* runEvents(response) {
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffer = "";
  while (true) {
    const { value, done } = await reader.read();
    if (done) return;
    buffer += value;
    let depth = 0, inString = false, escaped = false, start = -1, consumed = 0;
    for (let i = 0; i < buffer.length; i++) {
      const c = buffer[i];
      if (inString) {
        if (escaped) escaped = false;
        else if (c === "\\") escaped = true;
        else if (c === '"') inString = false;
      } else if (c === '"') inString = true;
      else if (c === "{") { if (depth++ === 0) start = i; }
      else if (c === "}" && --depth === 0) {
        const chunk = JSON.parse(buffer.slice(start, i + 1));
        consumed = i + 1;
        if (chunk.error) throw new Error(chunk.error.detail);
        if (chunk.data) yield chunk.data;
      }
    }
    buffer = buffer.slice(consumed);
  }
}

const response = await fetch("https://ayeto.ai/api/v3/workflow/run/stream", {
  method: "POST",
  headers: { "uni-api-key": process.env.AYETO_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    workflow_id: "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    input: { question: "When will order 1042 be delivered?" },
  }),
});
if (!response.ok) throw new Error((await response.json()).detail);
for await (const event of runEvents(response)) {
  if (event.type === "step_start") console.log("running", event.node_id);
  if (event.type === "step_end") console.log(event.node_id, event.status, event.error);
  if (event.type === "run_waiting") console.log("paused, waiting for", event.text);
  if (event.type === "run_end") console.log("done", event.status, event.output_json || event.error);
}
```

## Cancel a run

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/run/cancel` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `run_id` | UUID | Yes | The run. |

Cancels a run that is `waiting` or `queued`. Its waiting steps fail and their pending
approvals are cancelled. The user the run went as and users with write access to the
workflow may cancel it. A run that is `running` cannot be cancelled (`422`); it ends on
its own or at the run time limit. Returns the cancelled [run](#run-object).

## Runs

| | |
|---|---|
| Endpoints | `POST /api/v3/workflow/runs/find`<br>`POST /api/v3/workflow/runs/count`<br>`POST /api/v3/workflow/runs/get?entity_id=<run id>` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/runs/delete?entity_id=<run id>` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

These endpoints see the runs **that went as the API key's user**: runs the user started,
and automatic runs of workflows the user published or activated. Runs of other users are
reported as not found.

`find` returns an array of [runs](#run-object) and `count` an integer; both take the
[query body](conventions.md#querying-lists). Runs include all their steps and outputs, so
always page. `get` returns one run (empty body). `delete` deletes a run with the
conversations and files its steps created and its approvals, and returns its id; deleting a
paused run ends it.

Useful filters:

| Goal | Filter |
|---|---|
| Runs of one workflow | `["workflow_id", "==", "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a"]` |
| Paused runs | `["status", "==", "waiting"]` |
| Failed runs | `["status", "==", "failed"]` |
| Runs from the webhook | `["trigger_type", "==", "trigger.webhook"]` |
| Runs of the published version only | `["version", "!=", null]` |
| Started since a moment | `["created.timestamp", ">=", 1759363200000]` |

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/runs/find" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": [["workflow_id", "==", "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a"]], "sort_key": "created.timestamp", "sort_order": 1, "limit_from": 0, "limit_to": 10}'
```

To follow a paused or queued run, poll `get` until its `status` is `success`, `failed` or
`cancelled`.

## Approvals

The approval endpoints serve the people who decide: any user named by an approval step can
use them, without other permissions on the workflow. The API key still needs the
`ayeto.workflow` scope.

| | |
|---|---|
| Endpoints | `POST /api/v3/workflow/approval/find`<br>`POST /api/v3/workflow/approval/get`<br>`POST /api/v3/workflow/approval/decide` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/approval/count` |
| Scope | `ayeto.workflow` |
| Rate limit | [high](conventions.md#rate-limits) |

### Approval object

Common fields plus:

| Field | Type | Description |
|---|---|---|
| `workflow_id` | UUID | The workflow. |
| `workflow_name` | string | Its name when the approval was opened. |
| `run_id` | UUID | The paused run. |
| `node_id` | string | The approval step. |
| `organization_id` | UUID | Organization of the workflow, or `null`. |
| `status` | string | `pending`, `approved`, `rejected`, `expired` (nobody decided in time) or `cancelled` (the run ended first). |
| `title` | string | What to decide. |
| `message` | string | Details (Markdown). |
| `form_schema` | object | JSON schema of the form (`{"type": "object", "properties": {...}, "required": [...]}`) with flat fields of type `string`, `number`, `integer`, `boolean`, or a file (`{"type": "object", "format": "file"}`); `null` when there is no form. |
| `form_values` | object | Prefilled values by field name. |
| `allow_reject` | boolean | The approval can be rejected. When `false`, the step only collects the form. |
| `assignee_user_ids` | array of UUID | Users who may decide. |
| `expires_at` | timestamp | Deadline. |
| `timeout_continues` | boolean | On expiry the run continues on `timeout`; otherwise the step fails. |
| `decision_data` | object | The form data of the decision. |
| `comment` | string | Comment of the decision. |
| `decided_by` | object | `{id, email, name}` of the person who decided, or `null`. |
| `decided_at` | timestamp | When it was decided or expired. |

### List approvals

Request:

| Field | Type | Required | Description |
|---|---|---|---|
| `status` | string | No | Only approvals with this status, e.g. `pending`. |
| `limit_from` | integer | No | Index of the first item. Default `0`. |
| `limit_to` | integer | No | Index after the last item, 1 to 200. Default `50`. |

Returns `{"items": [<approval>, ...], "total": <number>}` with the approvals the user may
decide (or decided), newest first. `total` is the number of all matching approvals.

### Count pending approvals

No request body. Returns `{"pending": 3}`, the number of approvals waiting for the user's
decision. Suited for polling a badge.

### Get an approval

| Field | Type | Required | Description |
|---|---|---|---|
| `approval_id` | UUID | Yes | The approval. |

Returns the [approval](#approval-object). Users who may decide it and users who can read
its workflow may get it.

### Decide an approval

| Field | Type | Required | Description |
|---|---|---|---|
| `approval_id` | UUID | Yes | The approval. |
| `decision` | string | Yes | `approve` or `reject`. |
| `data` | object | No | Form data (approve only). Fields you do not send keep their prefilled values; fields not in the form are dropped. At most 20,000 characters as JSON. File fields take the base64 form described in [files in run input](#files-in-run-input). |
| `comment` | string | No | Comment, at most 2,000 characters. |

Only users in `assignee_user_ids` may decide, and only while the approval is `pending` and
before `expires_at`. The form data is validated against `form_schema`. The decision becomes
the output of the approval step:

```json
{
  "decision": "approved",
  "data": {"amount": 1200, "note": "OK for this quarter"},
  "comment": "Approved",
  "decided_by": {"id": "0f1e2d3c-4b5a-4968-8776-655443322110", "email": "jana@example.com", "name": "Jana Novak"},
  "decided_at": 1759405000000,
  "approval_id": "e4d3c2b1-a0f9-4e8d-9c7b-6a5f4e3d2c1b"
}
```

The run continues in the background as the user it went as (not as the decider); its
status changes from `waiting` to `queued` and then `running`. Returns the decided
[approval](#approval-object).

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/approval/decide" \
  -H "uni-api-key: $AYETO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "approval_id": "e4d3c2b1-a0f9-4e8d-9c7b-6a5f4e3d2c1b",
    "decision": "approve",
    "data": {"amount": 1200, "note": "OK for this quarter"},
    "comment": "Approved"
  }'
```

## Publishing and triggers

These endpoints need write access to the workflow.

### Deployment info

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/deployment` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |

Returns the publishing state. It contains the webhook URLs with their secrets, which is why
it needs the write scope.

| Field | Type | Description |
|---|---|---|
| `published_version` | integer | Latest published version, or `null`. |
| `published_at` | timestamp | When it was published. |
| `active` | boolean | Automatic triggers are on. |
| `run_as` | UUID | User the automatic runs go as. |
| `draft_changed` | boolean | The draft differs from the published version (node positions aside); always `true` when nothing is published. |
| `triggers` | array of object | Automatic triggers of the published version, see below. |
| `signing_secret` | string | Key that signs requests of `http.request` steps with `sign: true`; empty when no step signs. |

Trigger fields:

| Field | Type | Description |
|---|---|---|
| `node_id` | string | The trigger node. |
| `type` | string | `trigger.schedule`, `trigger.webhook` or `trigger.booster_record`. |
| `active` | boolean | The trigger fires. |
| `next_run_at` | timestamp | Next scheduled time (schedule triggers). |
| `last_fired_at` | timestamp | When it last started a run. |
| `url` | string | Webhook URL including its secret (webhook triggers). |
| `panel_id` | UUID | Booster panel (booster record triggers). |
| `collection` | string | Watched collection, `*` for all (booster record triggers). |

A signed `http.request` sends the header `X-Ayeto-Signature: sha256=<hex HMAC-SHA256 of the
request body>` computed with `signing_secret`, so the receiving service can verify it.

### Publish a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/publish` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `note` | string | No | Note of the version, at most 500 characters. |

Validates the draft as the caller, stores it as the next version, activates the workflow
and makes the caller the `run_as` user of automatic runs. Webhook URLs of trigger nodes
that existed before stay the same. Returns the [deployment info](#deployment-info). An
invalid draft is rejected with `422`.

### Activate or deactivate

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/activate` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `active` | boolean | Yes | `true` turns the automatic triggers on, `false` off. |

Activating validates the published version for the caller and makes the caller the
`run_as` user. While inactive, webhook calls return `404` and schedules do not fire.
Returns the [deployment info](#deployment-info). Fails with `422` when nothing is published.

### Regenerate a webhook secret

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/trigger/regenerate` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [low](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `node_id` | string | Yes | The webhook trigger node of the published version. |

Gives the webhook a new secret; the old URL stops working at once. Returns the
[deployment info](#deployment-info) with the new URL.

### Preview a schedule

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/schedule/preview` |
| Scope | `ayeto.workflow` |
| Rate limit | [high](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `cron` | string | Yes | Cron expression with five fields: minute, hour, day of month, month, day of week (e.g. `0 8 * * 1-5`). At most 200 characters. |
| `timezone` | string | No | IANA time zone, e.g. `Europe/Prague`. Default `UTC`. |

Checks a schedule for a `trigger.schedule` node and lists its next times:

```json
{"valid": true, "error": "", "next_runs": [1759471200000, 1759730400000, 1759816800000, 1759903200000, 1759989600000]}
```

An invalid schedule returns `200` with `valid: false` and the reason in `error` (wrong
number of fields, unknown time zone, runs more often than the shortest allowed interval).

## Builder assistant

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/assistant` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |

Every user with write access has their own builder assistant for a workflow: an
[assistant](assistants.md) that reads, edits, test-runs and explains the workflow in
conversation. This endpoint returns the caller's builder (creating it when missing) as an
[assistant](assistants.md). Talk to it through [chat](chat.md) with its `id` as the
assistant; that needs a key with the chat scope. Its edits are recorded as revisions with
origin `builder`.

## Revisions

Every change of the definition is a revision. Changes that only move nodes, made by the same
user within three minutes, are merged into the latest revision. The newest revisions are kept
(100 by default).

### List revisions

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/revisions` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |

Returns up to 100 revisions, newest first, without their definitions:

| Field | Type | Description |
|---|---|---|
| `id` | UUID | The revision. |
| `created_at` | timestamp | When it was made. |
| `user_id` | UUID | Who made it. |
| `origin` | string | `created`, `editor` (the app editor or this API), `builder` (the builder assistant) or `restore`. |
| `changes` | object | `{added_nodes, removed_nodes, changed_nodes, added_edges, removed_edges}`: node id lists and edge counts against the previous revision. |
| `restored_from` | UUID | For a restore, the revision that was put back. |
| `nodes` | integer | Number of nodes. |
| `edges` | integer | Number of edges. |

### Get a revision

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/revision/get` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `revision_id` | UUID | Yes | The revision. |

Returns the revision with its full definition: the common fields plus `workflow_id`,
`definition`, `origin`, `changes` and `restored_from`.

### Restore a revision

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/revision/restore` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

Same request as [get a revision](#get-a-revision). Puts the revision's definition back as
the draft; the restore is recorded as a new revision (origin `restore`), so it can be undone.
The published version does not change. Returns the updated [workflow](#workflow-object).

## Export and import

An export is a JSON document with the draft definition, name and description. Runs,
revisions, published versions, trigger state (webhook secrets), the signing secret, the
organization and sharing are not exported. Assistants that steps refer to are exported as
ids only.

### Export a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/export` |
| Scope | `ayeto.workflow` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow. |
| `strip_secrets` | boolean | No | Ignored on this API: credentials are always removed. |

Through an API key, the values of `http.request` step headers whose name looks like a
credential (contains `auth`, `token`, `secret`, `key`, `password`, `cookie`, `signature` or
`session`) are always emptied; the header names stay. The emptied paths are listed in
`secret_keys`. The user needs write access, or a read share with the `workflow.export`
permission.

```json
{
  "version": "1.0",
  "exported_at": 1759405000000,
  "workflow": {
    "name": "Customer question",
    "description": "Drafts an answer to a customer question",
    "definition": {"nodes": ["..."], "edges": ["..."]},
    "from_id": "5d4c3b2a-1f0e-4d9c-8b7a-6f5e4d3c2b1a",
    "secret_keys": ["notify_crm.headers.Authorization"]
  }
}
```

### Import a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/import` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `data` | object | Yes | An export document, as returned by [export](#export-a-workflow). |
| `new_name` | string | No | Name of the new workflow, at most 200 characters. Default: the exported name. |
| `organization_id` | UUID | No | Create it in this organization (write access needed). Default: personal. |

Creates a new, unpublished workflow of the caller and validates it. Steps that refer to
things the caller cannot use (an assistant from another account, emptied credentials) do
not block the import; they are reported so they can be fixed.

| Field | Type | Description |
|---|---|---|
| `workflow` | object | The new [workflow](#workflow-object). |
| `issues` | array of object | Validation issues for the caller, as in [validate](#validate-a-workflow). |
| `secret_keys` | array of string | Header values to fill in (`<node id>.headers.<name>`). |

The export format must have the same major version (`1`), and the definition at most 200
nodes.

### Copy a workflow

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/copy` |
| Scope | `ayeto.workflow.write` |
| Rate limit | [default](conventions.md#rate-limits) |

| Field | Type | Required | Description |
|---|---|---|---|
| `workflow_id` | UUID | Yes | The workflow to copy. |
| `new_name` | string | No | Name of the copy. Default: the original name. |
| `organization_id` | UUID | No | Organization of the copy. Default: personal. |

Exports the draft and imports it as a new workflow of the caller in one call; the response
is the same as for [import](#import-a-workflow). The user needs write access or a read share
with the `workflow.copy` permission. A user with write access to the original keeps the
credentials of HTTP steps; a copy made through the `workflow.copy` permission leaves them
out.

## Webhooks

| | |
|---|---|
| Endpoint | `POST /api/v3/workflow/hook/{trigger_id}/{secret}` |
| Scope | none: the secret in the URL authorizes the call |
| Rate limit | [webhook](conventions.md#rate-limits) per IP, plus a limit per trigger (below) |

A `trigger.webhook` node of a published, active workflow has a public URL. Get it from
`triggers[].url` of the [deployment info](#deployment-info):

```
https://ayeto.ai/api/v3/workflow/hook/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/Xq3v...secret...
```

Treat the URL as a secret; anyone who has it can start runs that spend the credits of the
`run_as` user. If it leaks, [regenerate](#regenerate-a-webhook-secret) it.

### Request

Send a `POST` with a JSON body and `Content-Type: application/json`. A body of another
content type is passed on as text (or parsed, when it is JSON). No API key is needed.

The run input (the output of the trigger, available to steps as `{{ input }}`) is:

| Field | Type | Description |
|---|---|---|
| `body` | any | The request body; `{}` when empty. |
| `query` | object | Query string parameters of the URL. |
| `headers` | object | Only these request headers, when present: `content-type`, `user-agent`, `x-github-event`, `x-request-id`. |

Steps read it as `{{ input.body.order_id }}`, `{{ input.query.source }}` and so on. The input
is limited to 200,000 characters of JSON.

### Files in the body

Files can be sent inside the body, at any depth (up to 32 levels):

- an object with base64 `data` and a `name` or `filename`, and no keys other than `name`,
  `filename`, `content_type`, `mime_type`, `data`, `size`;
- a data URL string `data:<type>;base64,...`.

Each file is stored as a file of the run (owned by the `run_as` user, subject to the upload
size limit and storage quota) and replaced in the body by its attachment metadata
(`{file_id, filename, content_type, file_url, size}`). At most 10 files per call.

### Response

`200 OK` as soon as the run is queued; the run executes in the background:

```json
{"run_id": "b7c6d5e4-f3a2-4b1c-9d8e-7f6a5b4c3d2e", "status": "queued"}
```

The run executes the published version as the `run_as` user, who sees it through
[runs](#runs) (`trigger_type` is `trigger.webhook`, `automatic` is `true`). Its input is not
trusted: a step reads the user's files only when the step names them itself, never because
the webhook body names them.

### Rate limiting

Each trigger accepts at most 60 calls per minute, 2,000 per hour and 20,000 per day, from
any number of senders. A per-IP flood limit applies on top. A workflow also refuses new
runs while it has 50 runs queued (by default). Both answer `429`; a rate limit response
carries `Retry-After` (seconds).

### Example

```bash
curl -X POST "https://ayeto.ai/api/v3/workflow/hook/9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d/$WEBHOOK_SECRET?source=shop" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": 1042,
    "customer": {"name": "Jana Novak", "email": "jana@example.com"},
    "invoice": {"filename": "invoice-1042.pdf", "content_type": "application/pdf", "data": "JVBERi0xLjcKJcfsj6IK..."}
  }'
```

## Errors

Errors have the JSON body `{"detail": "..."}`. Authentication, rate limit and server errors
are described in [conventions](conventions.md#errors).

| Status | Meaning |
|---|---|
| `401` | The API key is missing, invalid or lacks the required scope (`API key is invalid`). |
| `403` | The user has no access to the workflow, or lacks the permission or organization membership the operation needs. |
| `404` | The workflow, run, revision, approval or webhook does not exist or is not visible to the user. |
| `422` | The request is invalid, or the operation is not possible in the current state (`detail` says why). |
| `423` | The workflow or run is being changed by another request; retry shortly. |
| `429` | Too many requests (with `Retry-After`), or too many queued runs. |
| `500` | Server error. |

Workflow-specific errors:

| Status | `detail` | Cause |
|---|---|---|
| `403` | `permission denied` | The user's account is not allowed to read or write workflows or runs. |
| `403` | `You do not have access to this workflow` | Not the owner and not shared with the user (or shared for reading where writing is needed). |
| `403` | `This workflow is shared with you without the 'workflow.run' permission` | Read share without the permission to run (`workflow.export`, `workflow.copy` likewise). |
| `403` | `User is not a member of the organization` | The workflow belongs to an organization the user is not in. |
| `403` | `User does not have write permissions in the organization` | The user cannot work in the workflow's organization. |
| `404` | `Workflow not found` | The workflow does not exist (or was removed). |
| `404` | `entity not found` | CRUD `get`, `update`, `delete` of a workflow or run that does not exist or is not the user's. |
| `404` | `Workflow run not found` | Unknown run, or a run the user may not cancel. |
| `404` | `Revision not found` | The revision does not exist or belongs to another workflow. |
| `404` | `Approval not found` | Unknown approval, or the user may not see or decide it. |
| `404` | `The file of the input '<key>' was not found or is not accessible` | A file field names a file the user does not own. |
| `404` | `The published workflow has no such webhook trigger` | Regenerate with a node that is not a webhook trigger of the published version. |
| `404` | `Webhook not found` | Unknown trigger id or wrong secret. |
| `404` | `The workflow of this webhook is not active` | The workflow is deactivated or not published, or it was removed. |
| `422` | `The workflow is not valid: <errors>` | Run, publish or activate of a definition with validation errors. |
| `422` | `'<id>' is not a trigger of the workflow` | `trigger_node_id` is not a trigger. |
| `422` | `The workflow has no trigger` | No trigger to start from. |
| `422` | `The run input is too large (at most 200000 characters of JSON)` | Run input or webhook input over the limit. |
| `422` | `The input '<key>' must be one file` | A file field holds several files or something else. |
| `422` | `The file is not valid base64 data` | Inline file with invalid base64. |
| `422` | `Too many files (at most 10)` | Too many files in a webhook body. |
| `422` | `A <status> run can not be cancelled` | Cancel of a run that is not `waiting` or `queued`. |
| `422` | `Publish the workflow first` | Activate without a published version. |
| `422` | `Unsupported workflow export format <version>` | Import of an export with another major version. |
| `422` | `The workflow has too many steps (<n>, at most 200)` | Import of a definition over the node limit. |
| `422` | `The approval is <status> already` | Decide on an approval that is no longer pending. |
| `422` | `The time to decide the approval is over` | Decide after `expires_at`. |
| `422` | `The approval can not be rejected` | `reject` when `allow_reject` is `false`. |
| `422` | `Step '<node id>' of the run does not wait` | The run stopped waiting (cancelled, failed) before the decision. |
| `422` | `The form data is too large (at most 20000 characters of JSON)` | Decision data over the limit. |
| `429` | `Too many requests` | Rate limit, including the per-trigger webhook limit; see `Retry-After`. |
| `429` | `The workflow already has <n> runs waiting` | Webhook call while the queue of the workflow is full. |

A step failure is not an HTTP error: the run comes back with `status: "failed"` and the
reason in `error` and in the failing step's `error`.
