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

# Managing credentials

> Create, connect, test, rotate, and revoke ModuleX integration credentials in the app and via the API. Credentials are organization-scoped, encrypted at rest, and require an owner or admin role.

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

A **credential** is a stored, encrypted auth record that links your organization to an integration (a tool, an LLM provider, or a knowledge provider). Each credential has a `credential_id`, an `auth_type`, an owning organization, and an optional `display_name`. This page covers the full lifecycle: create and connect, inspect and test, set a default, rotate, and revoke — both in the app and via the API.

For the auth-type variants themselves (API key, OAuth2 with PKCE, bearer, and the other schemas) and the OAuth2 connect flow in depth, see [Authentication & credentials](/integrations/authentication) and [Credentials & OAuth2](/concepts/credentials-oauth). For request lifecycle and base URLs, see the [API overview](/api-reference/overview).

<Note>
  Credentials are **organization-scoped**. The organization is always taken from the `X-Organization-ID` header (and the authenticated caller's active org) — never from the request body. A credential created under one organization is invisible to every other organization. See [Org context & X-Organization-ID](/security/org-context).
</Note>

## Who can manage credentials

Every credential endpoint requires an **owner** or **admin** role on the organization (the backend dependency is `organization_admin_required`). The `member` role is retired and is not a current first-class role. See [Roles & permissions](/security/roles-permissions).

| Outcome                     | HTTP status | When                                                                           |
| --------------------------- | ----------- | ------------------------------------------------------------------------------ |
| Missing `X-Organization-ID` | `400`       | No org context on the request                                                  |
| Not a member of the org     | `403`       | Caller is not in the organization                                              |
| Member but not owner/admin  | `403`       | Role check fails                                                               |
| Inactive user               | `403`       | The user account is not active                                                 |
| Rate limited                | `429`       | The org-class `api` rate limit, with `X-RateLimit-*` and `Retry-After` headers |

<Note>
  The credential CRUD and OAuth endpoints have **no per-call credit gate** — they return the standard `{"detail": ...}` error envelope, not the flat `DenialEnvelope`. The credit gate runs later, at execution time, only for ModuleX-managed (`modulex_key`) credentials. There is no `402` on these routes. For the gated surfaces and the `DenialEnvelope` shape, see [Errors & status codes](/api-reference/errors) and [Usage gating & limits](/billing/usage-gating).
</Note>

## Authenticate every request

All examples use the same auth as the rest of the API: an `Authorization: Bearer` token (a `mx_live_*` API key, or a Clerk JWT from the app) plus the `X-Organization-ID` header. The SDKs send both for you once configured. See [Authentication](/api-reference/authentication).

<CodeGroup>
  ```bash cURL theme={null}
  curl -s https://api.modulex.dev/credentials \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  # Reads MODULEX_API_KEY / MODULEX_ORGANIZATION_ID from the environment,
  # or pass them explicitly:
  async with Modulex(
      api_key="mx_live_your_api_key",
      organization_id="9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60",
  ) as client:
      grouped = await client.credentials.list()
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_your_api_key",
    organizationId: "9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60",
  });

  const grouped = await client.credentials.list();
  ```
</CodeGroup>

## Create and connect a credential

### In the app

<Steps>
  1. Open **Settings → Credentials** (or **Browse** to start from the integration catalog), then choose the integration you want to connect.
  2. Pick the auth type the integration supports — `oauth2`, `api_key`, `bearer_token`, `modulex_key`, or a `custom` schema with fields from the integration's manifest.
  3. For API key, bearer, and custom types, fill in the secret fields and select **Test** to validate before saving (see [Test a credential](#test-a-credential)). For OAuth2 you are redirected to the provider's consent screen, then back to ModuleX.
  4. Optionally set a **display name** and toggle **Make default** so this credential is used automatically for the integration.
  5. Save. The secret is encrypted at rest and only a masked form is ever shown again.
</Steps>

<MediaEmbed id="MX-MEDIA-4030" type="app_video" caption={"Connecting an integration credential from Settings → Credentials, including the test-before-save step and the OAuth2 redirect."} />

### Via the API

`POST /credentials` creates a credential. The body is read raw and the credential **type is auto-detected** from `auth_data` / `auth_type` / `integration_name` — you do not send a `type` field. Returns `201 Created` with a `CredentialResponse`.

<ParamField body="integration_name" type="string" required>
  The integration to attach the credential to (for example `slack`, `openai`, `github`). Must be a live integration. For an external MCP server, prefer the dedicated MCP create path described in [Authentication & credentials](/integrations/authentication).
</ParamField>

<ParamField body="auth_data" type="object">
  The secret payload, encrypted at rest. The presence of specific keys selects the credential type:

  <Expandable title="auth_data shapes by detected type">
    * `auth_data.api_key` present → `api_key` credential.
    * `auth_data.token` or `auth_data.bearer_token` present → `bearer_token` credential.
    * `auth_data.access_token` present **and** a top-level `oauth_config` present → `oauth2` credential.
    * For `auth_type: "custom"`, `auth_data` holds the custom fields declared by the integration's manifest (must be non-empty).
    * If none of the above match, the request fails with `400` (`Invalid auth_data or auth_type ...`).
  </Expandable>
</ParamField>

<ParamField body="auth_type" type="string">
  Explicit auth type. Required for `modulex_key` (ModuleX-managed pooled key — send no `auth_data`) and `custom`. For `api_key`, `bearer_token`, and `oauth2`, the type is inferred from `auth_data` and you may omit it. Accepted values: `oauth2`, `api_key`, `bearer_token`, `modulex_key`, `custom`. (The backend also tolerates a legacy `bearer`.)
</ParamField>

<ParamField body="oauth_config" type="object">
  OAuth2 client configuration (`client_id`, `client_secret`, `token_url`, and related fields) sent **only** when creating an `oauth2` credential directly with an `access_token`. Most OAuth2 credentials are created by the connect flow on the [Authentication & credentials](/integrations/authentication) page rather than this field.
</ParamField>

<ParamField body="display_name" type="string">
  A human-friendly label, for example `Production Slack`. Defaults to a generated name.
</ParamField>

<ParamField body="metadata" type="object">
  Optional free-form metadata stored alongside the credential.
</ParamField>

<ParamField body="make_default" type="boolean" default="false">
  When `true`, this credential becomes the default for its integration, unsetting any prior default.
</ParamField>

<ParamField body="expires_at" type="string">
  Optional ISO-8601 expiry timestamp (`SDK only` — accepted by the SDK create methods).
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60" \
    -H "Content-Type: application/json" \
    -d '{
      "integration_name": "slack",
      "display_name": "Production Slack",
      "make_default": true,
      "auth_data": { "api_key": "xoxb-your-slack-token" }
    }'
  ```

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

  async with Modulex() as client:
      credential = await client.credentials.create(
          "slack",
          auth_data={"api_key": "xoxb-your-slack-token"},
          display_name="Production Slack",
          make_default=True,
      )
      print(credential.credential_id)
  ```

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

  const client = new Modulex();

  const credential = await client.credentials.create({
    integration_name: "slack",
    auth_data: { api_key: "xoxb-your-slack-token" },
    display_name: "Production Slack",
    make_default: true,
  });
  console.log(credential.credential_id);
  ```
</CodeGroup>

<ResponseField name="credential_id" type="string">
  The unique identifier for the credential. Use it for every subsequent operation.
</ResponseField>

<ResponseField name="integration_name" type="string">
  The integration this credential belongs to.
</ResponseField>

<ResponseField name="integration_type" type="string">
  One of `tool`, `llm_provider`, `knowledge_provider`.
</ResponseField>

<ResponseField name="display_name" type="string">
  The credential's label.
</ResponseField>

<ResponseField name="auth_type" type="string">
  The detected auth type: `oauth2`, `api_key`, `bearer_token`, `modulex_key`, or `custom`.
</ResponseField>

<ResponseField name="is_default" type="boolean">
  Whether this credential is the default for its integration.
</ResponseField>

<ResponseField name="created_at" type="string">
  ISO-8601 creation timestamp.
</ResponseField>

<ResponseField name="updated_at" type="string">
  ISO-8601 last-update timestamp.
</ResponseField>

<ResponseField name="last_used_at" type="string">
  ISO-8601 timestamp of last use, or `null` if never used.
</ResponseField>

<ResponseField name="expires_at" type="string">
  ISO-8601 expiry, or `null` if the credential does not expire.
</ResponseField>

**Errors:** `400` validation (`CredentialValidationError` / `CredentialServiceError`, including invalid `auth_data`), `401` / `403` auth, `429` rate limit, `500` on unexpected failure.

## List and inspect credentials

### List

`GET /credentials` returns credentials grouped by integration. Supplying `integration_name` switches to a flat list for that one integration.

<ParamField query="integration_name" type="string">
  Filter to a single integration and return a flat list instead of the grouped shape.
</ParamField>

<ParamField query="auth_type" type="string">
  Filter by auth type (`oauth2`, `api_key`, `bearer_token`, `modulex_key`, `custom`).
</ParamField>

<ParamField query="limit" type="integer" default="100">
  Page size. Range `1`–`500`.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Number of items to skip. Minimum `0`.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -s "https://api.modulex.dev/credentials?auth_type=oauth2&limit=50" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      # Grouped by integration
      grouped = await client.credentials.list()
      # Flat list for one integration
      slack_creds = await client.credentials.list(integration_name="slack")
  ```

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

  const client = new Modulex();

  // Grouped by integration
  const grouped = await client.credentials.list();
  // Flat list for one integration
  const slackCreds = await client.credentials.list({ integrationName: "slack" });
  ```
</CodeGroup>

The grouped response keys each integration by name; each group lists its credentials with the per-credential fields above plus the integration `logo`, `total_count`, and the set of `auth_types` present:

```json Grouped response theme={null}
{
  "integrations": {
    "github": {
      "integration_name": "github",
      "integration_type": "tool",
      "logo": "https://.../github.svg",
      "credentials": [
        {
          "credential_id": "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
          "integration_name": "github",
          "integration_type": "tool",
          "display_name": "Production GitHub",
          "auth_type": "oauth2",
          "is_default": true,
          "created_at": "2026-01-19T12:00:00",
          "updated_at": "2026-01-19T12:00:00",
          "last_used_at": null,
          "expires_at": "2026-02-19T12:00:00",
          "credentials_metadata": null
        }
      ],
      "total_count": 1,
      "auth_types": ["oauth2"]
    }
  },
  "total_credentials": 1,
  "total_integrations": 1,
  "filters": { "auth_type": null }
}
```

<Note>
  Response fields are **snake\_case** on the wire (for example `credentials_metadata`, `auth_type`, `display_name`). The SDKs convert to their own conventions (`integrationName` in JS, snake\_case in Python).
</Note>

### Get one credential

`GET /credentials/{credential_id}` returns one credential with masked secrets. Pass `include_masked=true` to also receive a per-field map of masked values. The secret itself is never returned — only labels and masked fragments (for example `xoxb***-end`).

<ParamField query="include_masked" type="boolean" default="false">
  When `true`, add a dict of per-field masked auth values to the response.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -s "https://api.modulex.dev/credentials/b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      detail = await client.credentials.get(
          "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
          include_masked=True,
      )
  ```

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

  const client = new Modulex();

  const detail = await client.credentials.get(
    "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
    { includeMasked: true },
  );
  ```
</CodeGroup>

The detail response adds `organization_id`, `created_by`, `created_by_email`, and `auth_data_masked` (a label for OAuth2/managed keys, or a masked secret for API-key and bearer credentials).

**Errors:** `404` not found, `403` access denied, `400` service error.

## Test a credential

ModuleX can validate a credential against the integration's declared test endpoint.

* `POST /credentials/test-temporary` validates an unsaved credential **before** you store it (used by the test-before-save step in the app). Body: `integration_name`, `auth_type` (`api_key` / `bearer_token` / `oauth2`), `auth_data`.
* `POST /credentials/{credential_id}/test` validates a credential you have already saved. A credential past its `expires_at` returns `is_valid: false` with `Credential has expired`.

If the integration declares no test endpoint, the test returns `is_valid: true` with a `test_method` of `none` or `basic` and a "no test endpoint" message.

<CodeGroup>
  ```bash cURL theme={null}
  # Validate before saving
  curl -X POST https://api.modulex.dev/credentials/test-temporary \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60" \
    -H "Content-Type: application/json" \
    -d '{
      "integration_name": "tavily",
      "auth_type": "api_key",
      "auth_data": { "api_key": "tvly-your-key" }
    }'

  # Test an existing credential
  curl -X POST "https://api.modulex.dev/credentials/b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef/test" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      # Validate before saving
      temp = await client.credentials.test_temporary(
          integration_name="tavily",
          auth_type="api_key",
          auth_data={"api_key": "tvly-your-key"},
      )
      # Test an existing credential
      result = await client.credentials.test("b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef")
      print(result.is_valid)
  ```

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

  const client = new Modulex();

  // Validate before saving
  const temp = await client.credentials.testTemporary({
    integration_name: "tavily",
    auth_type: "api_key",
    auth_data: { api_key: "tvly-your-key" },
  });
  // Test an existing credential
  const result = await client.credentials.test("b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef");
  console.log(result.is_valid);
  ```
</CodeGroup>

```json test-temporary response theme={null}
{
  "is_valid": true,
  "message": "Credential is valid for tavily (test cost: minimal)",
  "tested_at": "2026-01-19T12:00:00",
  "test_method": "api_call",
  "integration_name": "tavily",
  "auth_type": "api_key",
  "test_endpoint": "https://api.tavily.com/search",
  "status_code": 200,
  "cost_level": "minimal"
}
```

<ResponseField name="is_valid" type="boolean">
  Whether the credential passed validation.
</ResponseField>

<ResponseField name="message" type="string">
  Human-readable result detail.
</ResponseField>

<ResponseField name="test_method" type="string">
  How validation ran: `api_call`, `basic`, or `none`.
</ResponseField>

<ResponseField name="tested_at" type="string">
  ISO-8601 timestamp of the test.
</ResponseField>

**Errors:** `test-temporary` wraps any failure as `500` (`Failed to test credential: ...`). The saved-credential `test` returns `404` / `403` for missing or forbidden credentials.

## Set a default credential

When an integration has more than one credential, ModuleX resolves which one to use in this precedence order: an explicitly requested `credential_id`, then the credential marked default, then the most recent valid **user** credential, then the most recent valid **ModuleX-managed** (`modulex_key`) credential. `POST /credentials/{credential_id}/set-default` marks a credential as the default and unsets any prior default for the same integration.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.modulex.dev/credentials/b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef/set-default" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      updated = await client.credentials.set_default(
          "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
      )
  ```

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

  const client = new Modulex();

  const updated = await client.credentials.setDefault(
    "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
  );
  ```
</CodeGroup>

You can also update a credential's label or metadata in place with `PUT /credentials/{credential_id}` (body: `display_name?`, `metadata?` — secrets are not updatable here; to change a secret, rotate).

## Rotate a credential

There is **no dedicated rotate endpoint**. Rotation in ModuleX is a deliberate three-step pattern: create the replacement, promote it to default, then revoke the old one. This keeps the integration usable throughout — the new credential is in place and default before the old secret is removed.

<Steps>
  1. **Create** a new credential for the same integration with the new secret (`POST /credentials`).
  2. **Promote** it with `make_default: true` on create, or `POST /credentials/{new_id}/set-default` afterward, so resolution prefers it immediately.
  3. **Revoke** the old credential with `DELETE /credentials/{old_id}` once the new one is verified.
</Steps>

<CodeGroup>
  ```bash cURL theme={null}
  # 1. Create the replacement as the new default
  NEW_ID=$(curl -s -X POST https://api.modulex.dev/credentials \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60" \
    -H "Content-Type: application/json" \
    -d '{"integration_name":"slack","display_name":"Production Slack (rotated)","make_default":true,"auth_data":{"api_key":"xoxb-new-token"}}' \
    | python3 -c "import sys,json;print(json.load(sys.stdin)['credential_id'])")

  # 2. (Optional) verify the new credential
  curl -X POST "https://api.modulex.dev/credentials/$NEW_ID/test" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"

  # 3. Revoke the old credential
  curl -X DELETE "https://api.modulex.dev/credentials/OLD_CREDENTIAL_ID" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      # 1. Create the replacement as the new default
      new = await client.credentials.create(
          "slack",
          auth_data={"api_key": "xoxb-new-token"},
          display_name="Production Slack (rotated)",
          make_default=True,
      )
      # 2. Verify it
      check = await client.credentials.test(new.credential_id)
      # 3. Revoke the old credential once verified
      if check.is_valid:
          await client.credentials.delete("OLD_CREDENTIAL_ID")
  ```

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

  const client = new Modulex();

  // 1. Create the replacement as the new default
  const fresh = await client.credentials.create({
    integration_name: "slack",
    auth_data: { api_key: "xoxb-new-token" },
    display_name: "Production Slack (rotated)",
    make_default: true,
  });
  // 2. Verify it
  const check = await client.credentials.test(fresh.credential_id);
  // 3. Revoke the old credential once verified
  if (check.is_valid) {
    await client.credentials.delete("OLD_CREDENTIAL_ID");
  }
  ```
</CodeGroup>

<Warning>
  The manual OAuth2 token-refresh endpoint (`POST /credentials/{credential_id}/oauth2/refresh`) and the app's `refreshOAuth2` action are **known to be broken** and must not be relied on to rotate or refresh OAuth2 credentials. To refresh an expired OAuth2 connection, **reconnect** the integration through the OAuth connect flow (which creates a fresh credential), then revoke the stale one. ModuleX also refreshes OAuth2 tokens automatically at execution time when a token is within 5 minutes of expiry, so most refresh happens without any manual step. See [Known limitations](/reference/known-limitations).
</Warning>

## Revoke a credential

Revoking deletes the credential permanently. `DELETE /credentials/{credential_id}` returns `204 No Content`; in the app, open the credential's detail panel and choose **Delete**.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X DELETE "https://api.modulex.dev/credentials/b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      await client.credentials.delete("b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef")
  ```

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

  const client = new Modulex();

  await client.credentials.delete("b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef");
  ```
</CodeGroup>

<Warning>
  Deletion is **permanent** — there is no soft-delete or undo. If the deleted credential was the default for its integration, any node or agent that relied on default resolution will fall through to the next valid credential, or fail with no credential found if none remains. Rotate (create the replacement first) instead of deleting in place for production integrations.
</Warning>

**Errors:** `404` not found, `403` access denied, `400` service error.

## Per-organization scope and isolation

Every credential belongs to exactly one organization, and the active organization is fixed by the `X-Organization-ID` header on each request — it is never read from the body. The practical consequences:

* **No cross-org access.** A credential created in one organization cannot be listed, fetched, tested, or used from another. Switching organizations changes the credential set entirely.
* **Encryption is org-scoped.** Each credential's secret is encrypted with a key derived from the organization ID and the credential ID, so ciphertext cannot be reused across credentials or organizations. See [Data security & encryption](/security/data-encryption).
* **Owner/admin only.** Because credentials are organization-wide, only owners and admins can create, change, or revoke them. See [Roles & permissions](/security/roles-permissions).
* **Resolution stays in-org.** When a tool or LLM node runs, ModuleX resolves the credential within the same organization using the precedence in [Set a default credential](#set-a-default-credential).

<MediaEmbed id="MX-MEDIA-4031" type="image" caption={"Diagram showing two organizations, each with its own isolated set of integration credentials, with the `X-Organization-ID` header selecting the active org's credential scope."} />

## Auditing credential changes

`GET /credentials/{credential_id}/audit` returns the change history for a credential from the unified audit log. Logged operations include `CREDENTIAL_CREATED`, `CREDENTIAL_UPDATED`, `CREDENTIAL_DELETED`, `CREDENTIAL_ROTATED`, `CREDENTIAL_REVOKED`, `CREDENTIAL_ACTIVATED`, `CREDENTIAL_DEACTIVATED`, and `MCP_DISCOVERY_REFRESHED`.

<ParamField query="limit" type="integer" default="100">
  Page size. Range `1`–`500`.
</ParamField>

<ParamField query="offset" type="integer" default="0">
  Number of items to skip. Minimum `0`.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -s "https://api.modulex.dev/credentials/b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef/audit?limit=50" \
    -H "Authorization: Bearer mx_live_your_api_key" \
    -H "X-Organization-ID: 9a1f0c7e-2b44-4f1a-9c3d-7e5b2a1d8f60"
  ```

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

  async with Modulex() as client:
      logs = await client.credentials.audit(
          "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
          limit=50,
      )
  ```

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

  const client = new Modulex();

  const logs = await client.credentials.audit(
    "b3f1c2d4-aa11-4e55-8c90-12ab34cd56ef",
    { limit: 50 },
  );
  ```
</CodeGroup>

<Note>
  A usage-statistics endpoint (`GET /credentials/{credential_id}/usage`) and the audit response are documented in the API reference, but both have **known field-shape issues** today — treat their exact response fields as `TBD` until verified against a live response. See [Known limitations](/reference/known-limitations).
</Note>

## Errors

Credential endpoints return the standard `{"detail": <string>}` envelope (the dict form only for the rate-limit `429`). There is no `DenialEnvelope` and no `402` on these routes.

| Status | Meaning                                                                                     |
| ------ | ------------------------------------------------------------------------------------------- |
| `200`  | Read, update, set-default, or test succeeded                                                |
| `201`  | Credential created                                                                          |
| `204`  | Credential deleted (revoked)                                                                |
| `400`  | Missing `X-Organization-ID`, invalid `auth_data` / `auth_type`, or service validation error |
| `401`  | Missing or invalid auth token                                                               |
| `403`  | Not a member, or not owner/admin                                                            |
| `404`  | Credential not found in this organization                                                   |
| `429`  | Org-class API rate limit; see `X-RateLimit-*` and `Retry-After` headers                     |
| `500`  | Unexpected error (and the `test-temporary` failure wrapper)                                 |

For the full error model across all surfaces — including the three error-envelope shapes and the billing `DenialEnvelope` on gated endpoints — see [Errors & status codes](/api-reference/errors).

## Related pages

<CardGroup cols={2}>
  <Card title="API overview" icon="book-open" href="/api-reference/overview">
    Request lifecycle, base URLs, and how every operation is shown three ways.
  </Card>

  <Card title="Authentication & credentials" icon="key" href="/integrations/authentication">
    The auth-type variants and the OAuth2 (PKCE) connect flow.
  </Card>

  <Card title="Credentials & OAuth2" icon="shield-check" href="/concepts/credentials-oauth">
    How ModuleX stores and resolves credentials, conceptually.
  </Card>

  <Card title="Roles & permissions" icon="users" href="/security/roles-permissions">
    Which actions require an owner or admin role.
  </Card>
</CardGroup>
