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

# Organization context

> How ModuleX scopes org-bound requests with the X-Organization-ID header: resolution order, the missing-header 400, per-request overrides in both SDKs, org-scoped API keys, and the Socket.io handshake field.

ModuleX is multi-tenant. Identity and organization are **two separate axes** on every request: your credential says *who you are*, and the `X-Organization-ID` header says *which organization the request runs against*. This page is the reference for how that header is resolved, validated, and enforced — including org-scoped API keys and the realtime handshake equivalent.

For the credentials themselves (API keys versus the Clerk session token), see [Auth model: JWT vs API key](/security/authentication). For the conceptual model of organizations and membership, see [Organizations, roles & membership](/concepts/organizations-roles).

## The two axes

A single credential can belong to a user who is a member of several organizations. The credential never picks the organization for you — you select it per request.

| Axis         | Carried by                                                                     | Answers                                |
| ------------ | ------------------------------------------------------------------------------ | -------------------------------------- |
| Identity     | `Authorization: Bearer …` (API key or Clerk JWT), or the `X-API-KEY` fallback  | Who is making the request              |
| Organization | `X-Organization-ID` (REST / SDK) · `auth.organizationId` (Socket.io handshake) | Which organization the request acts in |

Most resources are organization-scoped: workflows, runs, knowledge bases, credentials, integrations, the AI Composer, the Assistant, schedules, and organization settings. Identity-only endpoints — for example `GET /auth/me` and `GET /auth/me/organizations` — do **not** require the org header.

<Note>
  The header name is `X-Organization-ID` with a capital `ID`. HTTP header lookup is case-insensitive on the wire, but quote this exact casing in code and docs. Response **bodies** use snake\_case (`organization_ids`, `current_organization_id`, `primary_organization_id`).
</Note>

## Sending the header

The header value is the organization's id (a UUID). Add it to any org-scoped request alongside your credential:

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

Find the organizations you belong to — and their ids — with `GET /auth/me/organizations`. The response carries an `organizations` array and a `total` count; use each organization's id as the header value.

<CodeGroup>
  ```bash cURL theme={null}
  # List your organizations, then use one id as X-Organization-ID
  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",
          organization_id="a1b2c3d4-e29b-41d4-a716-446655440000",
      ) as client:
          orgs = await client.auth.organizations()  # identity endpoint, no org header needed
          print(orgs)

  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 orgs = await client.auth.organizations(); // identity endpoint, no org header needed
  console.log(orgs);
  ```
</CodeGroup>

## How the header is resolved and enforced

On every org-scoped route the backend runs the same membership check before your handler executes. It reads the header, validates the credential's org scope, confirms membership, sets the org context on the request, and applies an org-keyed rate limit. The order matters because it determines which error you get.

<Steps>
  <Step title="Read the header">
    The backend reads `X-Organization-ID` from the request. If it is missing, the request is rejected with **`400`** and `detail` `X-Organization-ID header is required` — before any membership or billing logic runs.
  </Step>

  <Step title="Check the user is active">
    An inactive user is rejected with **`403`** `Inactive user`.
  </Step>

  <Step title="Enforce API-key org scope">
    If the credential is an **API key** that was scoped to a specific organization at creation, the header must name that same organization. A mismatch returns **`403`** `API key is scoped to a different organization`. Keys with no scope skip this check.
  </Step>

  <Step title="Confirm membership and resolve the role">
    The backend looks up your role in the named organization. If you are not a member, it returns **`403`** `User is not a member of organization {id}`. On success it sets `current_organization_id` and `current_organization_role` on the request context (plus a back-compat `organization_id` alias).
  </Step>

  <Step title="Apply the org-class rate limit">
    An organization-keyed `api`-class rate limit is checked. It covers both Clerk-JWT and API-key traffic uniformly and fails **open** on any internal error. Exceeding it returns **`429`** — see [Rate limiting](/api-reference/rate-limiting).
  </Step>
</Steps>

### Error responses

All org-context failures use the standard FastAPI envelope, `{"detail": "<message>"}`, except the org rate-limit denial, whose `detail` is a structured object. See [Errors & status codes](/api-reference/errors) for the full envelope reference.

| Status | When                                                 | `detail`                                                            |
| ------ | ---------------------------------------------------- | ------------------------------------------------------------------- |
| `400`  | `X-Organization-ID` missing on an org-scoped route   | `X-Organization-ID header is required`                              |
| `403`  | Inactive user                                        | `Inactive user`                                                     |
| `403`  | Org-scoped key used against a different organization | `API key is scoped to a different organization`                     |
| `403`  | Caller is not a member of the named organization     | `User is not a member of organization {id}`                         |
| `429`  | Org `api`-class rate limit exceeded                  | `{code, layer, key, current, limit, reason}` (dict-valued `detail`) |

```json 400 — missing org header theme={null}
{ "detail": "X-Organization-ID header is required" }
```

```json 403 — not a member theme={null}
{ "detail": "User is not a member of organization a1b2c3d4-e29b-41d4-a716-446655440000" }
```

<Note>
  A missing org header is a **`400`** (the header is required and absent), not a `403` (membership/permission). A `403` means the header was present but you may not act in that organization. Reserve `404` for the resource not existing within the org you named.
</Note>

The flat billing-gate `DenialEnvelope` (`{code, layer, key, current, limit, reason}`, returned as `402` / `403` / `429`) is a **separate** mechanism that applies only to run and managed-usage surfaces — workflow runs, the Composer, the Assistant, and managed knowledge — never to plain CRUD or org-settings routes. Org-context failures above are not billing denials. See [Usage gating & limits](/billing/usage-gating).

## Per-request override

The organization is not pinned to your client. You choose it per request, with a clear precedence: a value passed on the call wins over the client default.

| Level                | JavaScript               | Python                     | Sent as             |
| -------------------- | ------------------------ | -------------------------- | ------------------- |
| Per-request override | `options.organizationId` | `organization_id=` keyword | `X-Organization-ID` |
| Client default       | `config.organizationId`  | `config.organization_id`   | `X-Organization-ID` |
| Neither resolved     | header omitted           | header omitted             | —                   |

Both SDKs resolve the per-request value first and fall back to the client default. When neither is set, the header is **omitted** entirely — which is correct for identity-only endpoints and a `400` on org-scoped ones.

<CodeGroup>
  ```bash cURL theme={null}
  # Override is just a different header value per request
  curl https://api.modulex.dev/workflows \
    -H "Authorization: Bearer mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u" \
    -H "X-Organization-ID: 99999999-1111-2222-3333-444444444444"
  ```

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

  async def main():
      # Client default org
      async with Modulex(
          api_key="mx_live_2J9vK4xM8nP3wQ7tR5sL1yB6cF0dG2h4vN8pX5mK9rT3wY7u",
          organization_id="a1b2c3d4-e29b-41d4-a716-446655440000",
      ) as client:
          # Uses the client default
          default_orgs = await client.organizations.llms()
          # Per-call override — wins over the default, just for this call
          other_orgs = await client.organizations.llms(
              organization_id="99999999-1111-2222-3333-444444444444"
          )
          print(default_orgs, other_orgs)

  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',
  });

  // Uses the client default
  const defaultOrgs = await client.organizations.llms();
  // Per-call override — wins over the default, just for this call
  const otherOrgs = await client.organizations.llms({
    organizationId: '99999999-1111-2222-3333-444444444444',
  });
  console.log(defaultOrgs, otherOrgs);
  ```
</CodeGroup>

<Warning>
  **Environment-variable fallback for the org id differs between the SDKs.** The Python SDK reads `MODULEX_ORGANIZATION_ID` (and `MODULEX_API_KEY` / `MODULEX_BASE_URL`) when the matching argument is omitted. The JavaScript SDK has **no** such fallback — pass `organizationId` to the constructor or per call. Any `process.env.*` in a JS example is your own caller code, not SDK behavior.
</Warning>

## Header org scope vs. body `organization_id`

Two different things share the word "organization", and conflating them is the most common org-context mistake.

<CardGroup cols={2}>
  <Card title="X-Organization-ID (header)" icon="arrows-left-right">
    The organization you are **acting in** for this request. Resolved and enforced per the steps above. Applies to nearly every org-scoped endpoint.
  </Card>

  <Card title="organization_id (request body)" icon="box-archive">
    On a few endpoints (for example, creating an org-scoped API key), a body field that sets which organization **owns** the new resource. It is independent of the header.
  </Card>
</CardGroup>

<Note>
  "Which org owns this new resource" (`organization_id` in the body) is not the same as "which org am I acting in" (`X-Organization-ID` in the header). They can name different organizations on the same request.
</Note>

## Org-scoped API keys

An API key is owned by a user and can optionally be **scoped** to one organization when you create it. Scope is set with the `organization_id` field in the create body and is independent of the `X-Organization-ID` header you send on later requests.

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

```bash Create an org-scoped key theme={null}
curl -X POST https://api.modulex.dev/api-keys \
  -H "Authorization: Bearer $CLERK_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme prod automation",
    "organization_id": "a1b2c3d4-e29b-41d4-a716-446655440000"
  }'
```

How the scope behaves at request time:

| Key scope (`organization_id` at creation) | `X-Organization-ID` on the request | Result                                                |
| ----------------------------------------- | ---------------------------------- | ----------------------------------------------------- |
| `null` (unscoped)                         | Any org you are a member of        | Allowed                                               |
| `null` (unscoped)                         | Omitted, on an org-scoped route    | `400` (header required)                               |
| A specific org                            | The same org                       | Allowed (if you are a member)                         |
| A specific org                            | A different org                    | `403` `API key is scoped to a different organization` |
| A specific org                            | Omitted, on an org-scoped route    | `400` (header still required)                         |

<Warning>
  Scoping a key does **not** make the header optional. Even a single-org key must send `X-Organization-ID` on org-scoped routes; the scope adds an equality check, it does not supply a default. Pair a scoped key with a tight `rate_limit_per_minute` so a leaked automation key has limited blast radius.
</Warning>

The create response returns the scope it was given:

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

For the full key lifecycle — format, creation, limits, and revocation — see [Authentication](/api-reference/authentication).

## Roles within an organization

Membership alone is enough for most reads and writes, but some surfaces require a privileged role. The live organization roles are **`owner`** and **`admin`**.

<Note>
  The `member` role is **retired** and is not a current first-class role. Document and design against `owner` / `admin` only. Legacy `member` rows may still exist and the role name can still appear in some list filters and in the realtime read-gate, but the REST edge rejects it.
</Note>

The AI Composer, the Assistant, and the organization-settings and member-management endpoints require `owner` or `admin`. A membership-only call to one of these returns `403`. See [Roles & permissions](/security/roles-permissions) for the per-endpoint matrix and [Organizations, roles & membership](/concepts/organizations-roles) for the concept.

## Org context in realtime

The Socket.io collaboration server carries organization scope as a **handshake field**, not an HTTP header. The handshake `auth` object must include both `token` (a Clerk JWT) and `organizationId`; either one missing rejects the entire connection.

```javascript Socket.io handshake theme={null}
import { io } from 'socket.io-client';

const socket = io('wss://realtime.modulex.dev', {
  auth: {
    token: '<clerk_jwt>',                                   // identity
    organizationId: 'a1b2c3d4-e29b-41d4-a716-446655440000', // org context
  },
});
// Rejected as a connect_error if either is missing or membership fails:
//   'Authentication required' | 'Organization ID required'
//   | 'Invalid token' | 'Not a member of this organization'
```

On a successful handshake the socket joins the `org:<organizationId>` room and scopes all collaboration traffic to that organization.

<Note>
  Realtime membership is cached server-side with a short TTL, so a role or membership change can take up to a few minutes to take effect on an open socket. The REST surface re-checks membership on every request and has no such lag.
</Note>

See [Socket.io collaboration events](/realtime/socket-events) for the event reference and [Realtime overview & event taxonomy](/realtime/overview) for both realtime planes.

## Practical guidance

* **Always send the header on org-scoped routes.** When in doubt, send it — only identity endpoints (`/auth/me`, `/auth/me/organizations`) are safe without it.
* **Discover ids dynamically.** Resolve organization ids with `GET /auth/me/organizations` rather than hard-coding them, so the code keeps working as a user joins or leaves organizations.
* **Match the key scope to the deployment.** Use an unscoped key for tooling that spans organizations, and a scoped key for automation that should only ever touch one organization.
* **Distinguish the two `organization_id`s.** The header sets the acting org; a body `organization_id` sets resource ownership.
* **Plan for the realtime lag.** When you change someone's membership or role, expect REST to reflect it immediately and open realtime sockets to catch up within a few minutes.

## Next steps

<CardGroup cols={2}>
  <Card title="Organizations, roles & membership" icon="building" href="/concepts/organizations-roles">
    The conceptual model behind org context.
  </Card>

  <Card title="Authentication" icon="key" href="/api-reference/authentication">
    Credentials, key format, and the full org-scope flow.
  </Card>

  <Card title="Roles & permissions" icon="user-shield" href="/security/roles-permissions">
    Which actions require owner or admin.
  </Card>

  <Card title="Auth model: JWT vs API key" icon="id-card" href="/security/authentication">
    How the two credential paths differ.
  </Card>
</CardGroup>
