> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Make your first API call

> Run a ModuleX workflow programmatically and stream its result with cURL, Python, and JavaScript — authenticated with Authorization: Bearer and X-Organization-ID.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

This page runs a deployed workflow from code and streams its result, end to end, in three languages. You will send one authenticated `POST` to start a run, then open a Server-Sent Events (SSE) stream to watch it finish. Every operation is shown once as a `<CodeGroup>` with cURL, Python, and JavaScript tabs.

If you have not yet created an organization and an API key, start with the [Quickstart](/get-started/quickstart) and come back here. For the full request lifecycle, base URLs, and the unified SDK reference, see the [API overview](/api-reference/overview) and the [SDKs overview](/sdks/overview).

<Note>
  You need a saved workflow with an **active deployment** to run it by `workflow_id`. Deploying a workflow snapshots its schema and marks one deployment live; `POST /workflows/run` loads that live snapshot. Build and deploy one first in the [workflow builder](/workflow-builder/overview), then run it here. You can also run an inline ad-hoc definition — see [Running by ID vs. inline definition](#running-by-id-vs-inline-definition).
</Note>

## Before you begin

You need three things:

<Steps>
  <Step title="An API key">
    A user API key with the `mx_live_` prefix. Create one in the app under your organization's API Keys settings, or with `POST /api-keys`. Treat it like a password — it carries your organization's permissions.
  </Step>

  <Step title="Your organization ID">
    The UUID of the organization the call runs against. Every org-scoped request requires it in the `X-Organization-ID` header. Retrieve it from `GET /auth/me` (the `primary_organization_id` field) or `GET /organizations`.
  </Step>

  <Step title="A deployed workflow">
    A workflow with an active deployment, identified by its `workflow_id`. If the workflow has no live deployment, `POST /workflows/run` returns `400` — deploy it first.
  </Step>
</Steps>

The base URL is `https://api.modulex.dev`. Routers mount at the root, so there is **no `/v1` or `/api` path segment** — the run endpoint is exactly `https://api.modulex.dev/workflows/run`. See [Base URLs, environments & versioning](/get-started/environments) for details.

## Authentication

Authenticate every request with two headers:

```http theme={null}
Authorization: Bearer mx_live_xxx
X-Organization-ID: 11111111-1111-1111-1111-111111111111
```

<Warning>
  The auth header is `Authorization: Bearer`, **not** `X-Authorization`. There is no `X-Authorization` header anywhere in ModuleX. The backend accepts your API key as `Authorization: Bearer mx_live_…` or, alternatively, as `X-API-KEY: mx_live_…`. Both official SDKs send the `Authorization: Bearer` form. See [Authentication](/api-reference/authentication) for the complete model.
</Warning>

<ParamField header="Authorization" type="string" required>
  `Bearer mx_live_<key>`. The same scheme carries either a user API key (`mx_live_` prefix) or a Clerk JWT (browser sessions); the backend distinguishes them by the prefix. For programmatic calls, use your `mx_live_` key.
</ParamField>

<ParamField header="X-Organization-ID" type="string" required>
  The organization UUID the request is scoped to. **Required** on org-scoped endpoints, including `/workflows/run`. If it is missing, the request fails with `400` and `{"detail": "X-Organization-ID header is required"}`.
</ParamField>

<ParamField header="X-API-KEY" type="string">
  Optional alternative to `Authorization: Bearer` for the API key. Send either one, not both. The SDKs do not use this header.
</ParamField>

<ParamField header="Content-Type" type="string" required>
  `application/json` on the `POST` body. Omit it on the SSE `GET` (the listen call has no body).
</ParamField>

`POST /workflows/run` requires the caller to be an **owner or admin** of the organization. A non-admin caller receives `403`. The `member` role is retired and is not a current first-class role — see [Roles & permissions](/security/roles-permissions).

## Install an SDK

The cURL examples need no dependencies. To use a typed client, install one of the official SDKs.

<CodeGroup>
  ```bash Python theme={null}
  pip install modulex-python
  ```

  ```bash JavaScript theme={null}
  npm install modulex-js
  ```
</CodeGroup>

The Python SDK is **async-only** and reads `MODULEX_API_KEY`, `MODULEX_BASE_URL`, and `MODULEX_ORGANIZATION_ID` from the environment when the matching constructor argument is omitted. The JavaScript SDK has **no environment-variable fallback** — pass `apiKey` (and `organizationId`) to the constructor explicitly. Both default `baseUrl` to `https://api.modulex.dev`. This divergence is documented on the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) pages.

## Step 1 — Start a run

Send a `POST` to `/workflows/run` with the `workflow_id` of your deployed workflow and the `input` your workflow's state expects. The request returns immediately — the run executes in the background — so the response status is `running`, not a final result.

In the SDKs this operation is `client.executions.run(...)` (the execution-control methods live under `/workflows` on the backend but are grouped as `executions` in both clients).

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow_id": "550e8400-e29b-41d4-a716-446655440000",
      "input": { "query": "Summarize this week'\''s AI news" },
      "config": { "recursion_limit": 50 }
    }'
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex


  async def main():
      async with Modulex(
          api_key="mx_live_xxx",
          organization_id="11111111-1111-1111-1111-111111111111",
      ) as client:
          run = await client.executions.run(
              workflow_id="550e8400-e29b-41d4-a716-446655440000",
              input={"query": "Summarize this week's AI news"},
              config={"recursion_limit": 50},
          )
          print(run.status, run.run_id, run.thread_id)


  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  const run = await client.executions.run({
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
    input: { query: "Summarize this week's AI news" },
    config: { recursionLimit: 50 },
  });

  console.log(run.status, run.run_id, run.thread_id);
  ```
</CodeGroup>

<Note>
  In the JavaScript SDK, request parameters are **camelCase** (`workflowId`, `recursionLimit`) and are converted to snake\_case on the wire — but **response fields stay snake\_case** (`run.run_id`, `run.thread_id`). The Python SDK is snake\_case in both directions. This is why the JS example reads `run.run_id`, not `run.runId`.
</Note>

### Request body

The `/workflows/run` body is a JSON object. Provide **exactly one** execution mode (`workflow_id`, `workflow`, or `system_workflow`); the rest of the fields are optional.

<ParamField body="workflow_id" type="string">
  The UUID of a saved workflow. ModuleX loads that workflow's **live deployment** snapshot. Returns `400` if the workflow has no active deployment. Mutually exclusive with `workflow` and `system_workflow`.
</ParamField>

<ParamField body="workflow" type="object">
  An inline ad-hoc workflow definition (a full `WorkflowDefinition`) to run without saving it first. The run is marked `is_ad_hoc=true`. Mutually exclusive with `workflow_id` and `system_workflow`.
</ParamField>

<ParamField body="system_workflow" type="string">
  The name of a built-in system workflow. When used, both `input` and `config` are required (`400` otherwise). Mutually exclusive with the other two modes.
</ParamField>

<ParamField body="input" type="object" default="{}">
  The initial state passed to the workflow's entry node, keyed by your `state_schema` fields.
</ParamField>

<ParamField body="config" type="object" default="{}">
  Per-run execution config. Recognized keys: `thread_id` (string, reuse a checkpoint thread), `recursion_limit` (integer, caps graph steps; the workflow's own default is `500`), and `batch_interval_ms` (integer, event batching cadence). When running by `workflow_id`, these merge over the deployment's stored config.
</ParamField>

<ParamField body="stream" type="boolean" default="true">
  Echoed back in the response. Run events are always observed by opening the SSE stream in step 2; this flag does not toggle inline token streaming.
</ParamField>

<ParamField body="ephemeral" type="boolean" default="false">
  When `true`, the run is not attached to a chat record: `chat_id` is `null` and `thread_id` is a fresh UUID.
</ParamField>

<ParamField body="is_private" type="boolean" default="false">
  When `true`, scopes the created chat as private. Sent as `is_private` on the wire (`isPrivate` in the JS SDK).
</ParamField>

<ParamField body="attribution_workflow_id" type="string">
  Attributes an inline (`workflow`) run to a saved workflow's run history without switching to deployment-load mode.
</ParamField>

### Response

A successful start returns `200` with the run metadata. The run is not finished — you receive identifiers to track it.

```json theme={null}
{
  "status": "running",
  "run_id": "6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
  "thread_id": "550e8400-e29b-41d4-a716-446655440000",
  "chat_id": "550e8400-e29b-41d4-a716-446655440000",
  "ephemeral": false,
  "stream": true,
  "workflow_name": "Research",
  "workflow_version": "1.0",
  "workflow_source": "database",
  "elapsed_ms": 142.7,
  "human_message": { "id": "...", "role": "human", "content": {} },
  "ai_message": { "id": "...", "role": "ai", "running_status": "running" },
  "message": "Workflow execution started in background. Use /workflows/listen/{run_id} to track progress."
}
```

<ResponseField name="status" type="string">
  Always `"running"` on a successful start. The terminal outcome arrives over the SSE stream, not in this response.
</ResponseField>

<ResponseField name="run_id" type="string">
  The per-execution identifier. Use it to listen, cancel, and look up the run. This is the id you pass to `GET /workflows/listen/{run_id}`.
</ResponseField>

<ResponseField name="thread_id" type="string">
  The checkpoint thread. Equals `chat_id` when `ephemeral=false`. Use it for `GET /workflows/state/{thread_id}` and to resume after an interrupt.
</ResponseField>

<ResponseField name="chat_id" type="string | null">
  The chat the run is attached to, or `null` for an ephemeral run.
</ResponseField>

<ResponseField name="workflow_source" type="string">
  Where the executed schema came from: `database`, `request` (inline), or `system:<name>`.
</ResponseField>

<ResponseField name="workflow_name" type="string">
  The name of the workflow being executed.
</ResponseField>

<ResponseField name="workflow_version" type="string">
  The version label of the executed workflow.
</ResponseField>

<ResponseField name="elapsed_ms" type="number">
  Wall-clock time spent setting up the run before responding.
</ResponseField>

<ResponseField name="human_message" type="object | null">
  The persisted user message envelope, or `null` for an ephemeral run.
</ResponseField>

<ResponseField name="ai_message" type="object | null">
  The persisted assistant message envelope (with `running_status`), or `null` for an ephemeral run.
</ResponseField>

<Warning>
  The three identifiers above are **not interchangeable**, and ModuleX uses the word "run id" in three distinct senses. Pass the right one to each call:

  * `run_id` — the per-execution id for `GET /workflows/listen/{run_id}` and `POST /workflows/cancel/{run_id}`.
  * `thread_id` — the checkpoint thread for `GET /workflows/state/{thread_id}` and `POST /workflows/resume/{thread_id}`.
  * The run record's `id` (the `id` field on a `GET /workflow-runs` row) — for `GET /workflow-runs/{run_pk}`. Passing the `run_id` there returns `404`.

  See [Workflows & runs](/concepts/workflows-and-runs) for the full breakdown.
</Warning>

### Running by ID vs. inline definition

Most calls run a saved, deployed workflow by `workflow_id`. To run a definition without saving it, send it inline under `workflow` instead.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/workflows/run \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "workflow": {
        "metadata": { "name": "Ad-hoc research", "version": "1.0" },
        "state_schema": { "fields": { "query": { "type": "string" } } },
        "nodes": [],
        "edges": [],
        "entry_point": "__start__"
      },
      "input": { "query": "Summarize this week'\''s AI news" }
    }'
  ```

  ```python Python theme={null}
  run = await client.executions.run(
      workflow={
          "metadata": {"name": "Ad-hoc research", "version": "1.0"},
          "state_schema": {"fields": {"query": {"type": "string"}}},
          "nodes": [],
          "edges": [],
          "entry_point": "__start__",
      },
      input={"query": "Summarize this week's AI news"},
  )
  ```

  ```javascript JavaScript theme={null}
  const run = await client.executions.run({
    workflow: {
      metadata: { name: "Ad-hoc research", version: "1.0" },
      state_schema: { fields: { query: { type: "string" } } },
      nodes: [],
      edges: [],
      entry_point: "__start__",
    },
    input: { query: "Summarize this week's AI news" },
  });
  ```
</CodeGroup>

<Note>
  Inside the inline `workflow` definition, field names are already snake\_case (`state_schema`, `entry_point`) in the JavaScript SDK too — only the outer parameters use camelCase. For the complete `WorkflowDefinition` contract, see [Workflow engine & nodes](/concepts/workflow-engine) and [Variables & references](/workflow-builder/variables-and-references).
</Note>

## Step 2 — Stream the result

Open `GET /workflows/listen/{run_id}` to receive run events over SSE. The stream replays any buffered history first, then tails live, and closes after a terminal event (`done`, `error`, or `cancelled`). Multiple clients can listen to the same `run_id` concurrently.

In the SDKs this is `client.executions.listen(run_id)`, which yields parsed events you iterate over.

<CodeGroup>
  ```bash cURL theme={null}
  curl -N https://api.modulex.dev/workflows/listen/6a7b8c9d-1e2f-3a4b-5c6d-7e8f9a0b1c2d \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  async with client.executions.listen(run.run_id) as stream:
      async for event in stream:
          if event.event == "node_update":
              print("node", event.data.get("node"), "→", event.data.get("output"))
          if event.is_terminal:
              print("finished:", event.event)
              break
  ```

  ```javascript JavaScript theme={null}
  for await (const event of client.executions.listen(run.run_id)) {
    if (event.type === "node_update") {
      console.log("node", event.node, "→", event.output);
    }
    if (event.type === "done" || event.type === "error" || event.type === "cancelled") {
      console.log("finished:", event.type);
      break;
    }
  }
  ```
</CodeGroup>

<Note>
  The two SDKs surface the event discriminator differently. The Python `SSEEvent` normalizes it to `event.event` and exposes an `is_terminal` property; the JavaScript event uses `event.type`. Both read the same underlying wire field. The Python SDK filters heartbeat frames out by default; the JavaScript SDK skips the SSE comment lines used as keepalives.
</Note>

### SSE frame format

Run events are **data-only** SSE frames: each frame is `data: {json}\n\n` with **no `event:` line**. The discriminator is the `type` key inside the JSON. Here is a raw stream for a run that completes:

```text theme={null}
data: {"type":"metadata","data":{"run_id":"6a7b...","thread_id":"550e...","workflow_name":"Research","workflow_version":"1.0","workflow_type":"workflow","timestamp":"2026-06-21T10:00:00.000000+00:00"}}

data: {"type":"node_update","node":"plan","output":{"messages":[{"type":"ai","content":"..."}]}}

data: {"type":"heartbeat"}

data: {"type":"node_update","node":"summarize","output":{"summary":"This week in AI: ..."}}

data: {"type":"done","data":{"message":"Workflow completed successfully"}}
```

A failed run ends with `data: {"type":"error","message":"Execution failed: <message>"}` instead of `done`; a cancelled run ends with `data: {"type":"cancelled","data":{...}}`.

### Run event types

These are the event shapes the executor publishes on the wire. Note that some payloads are **wrapped** under a `data` key while others are **flat** alongside `type` — the table below reflects what a live client actually receives.

<ResponseField name="metadata" type="event (wrapped)">
  First frame. `data` carries `run_id`, `thread_id`, `workflow_name`, `workflow_version`, `workflow_type`, and `timestamp`.
</ResponseField>

<ResponseField name="node_update" type="event (flat)">
  A node produced output. Carries `node` (the node **name**) and `output` (the serialized state delta or messages). The knowledge node emits a richer `output` object with retrieval details.
</ResponseField>

<ResponseField name="node_started" type="event (flat)">
  A node began executing. Carries `node`, `name`, `timestamp`, and `metadata`. Emitted by some node types (for example, knowledge).
</ResponseField>

<ResponseField name="interrupt" type="event (wrapped)">
  The run paused at an interrupt (human-in-the-loop) node. `data` carries `thread_id`, `message`, and an optional `resume_schema`. This event does **not** close the live stream — resume with `POST /workflows/resume/{thread_id}` to continue. See [Human-in-the-loop (HITL) resume](/realtime/hitl).
</ResponseField>

<ResponseField name="resumed" type="event (wrapped)">
  Emitted after a resume. `data` carries `run_id`, `thread_id`, `resume_value`, and `timestamp`. A resumed workflow run keeps the **same** `run_id`.
</ResponseField>

<ResponseField name="done" type="event (wrapped, terminal)">
  The run completed successfully. `data` is `{"message": "Workflow completed successfully"}`. Closes the stream.
</ResponseField>

<ResponseField name="error" type="event (flat, terminal)">
  The run failed. Carries a `message` string. Closes the stream.
</ResponseField>

<ResponseField name="cancelled" type="event (wrapped, terminal)">
  The run was cancelled. `data` carries `run_id`, `reason`, and `cancelled_at` (or is `null` if the cancel record already expired). Closes the stream.
</ResponseField>

<ResponseField name="heartbeat" type="event (flat)">
  A `{"type":"heartbeat"}` keepalive injected after every 15 seconds of silence so a long pause does not idle-close the connection. Not a workflow event — ignore it. The SDKs handle this for you.
</ResponseField>

<Note>
  The durable run status uses different wording from the live stream: the SSE `done` event corresponds to the durable status `succeeded` you see later in `GET /workflow-runs`. The live status string is `completed`; the persisted status is `succeeded`.
</Note>

<MediaEmbed id="MX-MEDIA-1050" type="image" caption={"Sequence diagram of the first API call: client → `POST /workflows/run` → background executor → server-side pub/sub → SSE `GET /workflows/listen/{run_id}` → terminal `done`."} />

## Step 3 — Look up the run afterward

Once the stream closes, you can read the durable run record from history. List recent runs, then fetch one by its `id` field (returned by list/get, not the `run_id`).

<CodeGroup>
  ```bash cURL theme={null}
  # List recent runs for a workflow
  curl https://api.modulex.dev/workflow-runs?workflow_id=550e8400-e29b-41d4-a716-446655440000&limit=10 \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  runs = await client.executions.list_runs(
      workflow_id="550e8400-e29b-41d4-a716-446655440000", limit=10
  )
  latest = runs.runs[0]
  detail = await client.executions.get_run(latest.id)  # latest.id is the record's id, not run_id
  print(detail.status, detail.output_summary)
  ```

  ```javascript JavaScript theme={null}
  const { runs } = await client.workflowRuns.list({
    workflowId: "550e8400-e29b-41d4-a716-446655440000",
    limit: 10,
  });
  const detail = await client.workflowRuns.get(runs[0].id); // runs[0].id is the record's id, not run_id
  console.log(detail.status, detail.output_summary);
  ```
</CodeGroup>

<Note>
  Run history is grouped differently in the two SDKs: the Python SDK folds it into `client.executions` (`list_runs` / `get_run`), while the JavaScript SDK exposes a separate `client.workflowRuns` resource (`list` / `get`). Both call the same `GET /workflow-runs` routes.
</Note>

## Errors

`POST /workflows/run` is a **billing-gated** surface. Besides the usual validation and auth errors, it can return a billing denial before any run is created.

### Standard errors

These use the FastAPI envelope `{"detail": "<message>"}`.

| Status | When it happens                                                                                                                                                     |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Missing `X-Organization-ID`; no execution mode supplied; running by `workflow_id` with no active deployment; `system_workflow` without `input`/`config`.            |
| `401`  | Missing or invalid token (`WWW-Authenticate: Bearer` is returned).                                                                                                  |
| `403`  | The caller is not an organization owner/admin.                                                                                                                      |
| `404`  | The workflow (or, on listen, the run) is not found in your organization. The same `404` covers both not-found and cross-tenant access — there is no existence leak. |
| `410`  | Legacy LLM-only mode (an `llm`-only body with no workflow) is removed. Use the [Assistant](/assistant/overview) instead.                                            |
| `422`  | Request validation failed (FastAPI validation array).                                                                                                               |
| `500`  | A malformed inline `workflow` schema surfaces as `500` (it is validated inside the handler, not as a typed request model), as do other unexpected failures.         |

### Billing denials (the billing gate)

When your organization is over a credit, quota, or rate limit, the gate rejects the call **before any run record, chat row, or background task is created**. The response is a **flat** `DenialEnvelope` — note there is **no `detail` wrapper**:

```json theme={null}
{
  "code": "credit_plan_exhausted",
  "layer": "credit",
  "key": "credit:org:11111111-1111-1111-1111-111111111111",
  "current": 5000.0,
  "limit": 5000.0,
  "reason": "credit_plan_exhausted"
}
```

The HTTP status is determined by the `layer`:

| `layer`  | Status | `code` values                                                                |
| -------- | ------ | ---------------------------------------------------------------------------- |
| `rate`   | `429`  | `rate_limit_exceeded` (also sends `Retry-After` and `X-RateLimit-*` headers) |
| `quota`  | `403`  | `quota_exceeded`                                                             |
| `credit` | `402`  | `credit_plan_exhausted`, `upgrade_payment_failed`                            |
| `wallet` | `402`  | `wallet_overage_disabled`, `wallet_insufficient`                             |

<Warning>
  The flat `DenialEnvelope` shape is specific to billing-gated surfaces (run, composer, assistant, managed knowledge). Plain CRUD and org-settings routes do **not** return it — they return the `{"detail": …}` shape. Branch on both. The complete taxonomy of all three error-envelope shapes is on the [Errors & status codes](/api-reference/errors) page; usage limits are detailed in [Usage gating & limits](/billing/usage-gating).
</Warning>

In the SDKs, the Python client maps these to typed exceptions: `402` → `PaymentRequiredError` (or `CreditExhaustedError` / `WalletError` by layer), `403` → `PermissionError` (or `QuotaExceededError`), and `429` → `RateLimitError` carrying the retry headers. The JavaScript client maps `403` → `PermissionError` and `429` → `RateLimitError`; `402` falls through to the base `ModulexError`, where you can still read `code`, `layer`, and `reason`. See [SDK errors & retries](/sdks/errors-retries).

### Retries and idempotency

The SDKs retry only safe, idempotent requests (`GET`/`HEAD`) on `429`, `500`, `502`, and `503`, with backoff that honors `Retry-After`. A `POST /workflows/run` is **not** auto-retried.

<Warning>
  The Python SDK accepts an `idempotency_key` argument and sends it as the `Idempotency-Key` header on mutating calls, but `POST /workflows/run` mints its own `run_id` server-side and does not read that header — so it is a **no-op for run de-duplication**. Do not rely on `Idempotency-Key` to prevent duplicate runs. See [SDK errors & retries](/sdks/errors-retries).
</Warning>

## Next steps

<CardGroup cols={2}>
  <Card title="Run a workflow (REST + SDK)" icon="play" href="/guides/run-a-workflow">
    The full guide: authenticate, run, stream, and handle interrupts and the billing gate end to end.
  </Card>

  <Card title="SSE run streaming" icon="signal-stream" href="/realtime/sse-streaming">
    The complete SSE frame format, event taxonomy, and reconnect/replay behavior.
  </Card>

  <Card title="SDKs overview" icon="cubes" href="/sdks/overview">
    The JavaScript and Python clients, unified by operation, with the full parity matrix.
  </Card>

  <Card title="API overview" icon="book" href="/api-reference/overview">
    Base URLs, the request lifecycle, content types, and how every operation is shown three ways.
  </Card>
</CardGroup>
