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

# Rate limiting

> How ModuleX rate-limits the REST API: per-key and per-user limits, the org and run-surface limiters, the 429 response shapes (string detail, dict detail, and the flat DenialEnvelope), the X-RateLimit-* / Retry-After headers, and how to back off in the SDKs.

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>;
};

ModuleX rate-limits the REST API at several independent layers. Most callers only meet the **per-key** and **per-user** request limiters, which return a 429 with a plain string `detail` and a standard set of rate-limit headers. Workflow runs and other managed-usage surfaces add a **run-rate** limiter that returns the flat [`DenialEnvelope`](/api-reference/errors), and organization-scoped routes add a third **org `api`-class** limiter. This page documents every limiter, every 429 wire shape, the headers each carries, and how to back off.

This page covers the HTTP API only. The realtime Socket.io collaboration server enforces a separate transport-level flood limit that is **not** an HTTP 429 — see [its own section below](#socket-io-transport-rate-limit-not-a-429) and [/realtime/socket-events](/realtime/socket-events).

<MediaEmbed id="MX-MEDIA-1230" type="image" caption={"Diagram of the rate-limit layers an API request passes through."} />

## The rate-limit layers at a glance

| Layer                  | Scope                 | Where it runs                        | Default / limit              | 429 wire shape                                                                     |
| ---------------------- | --------------------- | ------------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------- |
| Per-key                | One API key           | Every API-key-authenticated request  | `60` req/min (key default)   | Shape A — `{"detail": "API key rate limit exceeded"}` + headers                    |
| Per-user               | All keys for one user | Every API-key-authenticated request  | `300` req/min                | Shape A — `{"detail": "User rate limit exceeded (across all API keys)"}` + headers |
| Org `api`-class        | One organization      | Org-scoped routes (membership-gated) | Plan-dependent (`api` class) | Shape B — `{"detail": {"code": "rate_limited", ...}}` + headers                    |
| Run-rate (`sync_exec`) | One organization      | Run / managed-usage surfaces only    | Plan-dependent (run classes) | Shape D — flat [`DenialEnvelope`](/api-reference/errors) + headers                 |

All limiters use a **sliding 60-second window**. The per-key and per-user limiters are checked together — a request is admitted only if **both** have remaining capacity, and the layer that ran out determines the `detail` string.

<Warning>
  A single 429 status can carry **three different bodies** depending on which limiter fired. The per-key/per-user limiter returns a string `detail`; the org limiter returns a dict `detail` with `code: "rate_limited"`; the run-rate limiter returns a flat `DenialEnvelope` with `code: "rate_limit_exceeded"`. Branch on the response body, not just the status code. The full taxonomy lives on [/api-reference/errors](/api-reference/errors).
</Warning>

## Per-key and per-user limits

Every request authenticated with an `mx_live_…` API key passes through a two-level request limiter before it reaches a route handler. Both levels share the same sliding 60-second window.

* **Per-key** — each API key has its own `rate_limit_per_minute`. The default is **60 requests per minute**. The rate-limit store counter key is `ratelimit:apikey:{api_key_id}`.
* **Per-user** — across **all** of a user's API keys, the aggregate limit is **300 requests per minute**. The rate-limit store counter key is `ratelimit:user:{user_id}`.

A request is admitted only when both counters are below their limit. When the per-key limit is hit first, the `detail` is `"API key rate limit exceeded"`; when the per-user aggregate is hit first, it is `"User rate limit exceeded (across all API keys)"`. Both responses are HTTP 429 with the same header set.

<ParamField path="rate_limit_per_minute" type="integer" default="60">
  The per-key request ceiling, stored on each API key. New keys are created with `60`; an administrator can raise or lower it per key. The per-user aggregate of `300` req/min applies on top and is not configurable per request.
</ParamField>

This limiter authenticates and counts the request **before** any route runs, so it applies uniformly to reads, writes, and run triggers made with an API key.

### 429 response — per-key / per-user (Shape A)

```json 429 Too Many Requests — Shape A theme={null}
{
  "detail": "API key rate limit exceeded"
}
```

The response carries these headers:

<ResponseField name="X-RateLimit-Limit" type="integer">
  The ceiling for the limiter that produced the response (the per-key limit, e.g. `60`).
</ResponseField>

<ResponseField name="X-RateLimit-Remaining" type="integer">
  Requests left in the current 60-second window. `0` when the limit is hit.
</ResponseField>

<ResponseField name="X-RateLimit-Reset" type="integer">
  Unix epoch time in seconds when the window resets and capacity is restored.
</ResponseField>

<ResponseField name="Retry-After" type="integer">
  Seconds to wait before retrying. Always present on a 429; defaults to `60` when a more precise value is not available.
</ResponseField>

## Org `api`-class limit

Org-scoped routes (those that require organization membership via `X-Organization-ID`) add an **organization `api`-class** limiter on top of the per-key and per-user limiters. Its ceiling comes from your plan's `api` rate-limit entitlement, so it is plan-dependent rather than a fixed default. See [/billing/usage-gating](/billing/usage-gating) and [/billing/plans](/billing/plans) for the per-plan values.

This limiter **stacks** with the others — an org-scoped API-key request is counted against the org `api`-class counter, the per-user counter (300/min), and the per-key counter. It **fails open** on any error, so a rate-limit store outage never blocks org traffic. Administrative routes and scheduled runs do not pass through it.

### 429 response — org `api`-class (Shape B)

The org limiter returns the structured envelope **wrapped under `detail`** (a dict-valued `detail`), with `code: "rate_limited"`:

```json 429 Too Many Requests — Shape B theme={null}
{
  "detail": {
    "code": "rate_limited",
    "layer": "rate",
    "key": "api",
    "current": 100,
    "limit": 100,
    "reason": "rate_limit_exceeded"
  }
}
```

<Expandable title="Shape B fields">
  <ResponseField name="detail.code" type="string">
    Always `"rate_limited"` for this limiter. Note this differs from the run-rate limiter's `"rate_limit_exceeded"` (see below) — same status, different code.
  </ResponseField>

  <ResponseField name="detail.layer" type="string">
    Always `"rate"`.
  </ResponseField>

  <ResponseField name="detail.key" type="string">
    Always `"api"` — the rate class that denied the request.
  </ResponseField>

  <ResponseField name="detail.current" type="integer">
    Observed request count in the window at denial time.
  </ResponseField>

  <ResponseField name="detail.limit" type="integer">
    The org `api`-class ceiling from your plan.
  </ResponseField>

  <ResponseField name="detail.reason" type="string">
    Always `"rate_limit_exceeded"`.
  </ResponseField>
</Expandable>

This response carries the same `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` headers documented above.

## Run-rate limits (run / managed-usage surfaces)

Workflow runs and the other managed-usage surfaces — the AI Composer, the [Assistant](/assistant/permissions-and-limits), and managed knowledge retrieval and ingestion — pass through an additional **run-rate** limiter as part of the billing admission gate. The schema defines two plan-scoped classes, but only one is live today:

* **`sync_exec`** — synchronous (blocking) run executions per minute. This is the **only** run-rate class that is actually consumed: every run and managed-usage surface counts against `sync_exec`, so it is the only one that can produce a live 429.
* **`async_exec`** — defined in the entitlement schema for asynchronous run executions and **reserved for a future surface**. It is not an active limit today; treat the synchronous `sync_exec` class as the live run-rate limit.

The `sync_exec` ceiling comes from your plan's rate-limit entitlements, so the exact number is plan-dependent. A `null` entitlement means **unlimited** (the limiter is skipped), and a `0` entitlement means **blocked**. See [/billing/usage-gating](/billing/usage-gating) for the per-plan run-rate values and [/billing/plans](/billing/plans) for the plan matrix.

When the run-rate limiter denies a request, it first releases any credit reservation the gate had taken (so a blocked run is charged nothing), then raises a 429 carrying the flat [`DenialEnvelope`](/api-reference/errors). This is the form you must handle on these surfaces alongside the header-based 429.

<Note>
  The run-rate limiter is part of the **live** billing admission gate. It applies on the run / Composer / Assistant / managed-knowledge surfaces and is **absent** on plain CRUD and org-settings routes — those return only the `{"detail": …}` shapes. The same gate also produces 402 (credit/wallet) and 403 (quota) denials; those are covered on [/api-reference/errors](/api-reference/errors) and [/billing/usage-gating](/billing/usage-gating).
</Note>

### 429 response — run-rate (Shape D, `DenialEnvelope`)

The run-rate denial is a **flat** object with no `detail` wrapper and `code: "rate_limit_exceeded"`:

```json 429 Too Many Requests — Shape D theme={null}
{
  "code": "rate_limit_exceeded",
  "layer": "rate",
  "key": "sync_exec",
  "current": 150,
  "limit": 150,
  "reason": "rate_limit_exceeded"
}
```

<Expandable title="DenialEnvelope fields (rate layer)">
  <ResponseField name="code" type="string" required>
    Machine-stable denial code. For the run-rate limiter this is always `"rate_limit_exceeded"`. Branch on this in client code.
  </ResponseField>

  <ResponseField name="layer" type="string" required>
    The gate layer that denied the request. `"rate"` for run-rate denials. The `layer` also determines the HTTP status: `rate` → 429, `quota` → 403, `credit` → 402, `wallet` → 402.
  </ResponseField>

  <ResponseField name="key" type="string">
    The rate class that was exhausted. In practice this is always `"sync_exec"`, the only active run-rate class. (`"async_exec"` is reserved for a future surface and does not appear here.)
  </ResponseField>

  <ResponseField name="current" type="number">
    Observed run count in the window at denial time. May be `null` when not applicable.
  </ResponseField>

  <ResponseField name="limit" type="number">
    The plan ceiling for the class that was hit. May be `null` when not applicable.
  </ResponseField>

  <ResponseField name="reason" type="string">
    Short reason token; `"rate_limit_exceeded"` for run-rate denials.
  </ResponseField>
</Expandable>

A `rate`-layer `DenialEnvelope` also carries the rate-limit headers, each **omitted** when its underlying value is unknown rather than sent as an empty string. `Retry-After` is always present and defaults to `60`.

<ResponseField name="Retry-After" type="integer">
  Seconds to wait before retrying. Defaults to `60`.
</ResponseField>

<ResponseField name="X-RateLimit-Limit" type="integer">
  The class ceiling. Omitted when unknown.
</ResponseField>

<ResponseField name="X-RateLimit-Remaining" type="integer">
  Runs left in the window. Omitted when unknown.
</ResponseField>

<ResponseField name="X-RateLimit-Reset" type="integer">
  Unix epoch (seconds) when the window resets. Omitted when unknown.
</ResponseField>

## Reading the headers

Every 429 response — whichever limiter fired — carries `Retry-After`, and where the value is known, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`. The example below triggers a workflow run, then inspects the headers on a 429. Replace `mx_live_…` with your API key and the IDs with your own.

<CodeGroup>
  ```bash cURL theme={null}
  # Trigger a run; on 429, the headers tell you how long to wait.
  # The only run route is POST /workflows/run — pass the workflow id in the body.
  curl -i -X POST "https://api.modulex.dev/workflows/run" \
    -H "Authorization: Bearer mx_live_8b1c4e9f2a7d6035c1e84f29" \
    -H "X-Organization-ID: org_3d9a1f7c52e84b06" \
    -H "Content-Type: application/json" \
    -d '{"workflow_id": "wf_8f3c2a1b", "input": {"topic": "quarterly report"}}'

  # HTTP/1.1 429 Too Many Requests
  # Retry-After: 60
  # X-RateLimit-Limit: 150
  # X-RateLimit-Remaining: 0
  # X-RateLimit-Reset: 1718924400
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_8b1c4e9f2a7d6035c1e84f29",
          organization_id="org_3d9a1f7c52e84b06",
      ) as client:
          try:
              run = await client.executions.run(
                  workflow_id="wf_8f3c2a1b",
                  input={"topic": "quarterly report"},
              )
          except RateLimitError as err:
              # Header-based 429 (per-key / per-user / org limiter).
              print("retry after", err.retry_after, "seconds")
              print("limit", err.limit, "remaining", err.remaining, "reset", err.reset)
          except BillingError as err:
              # Run-rate DenialEnvelope 429 surfaces here when layer == "rate".
              if err.layer == "rate":
                  print("run-rate limited:", err.code, "retry after", err.retry_after)

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex, RateLimitError } from 'modulex-js';

  const client = new Modulex({
    apiKey: 'mx_live_8b1c4e9f2a7d6035c1e84f29',
    organizationId: 'org_3d9a1f7c52e84b06',
  });

  try {
    const run = await client.executions.run({
      workflowId: 'wf_8f3c2a1b',
      input: { topic: 'quarterly report' },
    });
  } catch (err) {
    if (err instanceof RateLimitError) {
      // Both header-based and DenialEnvelope 429s map to RateLimitError in JS.
      console.log('retry after', err.retryAfter, 'seconds');
      console.log('limit', err.limit, 'remaining', err.remaining, 'reset', err.reset);
    }
  }
  ```
</CodeGroup>

<Note>
  The two SDKs surface 429s differently. The [JavaScript SDK](/sdks/javascript) maps **every** 429 to `RateLimitError` regardless of body shape. The [Python SDK](/sdks/python) maps only the string-`detail` header 429 (Shape A) to `RateLimitError`. **Both** the FastAPI-wrapped dict-`detail` envelope (Shape B) **and** the flat `rate`-layer `DenialEnvelope` (Shape D) raise the **base** `BillingError` with `layer == "rate"` — because the Python SDK's layer-to-subclass map has no `"rate"` key, so any `rate`-layer envelope (wrapped under `detail` or top-level) falls through to base `BillingError`. Catch **both** `RateLimitError` and `BillingError` in Python to handle every 429. See [/sdks/errors-retries](/sdks/errors-retries).
</Note>

## Backing off and retrying

Both SDKs treat `429`, `500`, `502`, and `503` as **retryable** and honor `Retry-After` when deciding how long to wait between attempts; `400`, `401`, `403`, `404`, `409`, and `422` are not retried. The Python client retries `GET` and `HEAD` requests only, while the JavaScript client retries all methods. SSE stream connection errors are surfaced once and are not auto-reconnected.

When you back off by hand, prefer `Retry-After` over a fixed sleep, and add jitter so concurrent clients do not retry in lockstep:

<CodeGroup>
  ```python Python theme={null}
  import asyncio
  import random
  from modulex import Modulex, RateLimitError

  async def run_with_backoff(client, workflow_id, *, max_attempts=5):
      for attempt in range(max_attempts):
          try:
              return await client.executions.run(
                  workflow_id=workflow_id, input={"topic": "report"}
              )
          except RateLimitError as err:
              if attempt == max_attempts - 1:
                  raise
              wait = (err.retry_after or 60) + random.uniform(0, 1)
              await asyncio.sleep(wait)
  ```

  ```javascript JavaScript theme={null}
  import { RateLimitError } from 'modulex-js';

  async function runWithBackoff(client, workflowId, maxAttempts = 5) {
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
      try {
        return await client.executions.run({ workflowId, input: { topic: 'report' } });
      } catch (err) {
        if (!(err instanceof RateLimitError) || attempt === maxAttempts - 1) throw err;
        const wait = ((err.retryAfter ?? 60) + Math.random()) * 1000;
        await new Promise((resolve) => setTimeout(resolve, wait));
      }
    }
  }
  ```
</CodeGroup>

<Tip>
  The per-user aggregate of 300 requests per minute counts **every** key a user owns, so adding more API keys for one user does not raise the ceiling. For high-volume runs, watch the live run-rate `sync_exec` class on [/billing/usage-gating](/billing/usage-gating) (`async_exec` is reserved for a future surface) and consider a plan with higher run ceilings on [/billing/plans](/billing/plans).
</Tip>

## Socket.io transport rate limit (not a 429)

The realtime collaboration server enforces its own **per-user flood limit** on socket messages. This is a transport-level guard and is deliberately distinct from the HTTP rate limits above — it is **not** an HTTP 429 and it does **not** carry a `DenialEnvelope`. When a connected user sends gated events too quickly, the server emits an `error` event:

```json Socket.io error event theme={null}
{
  "code": "rate_limited",
  "message": "Too many requests. Please slow down.",
  "retryAfterMs": 1234
}
```

The `retryAfterMs` hint is included only on a genuine limit hit; a store error blocks the operation without it (the limiter fails closed here). The limiter is keyed per user across all gated events. For the full Socket.io event reference, see [/realtime/socket-events](/realtime/socket-events).

<Warning>
  Do not confuse the Socket.io `code: "rate_limited"` (transport, with `message` and `retryAfterMs`) with the HTTP org-limiter `code: "rate_limited"` (a 429 with a dict `detail`) or the run-rate `code: "rate_limit_exceeded"` (a 429 with a flat `DenialEnvelope`). They are three separate mechanisms on two different transports.
</Warning>

## Related pages

<CardGroup cols={2}>
  <Card title="Errors & status codes" icon="circle-exclamation" href="/api-reference/errors">
    The three HTTP error-envelope shapes and which surface emits each, including the full status taxonomy.
  </Card>

  <Card title="Usage gating & limits" icon="gauge-high" href="/billing/usage-gating">
    The billing admission gate and the per-plan run-rate ceilings that drive Shape D 429s.
  </Card>

  <Card title="Authentication" icon="key" href="/api-reference/authentication">
    Authenticate every request with `Authorization: Bearer mx_live_…` and `X-Organization-ID`.
  </Card>

  <Card title="SDK errors & retries" icon="rotate" href="/sdks/errors-retries">
    SDK error classes, the retry policy, and how each SDK surfaces a 429.
  </Card>
</CardGroup>
