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

# Quickstart: your first authenticated API call

> Create an organization, mint a mx_live_ API key, and make your first authenticated ModuleX call with Authorization: Bearer and X-Organization-ID — in cURL, Python, and JavaScript.

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 guide takes you from a fresh account to your first authenticated request. You will create an [organization](/concepts/organizations-roles), mint a `mx_live_` [API key](/api-reference/authentication), and call the API three ways — with cURL, the [Python SDK](/sdks/python), and the [JavaScript SDK](/sdks/javascript).

Every ModuleX request authenticates with two pieces:

* **`Authorization: Bearer mx_live_…`** — your API key, sent as a Bearer token.
* **`X-Organization-ID: <org-id>`** — the organization the request acts in, required on every organization-scoped endpoint.

<Warning>
  The auth header is **`Authorization: Bearer`**, not `X-Authorization`. ModuleX does not recognize an `X-Authorization` header. If you are migrating from older notes or third-party snippets that use it, switch to `Authorization: Bearer`. The backend also accepts the key in an `X-API-KEY` header as an alternative, but the official SDKs always send `Authorization: Bearer`.
</Warning>

## Prerequisites

<Steps>
  <Step title="Sign in and pick an organization">
    Sign in to the ModuleX app. On first sign-in, a personal organization is provisioned for you automatically, so you always have at least one organization to act in. You can create more from the organization switcher. Every API call is scoped to exactly one organization — see [Organizations, roles & membership](/concepts/organizations-roles).
  </Step>

  <Step title="Confirm your role">
    API keys are created by a signed-in user, and they inherit that user's access. The live organization roles are **`owner`** and **`admin`** (the `member` role has been retired). You do not need to be an owner to create a personal API key, but the actions a key can perform are still gated by your role and by [usage limits](/billing/usage-gating). See [Roles & permissions](/security/roles-permissions).
  </Step>

  <Step title="Have a runtime ready">
    For the SDK examples you need Node.js 18+ (for the JavaScript SDK) or Python 3.9+ (for the async Python SDK). For the cURL examples you only need a terminal.
  </Step>
</Steps>

## Step 1 — Create an API key

API keys are managed in the app, under your account's API keys settings. Creating a key is the only time the full secret is shown — copy it immediately and store it somewhere safe. ModuleX stores only a salted hash and a short hint (for example `mx_live_2J9vK4xM********`), so a lost key cannot be recovered and must be replaced.

<MediaEmbed id="MX-MEDIA-1030" type="screenshot" caption={"The API keys settings page in the ModuleX app, mid-creation, showing the create-key dialog and a freshly generated key that is masked except for its hint."} />

When you create a key you can set the following fields.

<ParamField path="name" type="string" required>
  A human-readable label for the key (1–255 characters), for example `CI/CD` or `Production worker`. Shown in the key list to help you identify it later.
</ParamField>

<ParamField path="organization_id" type="string" default="null">
  Optional. Scopes the key to a single organization that you are a member of. When set, the key can only be used with a matching `X-Organization-ID` header — any other organization returns `403`. When omitted (`null`), the key works across all organizations you belong to, and you choose the organization per request via the header.
</ParamField>

<ParamField path="expires_at" type="string (ISO 8601)" default="null">
  Optional expiry timestamp. After this time the key is rejected with `401`. Omit (`null`) for a key that never expires.
</ParamField>

<ParamField path="rate_limit_per_minute" type="integer" default={60}>
  Optional per-key request limit (1–1000). Defaults to `60` requests per minute. A separate per-user limit of `300` requests per minute applies across all of your keys combined.
</ParamField>

The create response is the **only** place the full key appears.

<ResponseField name="id" type="string">
  The key's unique identifier (UUID). Use it to fetch or revoke the key later.
</ResponseField>

<ResponseField name="name" type="string">
  The label you provided.
</ResponseField>

<ResponseField name="key" type="string">
  The full secret, for example `mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u`. Returned **once**, only on creation. Every other endpoint returns the masked form instead.
</ResponseField>

<ResponseField name="key_hint" type="string">
  The first 8 characters of the random part, used to recognize the key without exposing it.
</ResponseField>

<ResponseField name="masked_key" type="string">
  The display form, for example `mx_live_2J9vK4xM********`.
</ResponseField>

<ResponseField name="organization_id" type="string | null">
  The organization the key is scoped to, or `null` for an unscoped key.
</ResponseField>

<ResponseField name="rate_limit_per_minute" type="integer">
  The per-key limit applied to this key.
</ResponseField>

<ResponseField name="expires_at" type="string | null">
  The expiry timestamp, or `null` if the key never expires.
</ResponseField>

<ResponseField name="is_active" type="boolean">
  Whether the key is currently usable. Becomes `false` after revocation.
</ResponseField>

<ResponseField name="created_at" type="string">
  When the key was created (ISO 8601).
</ResponseField>

<Tip>
  A `mx_live_` key is `mx_live_` followed by roughly 43 random Base62 characters. Treat it like a password: never commit it to source control or paste it into client-side code. Read it from an environment variable or a secrets manager instead.
</Tip>

## Step 2 — Find your organization ID

Every organization-scoped request needs the `X-Organization-ID` header. You can read your organizations from `GET /auth/me/organizations`, which lists each organization's `id`, `slug`, `name`, and your `role` in it. The `id` value is what goes in the header.

`GET /auth/me/organizations` is **not** organization-scoped, so it does not itself require the `X-Organization-ID` header — it returns every organization you belong to.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/auth/me/organizations \
    -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") as client:
          result = await client.auth.organizations()
          for org in result["organizations"]:
              print(org["id"], org["role"], org["name"])

  asyncio.run(main())
  ```

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

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

  const { organizations } = await client.auth.organizations();
  for (const org of organizations) {
    console.log(org.id, org.role, org.name);
  }
  ```
</CodeGroup>

A successful response looks like this:

```json theme={null}
{
  "success": true,
  "user_id": "550e8400-e29b-41d4-a716-446655440000",
  "organizations": [
    {
      "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      "slug": "acme",
      "name": "ACME",
      "domain": "acme.modulex.dev",
      "role": "owner",
      "joined_at": "2026-01-01T10:00:00",
      "is_default": false
    }
  ],
  "total": 1
}
```

Copy the `id` of the organization you want to work in. You will pass it as `X-Organization-ID` on every organization-scoped call.

## Step 3 — Make your first authenticated call

With your key and organization ID in hand, call an organization-scoped endpoint. `GET /organizations/llms` returns the language-model catalog for your organization, including which models have credentials connected — a good first call that exercises both the `Authorization` and `X-Organization-ID` headers.

In the SDKs you set the organization once on the client (it is sent as `X-Organization-ID` automatically) and override it per call when you need a different organization.

<CodeGroup>
  ```bash cURL theme={null}
  curl https://api.modulex.dev/organizations/llms \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
    -H "X-Organization-ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  ```

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

  async def main():
      async with Modulex(
          api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
          organization_id="a1b2c3d4-e5f6-7890-abcd-ef1234567890",
      ) as client:
          catalog = await client.organizations.llms()
          print(catalog["active_llm_total"], "models with credentials")

  asyncio.run(main())
  ```

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

  const client = new Modulex({
    apiKey: 'mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u',
    organizationId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
  });

  const catalog = await client.organizations.llms();
  console.log(catalog.active_llm_total, 'models with credentials');
  ```
</CodeGroup>

A `200` response confirms your credentials work. If you get an error instead, jump to [Troubleshooting](#troubleshooting) below.

<Tip>
  `GET /organizations/llms` requires the `admin` or `owner` role. If your role is not admin or owner you will get a `403` — use `GET /auth/me` (which needs no role and no organization header) to confirm authentication on its own first.
</Tip>

## Install the SDKs

The SDK examples above assume you have the relevant package installed.

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

  ```bash JavaScript (npm) theme={null}
  npm install modulex-js
  ```

  ```bash JavaScript (pnpm) theme={null}
  pnpm add modulex-js
  ```
</CodeGroup>

Both SDKs are thin, typed clients over the same REST API — every method maps to one HTTP call. The base URL defaults to `https://api.modulex.dev` and has **no version prefix**: do not append `/api` or `/v1`. See [Base URLs, environments & versioning](/get-started/environments).

### Client configuration

The two SDKs take the same options under language-idiomatic names (camelCase in JavaScript, snake\_case in Python).

| Option (JS / Python)                 | Type            | Required | Default                             | Notes                                                                                              |
| ------------------------------------ | --------------- | -------- | ----------------------------------- | -------------------------------------------------------------------------------------------------- |
| `apiKey` / `api_key`                 | string          | yes      | —                                   | Your `mx_live_` key, sent as `Authorization: Bearer`. Missing → the client throws on construction. |
| `organizationId` / `organization_id` | string          | no       | none                                | Default `X-Organization-ID` for every request; overridable per call.                               |
| `baseUrl` / `base_url`               | string          | no       | `https://api.modulex.dev`           | API root, no version prefix.                                                                       |
| `timeout`                            | number (ms / s) | no       | `30000` ms (JS) / `30.0` s (Python) | Per-request timeout.                                                                               |
| `maxRetries` / `max_retries`         | number          | no       | `3`                                 | Retries for transient errors (`429`, `500`, `502`, `503`) and network errors.                      |

<Note>
  **Environment-variable fallback differs by SDK.** The **Python** SDK reads `MODULEX_API_KEY`, `MODULEX_ORGANIZATION_ID`, and `MODULEX_BASE_URL` when the matching argument is omitted, so you can construct `Modulex()` with no arguments if those are set. The **JavaScript** SDK has **no** environment-variable fallback — you must pass `apiKey` explicitly; reading `process.env.MODULEX_API_KEY` yourself is just your own code, not SDK behavior. Full details: [JavaScript SDK](/sdks/javascript) and [Python SDK](/sdks/python).
</Note>

Reading the key from the environment looks like this:

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

  async def main():
      # Picks up MODULEX_API_KEY and MODULEX_ORGANIZATION_ID automatically.
      async with Modulex() as client:
          me = await client.auth.me()
          print(me["email"])

      # Or pass them explicitly:
      async with Modulex(
          api_key=os.environ["MODULEX_API_KEY"],
          organization_id=os.environ["MODULEX_ORGANIZATION_ID"],
      ) as client:
          me = await client.auth.me()
          print(me["email"])

  asyncio.run(main())
  ```

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

  // The JS SDK has no env fallback — read process.env yourself and pass it in.
  const client = new Modulex({
    apiKey: process.env.MODULEX_API_KEY,
    organizationId: process.env.MODULEX_ORGANIZATION_ID,
  });

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

<Note>
  Response fields stay **snake\_case** on the wire (`active_llm_total`, `created_at`, `organization_ids`). The JavaScript SDK converts your request keys from camelCase to snake\_case automatically, but it does **not** convert responses back — read response fields in snake\_case in both SDKs.
</Note>

## How authentication is evaluated

Understanding the order the backend checks credentials helps you read errors correctly.

<Steps>
  <Step title="Credential resolution">
    The backend reads `Authorization: Bearer <token>` first. If the token starts with `mx_live_` it is treated as an API key; otherwise it is treated as a Clerk JWT (the app's user-login path). If no Bearer credential resolves, it falls back to the `X-API-KEY` header (which must also start with `mx_live_`). With neither present, the request is rejected with `401`.
  </Step>

  <Step title="Key validation and rate limits">
    The key is looked up by its hash and must be active and unexpired. Each request consumes from the key's per-minute limit (default `60`) and your per-user limit (`300` across all keys). Exceeding either returns `429` with `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, and `Retry-After` headers.
  </Step>

  <Step title="Organization scope">
    For organization-scoped endpoints, the backend reads `X-Organization-ID`. A missing header returns `400`. If your key is scoped to a specific organization, the header must match that organization or you get `403`. If you are not a member of the organization in the header, you get `403`.
  </Step>
</Steps>

For the full authentication reference — including the Clerk JWT path used inside the app — see [Authentication](/api-reference/authentication) and the [JWT vs API key auth model](/security/authentication).

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 — Authentication required / Invalid API key">
    No valid credential was found, or the key is wrong, revoked, or expired. Confirm you are sending `Authorization: Bearer mx_live_…` (not `X-Authorization`), that the key is copied in full, and that it has not been revoked or passed its `expires_at`. Test the key in isolation with `GET /auth/me`, which needs no organization header.
  </Accordion>

  <Accordion title="400 — X-Organization-ID header is required">
    You called an organization-scoped endpoint without the `X-Organization-ID` header. Add it with an organization ID from `GET /auth/me/organizations`. In the SDKs, set `organizationId` / `organization_id` on the client or pass it per call.
  </Accordion>

  <Accordion title="403 — Not a member, wrong scope, or insufficient role">
    Three causes share this status: the organization in `X-Organization-ID` is not one you belong to; your key is scoped to a different organization than the header; or the endpoint requires the `owner` or `admin` role and yours is lower. Verify your membership and role with `GET /auth/me/organizations`.
  </Accordion>

  <Accordion title="429 — Rate limited">
    You exceeded the per-key (default `60`/min) or per-user (`300`/min) limit. Read the `Retry-After` header and back off; both SDKs retry `429` automatically up to `maxRetries` / `max_retries`, honoring `Retry-After`. See [Rate limiting](/api-reference/rate-limiting).
  </Accordion>

  <Accordion title="402 / 403 / 429 with a flat {code, layer, …} body">
    On run, Composer, Assistant, and managed-knowledge surfaces, a usage denial returns a flat envelope `{code, layer, key, current, limit, reason}` (not wrapped in `detail`). The `layer` maps to the status: `credit`/`wallet` → `402`, `quota` → `403`, `rate` → `429`. This is distinct from the plain `{"detail": "…"}` shape on standard CRUD routes. See [Errors & status codes](/api-reference/errors) and [Usage gating & limits](/billing/usage-gating).
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Make your first API call" icon="terminal" href="/get-started/first-api-call">
    Run a workflow programmatically and stream its result in cURL, Python, and JavaScript.
  </Card>

  <Card title="Authentication reference" icon="key" href="/api-reference/authentication">
    The complete header, token, and error reference for authenticating every request.
  </Card>

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

  <Card title="SDKs overview" icon="code" href="/sdks/overview">
    Install and configure the JavaScript and Python SDKs once, then use them everywhere.
  </Card>
</CardGroup>
