---
title: "HTTP API reference"
description: "Every invocation endpoint — flow invoke, SSE streaming, single-node calls, pre-warm — plus the Client Builder management API, Instance creation, adapter-mapping preview, with authentication, request and response fields, rate limits and daily quotas, and the structured error envelope."
category: reference
surfaces: [http-api]
related: [getting-started/invoke-via-api, guides/api-keys, guides/use-interactive-api-docs, guides/flow-configs, guides/debug-a-flow, guides/build-a-client-sdk, guides/configure-an-instance, reference/error-catalog, concepts/execution-model]
last_reviewed: 2026-07-11
---

# HTTP API reference

This page is the reference for Axiom's invocation HTTP surface: invoking a
flow (`POST /v1/flows/invoke`), streaming a pipeline-mode flow
(`POST /v1/flows/invoke/stream`), invoking a single node, pre-warming a
flow (`POST /v1/flows/warm`), the error envelope, and rate limits — plus
the [Client Builder management API](#client-builder-management-api) that
`axiom client` drives. For a guided walkthrough, start with
[Invoke a flow via API](../getting-started/invoke-via-api.md). For the exact
input and output schema of *your* flow or package, use the
[live OpenAPI specs](#live-openapi-specs) — they are generated from the
compiled artifact and are always current.

## Base URL and authentication

All invocation endpoints are served by the Axiom gateway at
`https://api.axiomide.com` under the `/invocations` path prefix. For example:

```text
POST https://api.axiomide.com/invocations/v1/flows/invoke
```

Every request must carry an API key as a bearer token:

```text
Authorization: Bearer <your 64-character API key>
```

API keys are 64-character hex strings created under **Console → API Keys**
in the app, or minted automatically by `axiom login` (named `cli`). The raw
key is shown exactly once at creation; the platform stores only its SHA-256
hash. See [Create and manage API keys](../guides/api-keys.md).

The key resolves to your tenant. Every execution it starts is scoped to that
tenant — the platform enforces isolation, so a key can never invoke another
tenant's flows or read another tenant's data
([Sandboxing and tenancy](../concepts/sandboxing-and-tenancy.md)).

A missing, invalid, or revoked key gets HTTP 401 with the body:

```json
{"error": "unauthorized"}
```

Revocation takes effect on the key's very next use — there is no key cache.

## Rate limits

Two per-tenant token buckets apply to authenticated requests:

- **All routes:** 100 requests/second, burst 200.
- **Invocation routes** (everything under `/invocations/`): an additional
  5 requests/second, burst 10 — a beta guardrail.

The invocation burst limit is enforced at ingress admission as well as at
the gateway, so it applies even on the direct-to-ingress routing the `axiom`
CLI defaults to in development (which bypasses the gateway entirely).

Exceeding either returns HTTP 429 with a `Retry-After: 1` header and the
body `{"error":"rate limit exceeded"}`.

## Daily quotas

On top of the per-second rate limits, each tenant has two daily budgets.
Both reset at midnight UTC. The beta defaults are:

- **2,000 invocations per day.** An invocation is one authenticated request
  that triggers compute: a flow invoke (plain or streaming), a single-node
  call, a paused-flow resume (including resume webhooks), or a debug-session
  fork.
- **500 executions per day.** An execution is one flow run. Most invocations
  start exactly one, but executions a flow spawns internally — child
  executions from parallel branches, runtime graph mutations, debug forks —
  each draw from the same budget. A flow that spawns 10 parallel branches
  costs 10 executions.

A request that would exceed either budget is rejected before any compute
runs, with HTTP 429, a `Retry-After` header, and a structured body whose
`retry_after_seconds` counts down to the next UTC midnight:

```json
{
  "error": "quota_exceeded",
  "message": "daily invocations quota exceeded (cap 2000)",
  "retry_after_seconds": 74380
}
```

If a running flow exhausts the execution budget mid-run (for example when
spawning a large number of parallel branches), the flow fails with the same
`quota exceeded` message as its error.

The defaults are per-tenant and the operator can raise or lower them for
your tenant — if you hit them with a legitimate workload, get in touch.
Two related responses you may also see on invocation routes:

| Status | Body `error` | Meaning |
|---|---|---|
| 403 | `tenant_suspended` | Your tenant has been disabled by the operator. No invocations or resumes are accepted. Contact the operator. |
| 503 | `quota_unavailable` | The quota service could not be reached, so the request was rejected as a safety measure. Transient — retry after `retry_after_seconds` (5 s). |

## Invoke a flow

`POST /v1/flows/invoke` executes a compiled artifact once and returns the
result inline (with `wait`) or an execution ID immediately (without).

```bash
curl -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD",
  "input": { "text": "hello" },
  "wait": true
}'
```

### Request fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `graph_id` | string | yes | The compiled artifact to execute. Get it from the **Use via API** dialog or the flow's [live OpenAPI spec](#live-openapi-specs); editing a flow produces a new artifact with a new ID. |
| `input` | object | one of `input`/`payload` | The entry node's input message as a JSON object. The platform converts it to the typed message, and decodes the result back to JSON. |
| `payload` | string (base64) | one of `input`/`payload` | The entry node's input message as protobuf-encoded bytes, base64-encoded. For callers that already speak protobuf. `input` takes precedence if both are set. |
| `wait` | boolean | no | `true` blocks until the execution completes and populates `result`. Default `false`: the response returns immediately with just `accepted` and `execution_id`. |
| `timeout_seconds` | integer | no | How long a `wait`ing call blocks before giving up (default 30). |
| `config_id` | string | no | Named flow config profile to apply; when omitted the default hierarchy applies (tenant default → flow default). See [Flow configs](../guides/flow-configs.md). |
| `debug_session_id` | string | no | Streams per-node debug events for this execution to the named debug session. Can also be sent as the `X-Debug-Session-Id` header, which takes precedence over the body field. See [Debug a flow](../guides/debug-a-flow.md). |
| `idempotency_key` | string | no | Dedups retries: a call with the same key from the same tenant within 5 minutes of the first returns that first call's `execution_id` instead of executing the flow again — at most one execution per key, even if the retry lands while the first call is still in flight. Can also be sent as the `Idempotency-Key` header, which takes precedence over the body field. When omitted, the platform falls back to a fingerprint of `graph_id`, `config_id`, and the payload/`input`, so byte-identical retries still dedup even if you never set this field. |

### Response

An accepted invocation returns HTTP 202:

```json
{
  "accepted": true,
  "execution_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "result": {
    "success": true,
    "output": { "text": "hello" },
    "completed_at": 1765432100000
  }
}
```

- `execution_id` — a 32-character hex ID assigned when the request is
  accepted. It is also the execution's trace ID, so one ID references the
  execution everywhere: results, debug events, and traces.
- `result` — present only with `wait: true`. `result.output` is the
  terminal node's output message decoded to JSON (when you invoked with
  `input`); `result.payload` carries base64-encoded protobuf bytes instead
  when you invoked with `payload` or when JSON decoding was not possible.
- `result.error_message` — present with `wait: true` when the execution was
  accepted and delivered but ran to a failure (`result.success` is `false`),
  for example because the input did not match the flow's start-node schema.
  The same diagnostic is also copied to the top-level `error_message` so a
  caller can read it without descending into `result`. The response is still
  HTTP 202 (the request was accepted); the failure is informational, not a
  transport error.
- Without `wait`, the body is just
  `{"accepted": true, "execution_id": "..."}` and the flow runs
  asynchronously.

Failures return a non-202 status with an error body — see
[Error envelope](#error-envelope).

## Stream a flow over SSE

`POST /v1/flows/invoke/stream` invokes a flow in pipeline mode and streams
result frames back as Server-Sent Events. Use it for flows that emit a
sequence of output frames rather than a single result
([Execution model](../concepts/execution-model.md)).

```bash
curl -N -X POST 'https://api.axiomide.com/invocations/v1/flows/invoke/stream' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
  "graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD",
  "input": { "text": "hello" }
}'
```

The request body accepts `graph_id`, `input` (or `payload`),
`timeout_seconds`, `config_id`, and `debug_session_id` exactly as in
[Invoke a flow](#invoke-a-flow). `wait` does not apply — frames are always
delivered as they are produced.

The response is `Content-Type: text/event-stream`. Each event is one
`data:` line of JSON:

```text
data: {"execution_id":"4bf92f3577b34da6a3ce929d0e0e4736","frame_index":0,"payload":{"text":"chunk 1"},"is_final":false}

data: {"execution_id":"4bf92f3577b34da6a3ce929d0e0e4736","frame_index":1,"payload":{"text":"chunk 2"},"is_final":true,"success":true}
```

Frame fields:

| Field | Meaning |
|---|---|
| `execution_id` | Same ID as the unary endpoint — also the trace ID. |
| `frame_index` | Position of this frame in the stream, starting at 0. |
| `payload` | The terminal node's output message for this frame, decoded to JSON. Omitted when the frame carries no decodable payload. |
| `is_final` | `true` on the last frame; the stream ends after it. |
| `success` | `true` on the final frame of a successful execution. On failure the field is omitted entirely (it is never serialized as `false`) — treat a final frame without `success: true` as failed and read `error`. |
| `error` | Error message when a frame reports failure. |

The stream times out after 30 seconds by default; set `timeout_seconds` to
extend it. A timeout ends the stream with a final frame whose `error` is
`"timeout waiting for pipeline result"`.

## Invoke a single node

A published node can be called directly, without composing a flow. This is
the endpoint that Client Builder SDKs use —
one method per node.

```bash
curl -X POST 'https://api.axiomide.com/invocations/v1/nodes/axiom-official/pdf-chunker/1.0.0/Chunk' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "url": "https://example.com/doc.pdf" }'
```

- **Path forms:** `POST /v1/nodes/{owner}/{package}/{version}/{node}`
  (name-based, as in the example) or `POST /v1/nodes/{node_ulid}` (the
  node's 26-character registry ID).
- **Request body:** the node's input message as a plain JSON object — no
  envelope.
- **Response:** HTTP 200 with the node's output message as JSON. A node
  that returns an error gets HTTP 422 with
  `{"error_message": "..."}`; an unknown or undeployed node gets HTTP 404.
  A payload that doesn't match the node's current input schema is rejected
  with HTTP 400: an unknown or renamed field name (rejected by JSON
  decoding), or an enum field set to an ordinal that names no declared
  value, yields
  `{"execution_id": "...", "error_message": "invalid value for <Message>: field \"<name>\" carries enum ordinal <n>, which names no declared value ..."}`.
  Re-creating a payload recorded against an older schema version is the
  usual cause — re-record it against the current schema.
- **Streaming nodes:** a pipeline-mode node (or any request with
  `Accept: text/event-stream`) streams its output frames as Server-Sent
  Events instead of a single JSON response.

A binary-protobuf variant exists at `POST /v1/nodes/invoke` with the JSON
body `{"node_id": "...", "payload": "<base64 protobuf bytes>"}`, returning
`{"success": ..., "payload": ..., "error_message": ...}` — for callers that
serialize the messages themselves. The payload is validated against the
node's current input schema before it is forwarded: bytes whose wire shape
doesn't match (an undeclared field number, or a wire type that conflicts
with the declared field kind) are rejected with HTTP 400 and
`{"success": false, "error_message": "payload does not match the node's current input schema: ..."}`,
as is any payload whose node schema can't be resolved.

## Pre-warm a flow

`POST /v1/flows/warm` primes a flow's pods ahead of time — a scale-from-zero
nudge with no business logic run — so a subsequent real
[invoke](#invoke-a-flow) doesn't pay a cold start. Call it, for example,
right before you expect a burst of traffic, or after deploying a flow that
has been idle.

```bash
curl -X POST 'https://api.axiomide.com/invocations/v1/flows/warm' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD"}'
```

A successful warm returns HTTP 202:

```json
{"warmed": true, "graph_id": "01JX3F8Q4ZJ4M9W4Y0B8T2K7RD"}
```

`warmed: true` means the warm was dispatched, not that pods are already
serving — it's fire-and-forget, the same way a cold start on invoke is not
something you wait on. A flow with nothing deployed (for example, one built
entirely from built-in nodes) is a legitimate no-op: you still get
`warmed: true` back. Only the node packages your tenant is entitled to use
are warmed; this only matters if a flow's saved graph somehow references a
package outside your tenant's access, in which case that package is
silently skipped rather than warmed.

### Request fields

| Field | Type | Required | Meaning |
|---|---|---|---|
| `graph_id` | string | yes | The compiled artifact to warm — the same id you'd pass to [invoke](#invoke-a-flow). Must match `[A-Za-z0-9_-]+`; anything else is a 400. |

Two other body fields, `tenant_id` and `flow_owner`, are accepted but
**always ignored** — see the next section. Don't rely on either to select
which tenant a warm applies to.

### Authentication and ownership

Pre-warm authenticates exactly like every other invocation route: your
bearer API key resolves to your tenant (see
[Base URL and authentication](#base-url-and-authentication)). That tenant —
never anything in the request body — is what the platform checks the
`graph_id` against: a `graph_id` your tenant does not own returns HTTP 403,
the same ownership check invoke performs. An unknown `graph_id` returns 404.
Either way, no warm is dispatched — a warm request can never be used to
spin up capacity for another tenant's flow.

The `tenant_id` and `flow_owner` body fields exist in the request schema
only so a forged value there has nowhere to go — the platform never reads
them. Setting either has no effect; your key's tenant is always the one
enforced.

### Rate limits and quotas

Pre-warm shares the same per-tenant budgets as
[invoke](#invoke-a-flow) — it does not have a separate limit:

- The [rate limits](#rate-limits) above (100 req/s overall, 5 req/s on
  invocation routes) apply identically.
- Each warm debits **one unit of the daily invocation quota** (the same
  2,000/day budget a flow invoke, single-node call, or resume consumes —
  see [Daily quotas](#daily-quotas)). It does **not** touch the separate
  executions budget, since warming runs no flow.

A tenant that is suspended, or has exhausted its invocation quota, gets the
same 403 `tenant_suspended` / 429 `quota_exceeded` responses invoke returns
— see [Error envelope](#error-envelope).

### Errors specific to pre-warm

| Status | `error` class | When |
|---|---|---|
| 400 | `invalid_request` | `graph_id` is missing or doesn't match `[A-Za-z0-9_-]+`. |
| 403 | `forbidden` | The authenticated tenant does not own `graph_id`. |
| 404 | `not_found` | `graph_id` doesn't resolve to any flow. |
| 503 | `warm_unavailable` | The warm dispatcher isn't configured on this deployment; `retry_after_seconds: 5`. |

All other statuses (401, 429, 403 `tenant_suspended`, 503
`quota_unavailable`) match [Error envelope](#error-envelope) exactly.

## Client Builder management API

These routes create and manage [clients](../concepts/nodes-packages-flows.md)
— tenant-scoped bundles of nodes and flows that compile into a typed SDK —
and back the `axiom client` CLI (see
[Build a client SDK](../guides/build-a-client-sdk.md)). They're a separate
management surface from the invocation endpoints above: served under the
**`/api`** path prefix on the same gateway (`https://api.axiomide.com`), not
`/invocations`.

```text
POST https://api.axiomide.com/api/v1/clients
```

Authentication is the same bearer API key as every other route. Mutating
calls (`POST`, `PUT`, `DELETE`) additionally require a key with the
**`write`** scope — a read-only-scoped key gets HTTP 403 with
`{"error": "forbidden: token lacks 'write' scope"}`. A request that reaches
the registry without a resolved tenant (for example, calling the registry
directly instead of through the gateway) gets HTTP 401 with
`{"error": "missing tenant context"}`.

| Method | Path | Description |
|---|---|---|
| `POST` | `/v1/clients` | Create a client. |
| `GET` | `/v1/clients` | List the tenant's clients. |
| `GET` | `/v1/clients/{id}` | Get one client, including its members and versions. |
| `PUT` | `/v1/clients/{id}` | Replace a client's name/description/default language. |
| `DELETE` | `/v1/clients/{id}` | Delete a client (cascades to its members and versions). |
| `POST` | `/v1/clients/{id}/members` | Add one member (a node or a flow). |
| `PUT` | `/v1/clients/{id}/members` | Replace the full member set in one call. |
| `DELETE` | `/v1/clients/{id}/members/{memberId}` | Remove one member. |
| `POST` | `/v1/clients/{id}/build` | Start a build: a new version, or additional languages on an existing one. |
| `GET` | `/v1/clients/{id}/versions` | List every version+language build row. |
| `GET` | `/v1/clients/{id}/versions/{version}?language={language}` | Get one version+language build row. |
| `GET` | `/v1/clients/{id}/versions/{version}/download?language={language}` | Download the built SDK zip. |

### Create / get / list a client

```bash
curl -X POST 'https://api.axiomide.com/api/v1/clients' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"name": "billing", "description": "Billing SDK", "default_language": "python"}'
```

Request fields: `name` (string, required, unique per tenant — 409
`{"error": "a client with that name already exists"}` on collision),
`description` (string, optional), `default_language` (string, optional —
must be one of `go`, `python`, `rust`, `java`, `typescript`, `csharp`, else
400 `{"error": "unsupported language: <value>"}`). `PUT` takes the same
three fields to replace them wholesale.

Response (`201` for create, `200` for get/list/update):

```json
{
  "id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
  "tenant_id": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
  "name": "billing",
  "slug": "billing",
  "description": "Billing SDK",
  "default_language": "python",
  "latest_version": 0,
  "created_at": "2026-07-04T12:00:00Z",
  "updated_at": "2026-07-04T12:00:00Z",
  "members": [],
  "versions": [],
  "member_count": 0,
  "version_count": 0
}
```

`GET /v1/clients` returns this same shape as a JSON array (never `null`,
even with zero clients); `GET /v1/clients/{id}` is the only call that
populates `members`/`versions` — the list form always reports them empty
and relies on `member_count`/`version_count` instead. `GET /v1/clients/{id}`
returns 404 `{"error": "client not found"}` for an unknown or
foreign-tenant ID.

### Add / list / remove a member

```bash
curl -X POST 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/members' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"type": "node", "package": "acme/billing-utils", "version": "1.2.0", "node_name": "Summarize", "alias": "summarize"}'
```

Request fields: `type` (`"node"` or `"flow"`, required), and for
`type: "node"` — `package` (required), `version` (optional; empty or
`"latest"` pins the package's current latest version server-side),
`node_name` or `node_ulid` (one identifies the node); for `type: "flow"` —
`artifact_id` (required, the saved flow's compiled artifact ID). `alias`
is optional on the wire (derived from the node name if omitted) but must
be unique within the client — a collision returns 409
`{"error": "a member with that alias already exists — pass a distinct alias"}`.
`PUT /v1/clients/{id}/members` takes `{"members": [...]}` with the same
per-member shape and replaces the entire set transactionally.

Response (`201` for add, `200` for `PUT`/list):

```json
{
  "id": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
  "client_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
  "member_type": "node",
  "alias": "summarize",
  "package_name": "acme/billing-utils",
  "package_version": "1.2.0",
  "node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y701",
  "created_at": "2026-07-04T12:00:00Z"
}
```

`package_name`/`package_version`/`node_ulid` are omitted for a flow member,
which instead carries `artifact_id`. `DELETE .../members/{memberId}`
returns `204 No Content` whether or not the member existed.

### Build a version and check status

```bash
curl -X POST 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/build' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{"languages": ["python", "go"]}'
```

Request fields: `languages` (array of strings; falls back to the client's
`default_language` if omitted, deduplicated, each validated against the
same six-language set — 400 `{"error": "unsupported language: <value>"}`
for any unknown entry) and `version` (integer; `0` or omitted cuts a
**new** version from the client's current member set — 400
`{"error": "client has no members — add at least one node or flow"}` if it
has none; a positive value adds the given languages to that existing
version's frozen member snapshot instead — 404
`{"error": "version not found"}` if it doesn't resolve).

The call returns immediately with HTTP 202 and one row per requested
language, each starting in `pending` (build/upload happen asynchronously
in the background):

```json
[
  {
    "id": "01J8Z3K9Q2R4S6T8V0W2X4Y702",
    "client_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
    "version": 1,
    "language": "python",
    "status": "pending",
    "created_at": "2026-07-04T12:00:00Z"
  }
]
```

Poll `GET /v1/clients/{id}/versions` (all rows) or
`GET /v1/clients/{id}/versions/{version}?language={language}` (one row —
the `language` query param is required, 400
`{"error": "language query param is required"}` without it) until
`status` reaches `succeeded` or `failed`; a failed row's `error` field
carries the failure message. No API key is ever embedded in a build (see
[Call the SDK](../guides/build-a-client-sdk.md#call-the-sdk)) — the
generated SDK always reads `AXIOM_API_KEY` from its caller's environment.

### Download a built SDK

```bash
curl 'https://api.axiomide.com/api/v1/clients/01J8Z3K9Q2R4S6T8V0W2X4Y6Z8/versions/1/download?language=python' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -o billing-sdk-py.zip
```

Both `version` (path) and `language` (query, required) select the exact
build row. `200 OK` streams `Content-Type: application/zip` with
`Content-Disposition: attachment; filename="<client-slug>-sdk-<ext>.zip"`.
A row that hasn't reached `succeeded` returns 409
`{"error": "build is not ready (status: <status>)"}` instead of a body;
an unknown version/language pair returns 404
`{"error": "version not found"}`.

## Instance creation API

This route materializes an **Instance** — a [Generic node](../concepts/nodes-packages-flows.md#generic-node-and-instance-typed-at-use-time-not-at-publish-time)'s
port(s) bound to a real type — entirely server-side (no git commit, no build,
no Knative deploy) and backs `axiom instance create` (see the
`axiom-instance-authoring` skill). Like the Client Builder routes above, it's
served under the **`/api`** path prefix, and mutating calls require a bearer
API key with the **`write`** scope.

```text
POST https://api.axiomide.com/api/v1/instances
```

### Create an Instance

```bash
curl -X POST 'https://api.axiomide.com/api/v1/instances' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "generic_package": "acme/postgres-connector",
    "generic_node": "Query",
    "generic_version": "1.2.0",
    "name": "your-handle/orders-query",
    "output": {
      "message_name": "OrderSummary",
      "fields": {"id": "int64", "total": "double", "status": "string"}
    }
  }'
```

Request fields: `generic_package` / `generic_node` (required) locate the
Generic node to bind. `generic_version` is optional — empty or `"latest"`
resolves and **pins** whatever is latest at creation time (it does not track
"latest" going forward). `name` (required) is the Instance's scoped name,
`your-handle/instance-name`; the scope must equal the caller's own handle —
a mismatched scope gets 403. At least one of `output` / `input` is required,
matching whichever port(s) the Generic node declared generic — each is
`{"message_name": string, "fields": {name: fieldDef}}` with at least one
field.

A `fieldDef` value is either a bare scalar-kind string (as in the example
above — `string, int32, int64, uint32, uint64, double, float, bool, bytes`)
or an object for anything richer:
`{"kind": "<scalar>", "repeated": true}`, `{"kind": "<scalar>", "optional":
true}` (mutually exclusive — proto3 forbids `repeated optional`), or
`{"kind": "message", "message": {"message_name": "...", "fields": {...}}}`
for a field whose type is itself another message, defined inline —
recursively, so that nested message's own fields may in turn include a
`message`-kind field. Every message in the tree — the top-level facade and
every nested one, however deep — is synthesized in the same transaction that
creates the Instance: nothing is written if the request fails validation at
any level, and nothing exists as an intermediate row. See
[Configure an Instance from a Generic node](../guides/configure-an-instance.md#nested-fields-with-a-fields-file)
for a full worked example.

Response (`201`) is the created Instance row:

```json
{
  "package_id": "01J8Z3K9Q2R4S6T8V0W2X4Y6Z8",
  "node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y700",
  "name": "your-handle/orders-query",
  "version": "1",
  "scope": "your-handle",
  "owner_tenant_id": "01J8Z3K9Q2R4S6T8V0W2X4Y701",
  "visibility": 0,
  "generic_package": "acme/postgres-connector",
  "generic_version": "1.2.0",
  "generic_node_ulid": "01J8Z3K9Q2R4S6T8V0W2X4Y702",
  "output_message": "OrderSummary"
}
```

`input_message` is present instead of (or alongside) `output_message` when
the corresponding port was bound. Errors: 400 for a missing/malformed field
(`{"error": "generic_package and generic_node are required"}`, `"name is
required"`, `"at least one of output or input must be bound"`, an invalid
message/field identifier, a name collision in the draft tree, or binding a
node that isn't `kind: generic`), 403 if the scope doesn't match the
caller's handle, 409 `{"error": "an instance with that name already
exists"}` on a duplicate name, and 413 `{"error": "request body too
large"}` over the body-size cap below. **Both an unknown Generic package
and one the caller can't read return the identical 404 `{"error": "generic
package not found"}`** — a denial can't be used as an existence oracle for
another tenant's private package.

### Draft-tree limits

A request's `output`/`input` field tree — the top-level facade plus every
nested message — is bounded so a request fails fast with a clear error
instead of an unbounded compile:

| Limit | Bound |
|---|---|
| Nesting depth (top-level message counts as depth 1) | 10 |
| Distinct messages in one tree (both ports combined) | 100 |
| Fields on any one message | 200 |
| Request body size | 1 MiB |

A name reused for two different shapes within one request 400s as a
collision; the identical name **and** shape drafted twice (for example, the
same nested message referenced from both `output` and `input`) is a
harmless dedup, not an error. These same limits apply to a structured draft
sent to `POST /adapters/preview` below.

## Preview an adapter mapping

`POST /adapters/preview` runs a field mapping (a single-edge adapter, or a
multi-source compose mapping) against sample JSON and returns the exact
value it produces — the same engine (`PrepareAdapter`/`ExecuteAdapter`, CEL,
transforms) the registry compiler and worker use at real compile/dispatch
time, not an approximation. It backs the canvas's live mapping preview and
`axiom instance preview`. Same auth posture as the Instance route above:
served under `/api`, bearer API key required.

```text
POST https://api.axiomide.com/api/adapters/preview
```

```bash
curl -X POST 'https://api.axiomide.com/api/adapters/preview' \
  -H "Authorization: Bearer $AXIOM_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "dst_msg_name": "OrderSummary",
    "sources": [
      {"edge_id": "e1", "msg_name": "Order", "sample": {"amount": 42.5}}
    ],
    "adapter": {"total": "$.amount"}
  }'
```

Request fields: `dst_msg_name` (required) names the mapping's target
message. `sources` (required, at least one entry) is each incident edge's
`edge_id` + `msg_name` + sample `sample` JSON payload. Exactly one of
`adapter` (a single-edge DSL map, target field → expression) or
`compose_mapping` (`edge_adapters` + `compose_plan`, for a multi-source
compose consumer) is required; `adapter` requires exactly one `sources`
entry.

Any `msg_name` slot — `dst_msg_name` or a `sources[]` entry — accepts
**either** a bare registry message name (a JSON string, as above) **or** an
inline structured draft in place of it: the identical
`{"message_name": ..., "fields": {...}}` tree shape the Instance-creation
`fields` block uses (including nested `message`-kind fields and
`repeated`/`optional` modifiers). A draft is rendered into `.proto` text and
compiled transiently for that one request only — never persisted — so a
mapping can be previewed against a message that doesn't exist in the
registry yet:

```json
{
  "dst_msg_name": {"message_name": "OrderSummary", "fields": {"total": "double"}},
  "sources": [{"edge_id": "e1", "msg_name": "Order", "sample": {"amount": 42.5}}],
  "adapter": {"total": "$.amount"}
}
```

Response (`200`) is always `{"output": <json>}` or `{"error": "<message>"}`,
never a 4xx for a mapping-level failure (bad expression, type mismatch,
unresolvable field) — that's a property of the in-progress mapping, not a
malformed request:

```json
{"output": {"total": 42.5}}
```

A malformed request body, an unresolvable `msg_name` not found in the
registry, or exceeding the [draft-tree limits](#draft-tree-limits) above is
a 4xx with a plain-text body, not the `{"error": ...}` JSON envelope other
routes on this page use.

## Error envelope

Invocation failures with a known cause return a structured body with a
stable, machine-readable `error` class:

```json
{
  "error": "payload_too_large",
  "message": "payload exceeds hard cap: actual=18874368 max=16777216",
  "max_bytes": 16777216,
  "actual_bytes": 18874368
}
```

| Status | `error` class | Extra fields | When |
|---|---|---|---|
| 400 | — | `{"accepted": false, "error_message": "invalid request body: ..."}` | The request body is not valid JSON. |
| 401 | `unauthorized` | — | Missing, invalid, or revoked API key. |
| 413 | `payload_too_large` | `max_bytes`, `actual_bytes` (when the rejecting code path knows the sizes) | The input payload exceeds the platform's 16 MiB hard cap. |
| 422 | — | `{"error_message": "..."}` | Single-node invocation only: the node itself returned an error. |
| 429 | `rate limit exceeded` | `Retry-After: 1` header | Per-tenant rate limit exceeded. |
| 429 | `quota_exceeded` | `retry_after_seconds` to next UTC midnight, mirrored in the `Retry-After` header | Per-tenant [daily quota](#daily-quotas) (invocations or executions) exhausted. |
| 403 | `tenant_suspended` | — | Your tenant has been disabled by the operator. |
| 503 | `quota_unavailable` | `retry_after_seconds: 5` | The quota service is unreachable; the request is rejected as a safety measure. Retry with backoff. |
| 503 | `upstream_unavailable` | `retry_after_seconds: 5` | The flow could not be queued; a platform dependency was temporarily unavailable. Retry with backoff. |
| 503 | `blob_storage_unavailable` | `retry_after_seconds: 5` | A large input payload could not be stored; a platform dependency was temporarily unavailable. Retry with backoff. |
| 500 | — | `{"accepted": false, "error_message": "..."}` | Any failure that does not classify into the rows above (for example, a missing `graph_id`). |

Errors inside a successful HTTP exchange surface differently: a `wait`ed
invoke returns 202 with `result.success: false`, and a stream delivers a
final frame with `error` set and no `success: true` (the frame's `success`
field is omitted on failure, never serialized as `false`). The
[error catalog](../reference/error-catalog.md) lists the error messages
themselves.

## Live OpenAPI specs

Hand-maintained docs cannot know your flow's input schema — the live,
generated OpenAPI 3.0 specs can. Use them as the authoritative request and
response schemas:

- **Per flow:** `GET /api/graphs/{artifact_id}/openapi.json`
  (authenticated, same bearer key). Generated on demand from the compiled
  artifact: the entry node's input message is the documented input schema,
  the terminal node's output message is the result schema, and `graph_id`
  is pinned to that exact artifact. Pipeline-mode flows document
  `/v1/flows/invoke/stream`; unary flows document `/v1/flows/invoke`. The
  same spec backs the **Open interactive docs** button in the flow
  inspector's **API** section — see
  [Use the interactive API docs](../guides/use-interactive-api-docs.md).
- **Per package:** `GET /api/packages/{name}@{version}/openapi.json`
  (public, no auth). One `POST /v1/nodes/{owner}/{package}/{version}/{node}`
  operation per node, with input and output schemas and examples. An
  interactive try-it page for the same spec is served at
  `GET /api/packages/{name}@{version}/docs`.
