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

# Authentication

> Authenticate every ModuleX API request with an Authorization: Bearer mx_live_ API key plus the X-Organization-ID header. Full reference for headers, key format, org scope, and error responses.

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

Every request to the ModuleX REST API is authenticated with a **ModuleX API key** sent in the `Authorization` header, and — for any organization-scoped endpoint — scoped to one organization with the `X-Organization-ID` header. The two SDKs send exactly these headers for you.

<Note>
  The API does **not** read a header named `X-Authorization`. There is no such header anywhere in the backend or in either SDK. Use `Authorization: Bearer` (preferred) or the `X-API-KEY` fallback, both described below.
</Note>

## The two headers you send

| Header              | Required                | Value                          | Applies to                                                                                                |
| ------------------- | ----------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `Authorization`     | Yes                     | `Bearer mx_live_…`             | Every endpoint                                                                                            |
| `X-Organization-ID` | On org-scoped endpoints | The organization's id (a UUID) | Workflows, runs, knowledge bases, credentials, integrations, composer, assistant, schedules, org settings |

A minimal authenticated request looks like this:

```bash Authenticated request theme={null}
curl https://api.modulex.dev/workflows \
  -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
  -H "X-Organization-ID: a1b2c3d4-e29b-41d4-a716-446655440000"
```

The base URL is `https://api.modulex.dev` and there is no `/v1` path segment — routers are mounted at the root. See [Base URLs & versioning](/api-reference/environments) for environments and the versioning policy, and the [API overview](/api-reference/overview) for the full request lifecycle.

<MediaEmbed id="MX-MEDIA-1190" type="screenshot" caption={"The API keys settings screen in the ModuleX app, showing the \"create key\" dialog and the one-time full-key reveal."} />

## API keys

A ModuleX API key is a per-user secret used by SDKs and any programmatic caller. It is distinct from the Clerk session token (JWT) that the web app uses — see [Auth model: JWT vs API key](/security/authentication) for how the two paths differ.

### Key format

| Property              | Value                                                                                           |
| --------------------- | ----------------------------------------------------------------------------------------------- |
| Prefix                | `mx_live_`                                                                                      |
| Random part           | \~43 Base62 characters (from 32 random bytes)                                                   |
| Example               | `mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u`                                      |
| Storage on the server | Kept as a one-way fingerprint (hardened with a server-side secret); the raw key is never stored |
| Shown to you          | **Once**, at creation time only                                                                 |

The backend distinguishes credentials by prefix: a token starting with `mx_live_` is treated as an API key; anything else presented as a bearer token is treated as a Clerk JWT.

<Warning>
  The full key is returned **only** in the response to creating it. List and detail endpoints return a masked value (`mx_live_2J9vK4xM********`). Store the key in a secret manager when you create it — there is no way to recover it later. If you lose it, revoke the key and create a new one.
</Warning>

### Create a key

Create keys in the app (Settings → API keys) or via the API. Creating a key requires an authenticated, already-provisioned user — you can present either a Clerk JWT or an existing `mx_live_` API key, so one key can create another. Only the very first key needs a JWT-provisioned account to bootstrap from.

<ParamField body="name" type="string" required>
  A label for the key. 1–255 characters.
</ParamField>

<ParamField body="organization_id" type="string">
  Scope the key to a single organization. The caller must be an active member of that organization. When omitted (the default), the key works across all of your organizations and you choose the organization per request with `X-Organization-ID`.
</ParamField>

<ParamField body="expires_at" type="string">
  An ISO 8601 timestamp. When omitted (the default), the key never expires.
</ParamField>

<ParamField body="rate_limit_per_minute" type="integer" default="60">
  Per-key request budget, 1–1000.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/api-keys \
    -H "Authorization: Bearer $CLERK_JWT" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "CI/CD",
      "rate_limit_per_minute": 120
    }'
  ```

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

  # Creating a key requires an authenticated user — either a Clerk JWT or an
  # existing mx_live_ API key works, so an existing key can create another.
  # You only need to bootstrap the very first key from a logged-in app session.
  async def main():
      async with Modulex(api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u") as client:
          key = await client.api_keys.create(name="CI/CD", rate_limit_per_minute=120)
          print(key.key)  # full key, shown once

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: 'mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u',
  });

  const key = await client.apiKeys.create({ name: 'CI/CD', rateLimitPerMinute: 120 });
  console.log(key.key); // full key, shown once
  ```
</CodeGroup>

The create response (HTTP `201`) is the **only** response that includes the full `key`:

<ResponseField name="id" type="string">UUID of the key record.</ResponseField>
<ResponseField name="name" type="string">The label you set.</ResponseField>
<ResponseField name="key" type="string">The full `mx_live_…` key. Returned only here.</ResponseField>
<ResponseField name="key_hint" type="string">The first 8 characters of the random part, for display.</ResponseField>
<ResponseField name="masked_key" type="string">`mx_live_{hint}********` — what list/detail endpoints return.</ResponseField>
<ResponseField name="organization_id" type="string | null">The org the key is scoped to, or `null` for all orgs.</ResponseField>
<ResponseField name="expires_at" type="string | null">Expiry timestamp, or `null` for never.</ResponseField>
<ResponseField name="is_expired" type="boolean">Whether the key is past its expiry.</ResponseField>
<ResponseField name="is_active" type="boolean">Whether the key is active (not revoked).</ResponseField>
<ResponseField name="rate_limit_per_minute" type="integer">The per-key budget.</ResponseField>
<ResponseField name="last_used_at" type="string | null">When the key was last used, or `null`.</ResponseField>
<ResponseField name="created_at" type="string">Creation timestamp.</ResponseField>
<ResponseField name="revoked_at" type="string | null">When the key was revoked, or `null`.</ResponseField>

### Key limits

| Limit                           | Value            | What happens when hit                           |
| ------------------------------- | ---------------- | ----------------------------------------------- |
| Keys per user                   | 10               | `400` with code `MAX_KEYS_EXCEEDED`             |
| Default rate limit per key      | 60 requests/min  | `429` (see [error responses](#error-responses)) |
| Rate limit across all your keys | 300 requests/min | `429`                                           |

### Revoke a key

Revoking is permanent and immediate; the record is kept for audit. A revoked key returns `401` on its next use.

```bash Revoke a key theme={null}
curl -X DELETE https://api.modulex.dev/api-keys/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $CLERK_JWT"
```

## Sending the key

You can present the key two ways. Both are accepted by the backend.

<Tabs>
  <Tab title="Authorization: Bearer (preferred)">
    This is what both SDKs send, and what you should use.

    ```http theme={null}
    Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u
    ```
  </Tab>

  <Tab title="X-API-KEY (fallback)">
    The backend also accepts the key in a dedicated header. Neither SDK uses this header — it exists for direct callers that prefer it. The value must still start with `mx_live_`.

    ```http theme={null}
    X-API-KEY: mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u
    ```
  </Tab>
</Tabs>

The backend resolves credentials in this order:

<Steps>
  <Step title="Authorization: Bearer">
    If present and the token starts with `mx_live_`, it is used as an API key. Otherwise it is verified as a Clerk JWT.
  </Step>

  <Step title="X-API-KEY">
    Consulted only if no bearer credential resolved, and only if the value starts with `mx_live_`.
  </Step>

  <Step title="Neither">
    A `401` is returned: `Authentication required. Provide Authorization: Bearer token or X-API-KEY header.`
  </Step>
</Steps>

## Organization scope

ModuleX is multi-tenant. Identity (your key) and organization (the tenant you act in) are **two separate axes**: the key says who you are; `X-Organization-ID` says which organization the request runs against. Most resources — workflows, runs, knowledge bases, credentials, integrations, the composer, the assistant, and schedules — are organization-scoped and require the header. Identity-only endpoints such as `GET /auth/me` and `GET /auth/invitations/my` do not.

```bash Org-scoped request theme={null}
curl https://api.modulex.dev/organizations/llms \
  -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
  -H "X-Organization-ID: a1b2c3d4-e29b-41d4-a716-446655440000"
```

On an org-scoped endpoint, the backend checks the header in this order:

<Steps>
  <Step title="Header present">
    Missing `X-Organization-ID` on an org-scoped route returns `400`: `X-Organization-ID header is required`.
  </Step>

  <Step title="Key scope matches">
    If the key is **scoped** to a specific organization (you set `organization_id` when creating it), the header must name that same organization, or you get `403`: `API key is scoped to a different organization`.
  </Step>

  <Step title="Membership">
    You must be a member of the named organization, or you get `403`: `User is not a member of organization {id}`.
  </Step>

  <Step title="Role">
    Some surfaces require a privileged role (see below).
  </Step>
</Steps>

Find the organizations you belong to — and their ids — with `GET /auth/me/organizations`. The header value is the organization's id. For the full org-context model see [Org context & X-Organization-ID](/security/org-context) and the concept page on [organizations, roles & membership](/concepts/organizations-roles).

<Note>
  A few request **bodies** also carry an `organization_id` field (for example, when creating an org-scoped API key). That field sets which organization **owns** the new resource. It is independent of the `X-Organization-ID` header, which sets which organization you are **acting in**. Do not conflate the two.
</Note>

### Roles

The live organization roles are **`owner`** and **`admin`**. The `member` role is retired and is not a current role — document and design against `owner`/`admin` only. A handful of surfaces require `owner` or `admin` rather than plain membership: the composer, the assistant, and the organization-settings and member-management endpoints. A membership-only call to one of these returns `403`. See [Roles & permissions](/security/roles-permissions) for the per-endpoint matrix.

## Authenticating with the SDKs

Both official SDKs send `Authorization: Bearer <key>` and add `X-Organization-ID` whenever an organization is resolved. The default base URL is `https://api.modulex.dev`; pass `baseUrl` / `base_url` to target another environment.

<CodeGroup>
  ```bash cURL theme={null}
  # Verify the key — identity endpoint, no org header needed
  curl https://api.modulex.dev/auth/me \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u"
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
          organization_id="a1b2c3d4-e29b-41d4-a716-446655440000",
      ) as client:
          me = await client.auth.me()
          print(me)

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: 'mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u',
    organizationId: 'a1b2c3d4-e29b-41d4-a716-446655440000',
  });

  const me = await client.auth.me();
  console.log(me);
  ```
</CodeGroup>

You can override the organization per call without changing the client default:

<CodeGroup>
  ```python Python theme={null}
  # Per-call org override
  llms = await client.organizations.llms(organization_id="other-org-uuid")
  ```

  ```javascript JavaScript theme={null}
  // Per-call org override
  const llms = await client.organizations.llms({ organizationId: 'other-org-uuid' });
  ```
</CodeGroup>

<Warning>
  **Environment-variable fallback differs between the SDKs.** The Python SDK reads `MODULEX_API_KEY`, `MODULEX_ORGANIZATION_ID`, and `MODULEX_BASE_URL` when the matching argument is omitted. The JavaScript SDK has **no** such fallback — you must pass `apiKey` (and `organizationId`) to the constructor explicitly, or it throws. Any `process.env.*` usage in JS examples is your own caller code, not SDK behavior.
</Warning>

See the [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python) pages for installation and full configuration, and [SDKs overview](/sdks/overview) for how each operation maps across cURL, Python, and JavaScript.

## Error responses

Authentication and organization-scope failures use the standard FastAPI envelope, `{"detail": "<message>"}`.

| Status | When                                                | Example `detail`                                                                                                            |
| ------ | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `401`  | No credential, or invalid/expired key               | `Invalid API key`                                                                                                           |
| `401`  | Neither header present                              | `Authentication required. Provide Authorization: Bearer token or X-API-KEY header.`                                         |
| `400`  | Missing `X-Organization-ID` on an org-scoped route  | `X-Organization-ID header is required`                                                                                      |
| `403`  | Inactive user                                       | `Inactive user`                                                                                                             |
| `403`  | Org-scoped key used against a different org         | `API key is scoped to a different organization`                                                                             |
| `403`  | Not a member of the named organization              | `User is not a member of organization {id}`                                                                                 |
| `403`  | Role too low for the surface (owner/admin required) | `Admin or owner role required in organization {id}` (or `Owner role required in organization {id}` for owner-only surfaces) |

```json 401 example theme={null}
{ "detail": "Invalid API key" }
```

### Rate-limit responses

When you exceed a key's per-minute budget or the across-keys per-user budget, the per-key limiter returns `429` with a string `detail` and rate-limit headers:

```json 429 (per-key / per-user limiter) theme={null}
{ "detail": "API key rate limit exceeded" }
```

| Header                  | Meaning                                      |
| ----------------------- | -------------------------------------------- |
| `X-RateLimit-Limit`     | The limit that applied                       |
| `X-RateLimit-Remaining` | Requests left in the window                  |
| `X-RateLimit-Reset`     | Unix epoch (seconds) when the window resets  |
| `Retry-After`           | Seconds to wait before retrying (default 60) |

The organization-level `api`-class limiter can also return `429`, but with a **dict-valued** `detail`:

```json 429 (org api-class limiter) theme={null}
{ "detail": { "code": "rate_limited", "layer": "rate", "key": "api",
              "current": 300, "limit": 300, "reason": "rate_limit_exceeded" } }
```

A third `429` shape — the flat billing-gate `DenialEnvelope` — appears only on run and managed-usage surfaces (workflow runs, composer, assistant, managed knowledge), never on a CRUD or settings route. The same surfaces can also return `402` and `403` billing denials. See [Usage gating & limits](/billing/usage-gating) for the gate, [Rate limiting](/api-reference/rate-limiting) for all three `429` shapes, and [Errors & status codes](/api-reference/errors) for the complete envelope reference.

## Security notes

* Treat the key like a password. Send it only over HTTPS; never embed it in client-side code, a public repository, or a URL query string.
* Use `X-API-KEY` only if you cannot set `Authorization` — `Authorization: Bearer` is the supported path.
* Scope keys to one organization and set a tight `rate_limit_per_minute` for automation, so a leaked key has limited blast radius.
* Rotate by creating a new key, switching traffic, then revoking the old one. Revocation is immediate.
* The Clerk JWT used by the web app and the `mx_live_` API key used by code are different credentials with different lifecycles. See [Auth model: JWT vs API key](/security/authentication) for which path to use where.

## Next steps

<CardGroup cols={2}>
  <Card title="Quickstart" icon="rocket" href="/get-started/quickstart">
    Get a key and make your first authenticated call.
  </Card>

  <Card title="Org context & X-Organization-ID" icon="building" href="/security/org-context">
    How organization scope is resolved and enforced.
  </Card>

  <Card title="Errors & status codes" icon="circle-exclamation" href="/api-reference/errors">
    Every error envelope and what produces it.
  </Card>

  <Card title="Glossary" icon="book" href="/reference/glossary">
    Canonical terms: API key, Clerk JWT, organization, role.
  </Card>
</CardGroup>
