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

# Credentials & OAuth2 (PKCE)

> How ModuleX stores integration credentials, runs the OAuth2 authorization-code flow with PKCE, encrypts secrets at rest, and resolves which credential a tool or model uses at run time — with the full credentials API and SDK reference.

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 authentication record that links one [organization](/concepts/organizations-roles) to one integration — a tool, an LLM provider, or a knowledge provider. Credentials never live in your workflow definitions; they are resolved at run time, decrypted in memory, and used to authenticate the outbound call ModuleX makes on your behalf.

This page is the reference for how credentials are modelled, how the OAuth2 authorization-code flow works (with PKCE on by default), how secrets are encrypted at rest, and how ModuleX picks which credential to use when a [tool](/workflow-builder/nodes/tool) or [model](/integrations/llm-providers/overview) runs. For the connect-it walkthrough see [Authentication & credentials](/integrations/authentication); for day-to-day management see [Managing credentials](/integrations/managing-credentials).

<Note>
  Every credentials endpoint requires **owner or admin** role on the organization, and every request must carry both `Authorization: Bearer mx_live_…` and the `X-Organization-ID` header. The organization is taken from that header, never from the request body. See [Authentication](/api-reference/authentication) and [Roles & permissions](/security/roles-permissions).
</Note>

## The credential model

Each credential record belongs to exactly one organization and one integration, and it stores its secrets in encrypted columns. The non-sensitive fields below are what every API and SDK response returns; the secret material (`auth_data`, `oauth_config`) is never returned in clear text.

<ResponseField name="credential_id" type="string">
  The credential's UUID. This is the handle you pass to every per-credential operation.
</ResponseField>

<ResponseField name="integration_name" type="string">
  The integration this credential authenticates (for example `github`, `openai`, `slack`).
</ResponseField>

<ResponseField name="integration_type" type="string | null">
  The integration category: `tool`, `llm_provider`, or `knowledge_provider`.
</ResponseField>

<ResponseField name="display_name" type="string">
  A human-readable label. Defaults to the integration's display name when you do not set one.
</ResponseField>

<ResponseField name="auth_type" type="string">
  The authentication mechanism. One of `oauth2`, `api_key`, `bearer_token`, `modulex_key`, `custom`, or `internal`. (The backend also still accepts the legacy value `bearer`. The `internal` type is system-managed and is **excluded from list output**.)
</ResponseField>

<ResponseField name="is_default" type="boolean">
  Whether this is the default credential for its integration in the organization. Only one credential per integration can be the default.
</ResponseField>

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

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

<ResponseField name="last_used_at" type="string | null">
  ISO-8601 timestamp of the last time the credential was resolved for a run, or `null` if never used.
</ResponseField>

<ResponseField name="expires_at" type="string | null">
  ISO-8601 expiry timestamp. For OAuth2 credentials this is the access-token expiry derived from the provider's `expires_in`.
</ResponseField>

<ResponseField name="credentials_metadata" type="object | null">
  Arbitrary metadata. For MCP server credentials this holds the discovered tool catalog. Note the field is `credentials_metadata` (snake\_case) on the wire.
</ResponseField>

<Note>
  **Wire casing.** The credentials API does not camelCase its responses. Fields such as `credentials_metadata`, `auth_type`, `display_name`, and `created_by_email` are snake\_case on the wire. The SDKs accept camelCase **parameters** (for example `integrationName`, `makeDefault`) and translate them for you, but they return the snake\_case response shapes unchanged.
</Note>

### The six authentication types

`auth_type` records how a credential authenticates. When you create a credential, ModuleX auto-detects the type from the body you send (see [Create a credential](#create-a-credential)).

| `auth_type`    | What it stores                                                       | Set by                                                                    |
| -------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `oauth2`       | An OAuth2 access token (+ optional refresh token and `oauth_config`) | The OAuth2 flow, or `auth_data.access_token` + a top-level `oauth_config` |
| `api_key`      | A single API key                                                     | `auth_data.api_key` present in the body                                   |
| `bearer_token` | A bearer token                                                       | `auth_data.token` or `auth_data.bearer_token` present                     |
| `modulex_key`  | A pointer to a ModuleX-managed pooled key (billed in credits)        | `auth_type: "modulex_key"` in the body                                    |
| `custom`       | Free-form auth data validated by the integration                     | `auth_type: "custom"` in the body                                         |
| `internal`     | System-managed secrets (for example managed knowledge)               | The platform; never created by you and never listed                       |

<Note>
  **`modulex_key` is metered; your own keys are not.** A `modulex_key` credential routes through ModuleX-provisioned provider keys and consumes [credits](/concepts/credits-billing) on every use, gated by your plan. Credentials you bring yourself (BYOK) — `api_key`, `bearer_token`, `oauth2`, `custom` — are billed by the provider directly and carry **no** ModuleX credit gate. See [Credits & metering](/billing/credits).
</Note>

<MediaEmbed id="MX-MEDIA-1140" type="image" caption={"A diagram of the credential model: one organization, many credentials, each linked to one integration and one auth type."} />

## OAuth2 authorization-code flow (with PKCE)

For integrations that support OAuth2, ModuleX runs the standard authorization-code flow with **PKCE (Proof Key for Code Exchange, S256) on by default**. The flow has two API steps you drive — **initiate** and the **callback** — and the provider's browser redirect connects them. A one-time `state` token (held in a short-lived server-side store with a 5-minute TTL) ties the two halves together.

<Steps>
  <Step title="Initiate the flow">
    Call `POST /credentials/oauth2/initiate` with the integration name and a `redirect_uri`. ModuleX generates the PKCE `code_verifier`/`code_challenge`, stores the flow state in a short-lived server-side store (5-minute TTL), and returns an `authorization_url` plus a `state` token.
  </Step>

  <Step title="Send the user to the provider">
    Redirect the user's browser to the returned `authorization_url`. The user authenticates with the provider and grants the requested scopes.
  </Step>

  <Step title="The provider redirects to the callback">
    The provider redirects the browser to `GET /credentials/oauth2/callback` with a `code` and the `state`. This endpoint is **anonymous** — it cannot carry your app token, so trust comes from the one-time `state` it reads (and deletes) from the short-lived server-side store.
  </Step>

  <Step title="ModuleX exchanges the code and stores the credential">
    The callback exchanges the authorization code for tokens, encrypts them, creates an `oauth2` credential, and **302-redirects the browser** to your frontend landing page with the result appended as query parameters — never as JSON.
  </Step>
</Steps>

<MediaEmbed id="MX-MEDIA-1141" type="image" caption={"A sequence diagram of the OAuth2 PKCE flow across the four actors."} />

### Initiate the OAuth2 flow

Start an OAuth2 authorization-code flow and get back the URL to send the user to.

<ParamField body="integration_name" type="string" required>
  The integration to authorize. It must declare an `oauth2` auth schema, or the call returns `400` "does not support OAuth2".
</ParamField>

<ParamField body="redirect_uri" type="string" required>
  The callback URL the provider redirects back to. Use the ModuleX callback, `https://api.modulex.dev/credentials/oauth2/callback`.
</ParamField>

<ParamField body="use_modulex_oauth" type="boolean" default="true">
  When `true`, the flow uses ModuleX's managed OAuth application for the integration. When `false`, you must supply `custom_oauth_config`.
</ParamField>

<ParamField body="custom_oauth_config" type="object">
  Your own OAuth app configuration. Required when `use_modulex_oauth` is `false`; must include `client_id` and `client_secret`. The `auth_url`/`token_url` are taken from the integration's schema.
</ParamField>

<ParamField body="scope" type="string">
  Space-separated scopes to request. Defaults to the provider's or schema's configured scopes.
</ParamField>

<ParamField body="display_name" type="string">
  Label for the credential that will be created on success.
</ParamField>

<ParamField body="make_default" type="boolean" default="false">
  Whether to set the resulting credential as the default for its integration.
</ParamField>

<ParamField body="env_var_values" type="object">
  Per-user setup environment variables to fold into the persisted `auth_data`, keyed by raw env-var name. Only keys the integration is allowed to read are retained.
</ParamField>

<ParamField body="composer_chat_id" type="string">
  AI Composer chat to atomically resume once the callback completes. Must be supplied together with `composer_request_id` and `composer_llm_config` — partial linkage returns `400`.
</ParamField>

<ParamField body="composer_request_id" type="string">
  The Composer interrupt request id, validated on the callback. All-or-none with `composer_chat_id` and `composer_llm_config`.
</ParamField>

<ParamField body="composer_llm_config" type="object">
  LLM configuration used to rebuild the chat model on Composer resume. All-or-none with the two fields above.
</ParamField>

**Response** — `200 OK`:

<ResponseField name="authorization_url" type="string">
  The provider URL to redirect the user to.
</ResponseField>

<ResponseField name="state" type="string">
  The opaque one-time CSRF/state token correlating this flow. Valid for 5 minutes.
</ResponseField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials/oauth2/initiate \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "integration_name": "github",
      "redirect_uri": "https://api.modulex.dev/credentials/oauth2/callback",
      "use_modulex_oauth": true,
      "make_default": true
    }'
  # → {"authorization_url":"https://github.com/login/oauth/authorize?...","state":"f3a1...token"}
  ```

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

  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      flow = await client.credentials.initiate_oauth2(
          "github",
          redirect_uri="https://api.modulex.dev/credentials/oauth2/callback",
          use_modulex_oauth=True,
          make_default=True,
      )
      # Redirect the user's browser to flow.authorization_url
      print(flow.authorization_url, flow.state)
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  const flow = await client.credentials.initiateOAuth2({
    integrationName: "github",
    redirectUri: "https://api.modulex.dev/credentials/oauth2/callback",
    useModulexOauth: true,
    makeDefault: true,
  });
  // Redirect the user's browser to flow.authorization_url
  console.log(flow.authorization_url, flow.state);
  ```
</CodeGroup>

<Expandable title="Initiate errors">
  | Status | Cause                                                                                                                                                             |
  | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
  | `400`  | Integration has no `oauth2` schema; ModuleX OAuth app not configured; missing `custom_oauth_config` when `use_modulex_oauth` is `false`; partial Composer linkage |
  | `404`  | Integration not found                                                                                                                                             |
  | `503`  | OAuth state storage unavailable                                                                                                                                   |
  | `500`  | Unexpected error during initiation                                                                                                                                |
</Expandable>

### The callback (browser redirect target)

`GET /credentials/oauth2/callback` is where the provider sends the user's browser. You do not call it directly and there is no SDK method for it — it is the redirect target you pass as `redirect_uri`.

* It is **anonymous**: no `Authorization` header is required, because the browser arriving from the provider has no app token. Trust comes from the one-time `state` value, which is read and deleted from the short-lived server-side store on first use.
* It **always responds with a `302` redirect** to your frontend landing page (default `https://app.modulex.dev/oauth/callback`), never with JSON — including on failure.
* The result is appended as query parameters:
  * Success: `?status=success&integration=<name>&credential_id=<uuid>`
  * Failure: `?status=error&integration=<name>&error_code=<code>&message=<urlencoded>`

| `error_code`                 | Meaning                                                         |
| ---------------------------- | --------------------------------------------------------------- |
| `oauth_denied`               | The user denied access at the provider                          |
| `oauth_provider_error`       | The provider returned an error other than denial                |
| `missing_params`             | The callback arrived without a `code` or `state`                |
| `invalid_state`              | The `state` was unknown, expired (>5 min), or already used      |
| `token_exchange_failed`      | Exchanging the authorization code for tokens failed             |
| `credential_creation_failed` | The credential could not be saved                               |
| `invalid_credentials`        | A Composer-linked credential failed its test before auto-resume |
| `internal_error`             | An unexpected error occurred                                    |

<Note>
  **Token exchange handles non-standard providers.** The code-for-token exchange sends client credentials either in the request body (`client_secret_post`, the default) or as HTTP Basic auth, depending on the integration's `token_auth_method`. It also handles providers that return a `200` with a `{"ok": false}` body (Slack-style failures) and redacts token values from logs.
</Note>

### Refreshing OAuth2 tokens

ModuleX keeps OAuth2 access tokens fresh **automatically** at run time. When a credential is resolved for a [tool](/workflow-builder/nodes/tool) or model call and its access token expires within the next 5 minutes, ModuleX refreshes it inline using the stored refresh token and re-encrypts the result before the call proceeds. You do not need to schedule or trigger this.

<Warning>
  **Do not rely on a manual or programmatic token refresh.** A manual refresh endpoint and the matching `refreshOAuth2` / `refresh_oauth2` SDK methods exist in the surface, but the path is **known-broken and is not a supported flow**:

  * In the ModuleX app, the credential UI's refresh action targets a frontend route that does not exist and **404s before reaching the backend**.
  * The backend handler itself has defects that prevent it from completing reliably.

  If a credential's authorization has fully lapsed (for example the refresh token was revoked or expired) and automatic refresh cannot recover it, **reconnect the integration** by running the OAuth2 flow again ([`POST /credentials/oauth2/initiate`](#initiate-the-oauth2-flow)). Re-authorizing replaces the stale tokens with a fresh credential. This is the only supported way to restore a lapsed OAuth2 connection. See [Known limitations](/reference/known-limitations).
</Warning>

<Note>
  **Automatic refresh has one parity gap.** The inline run-time refresh always sends client credentials in the request body. A provider that requires HTTP Basic auth for refresh (for example Notion's `token_auth_method: "basic"`) would receive an `invalid_client` error on automatic refresh. For those providers, reconnect via the OAuth2 flow rather than depending on background refresh.
</Note>

## Encryption at rest

Credential secrets are encrypted before they are stored and decrypted only in memory, at the moment a tool or model needs them. Each credential is sealed with its own key, bound to your organization and that specific credential, so a stored secret cannot be unlocked outside the organization it belongs to. See [Data security & encryption](/security/data-encryption) for the platform-wide picture.

<CardGroup cols={2}>
  <Card title="Per-credential secrets" icon="lock">
    Your access tokens, API keys, and OAuth settings are encrypted with a key unique to that credential and organization — never stored in clear text.
  </Card>

  <Card title="OAuth app secrets" icon="key">
    The secrets behind ModuleX's managed OAuth apps are protected with their own separately-keyed encryption.
  </Card>
</CardGroup>

* **Per-credential isolation.** Because each key is tied to one credential in one organization, a stored secret can never be reused or moved to another credential.
* **No secret read-back.** Reading a credential returns masked placeholders only: an `oauth2` credential shows the literal `"OAuth2"`; an `api_key` shows a masked value like `sk-proj12…xyz`; a bearer token shows a masked token. The clear-text secret is never returned by any endpoint.

## Credential resolution at run time

When a tool or model executes, ModuleX must decide **which** credential to use and then prepare it for the outbound call. This happens in two stages.

### Stage 1 — pick a credential

<Steps>
  <Step title="Explicit credential id">
    If the run specifies a `credential_id`, ModuleX uses it directly. The credential must match both the organization and the integration; otherwise resolution fails with "no credential found".
  </Step>

  <Step title="The default credential">
    Otherwise, ModuleX looks for the credential marked `is_default` for that integration and type.
  </Step>

  <Step title="The most-recent valid fallback">
    If there is no default, ModuleX falls back to any valid credential, preferring your **own** credentials over `modulex_key` ones, then the most recently created. If nothing valid exists, resolution fails.
  </Step>
</Steps>

<Note>
  **Precedence, in one line:** explicit `credential_id` → the `is_default` credential → most-recent valid **user** credential → most-recent valid `modulex_key`.
</Note>

### Stage 2 — prepare for execution

The chosen credential is then prepared depending on its type:

<AccordionGroup>
  <Accordion title="Your own credentials (oauth2 / api_key / bearer_token / custom)">
    ModuleX decrypts the `auth_data`. For `oauth2` credentials it also checks the access-token expiry and **refreshes inline** if the token expires within 5 minutes (see [Refreshing OAuth2 tokens](#refreshing-oauth2-tokens)). There is **no credit check** on this path — your own keys are billed by the provider, not by ModuleX.
  </Accordion>

  <Accordion title="ModuleX-managed keys (modulex_key)">
    The encrypted blob holds a **UUID pointer, not the real API key**. ModuleX decrypts the pointer, verifies it against the organization's managed-key records, fetches the real key from the system key pool, and runs a **credit-limit check** before use. If the credit limit is exceeded — or there is no active subscription — preparation fails and the call does not proceed. Tool usage on this path is metered after execution.
  </Accordion>
</AccordionGroup>

<Note>
  **Where the billing gate lives.** The credentials CRUD and OAuth endpoints on this page do **not** themselves run a per-call credit gate — they return the standard `{"detail": …}` error shape. The credit gate fires during resolution of `modulex_key` credentials, which happens on the run / composer / assistant / managed-knowledge surfaces. Those surfaces return the flat `DenialEnvelope` (`402`/`403`/`429`) when usage is denied. See [Usage gating & limits](/billing/usage-gating) and [Errors & status codes](/api-reference/errors).
</Note>

## The credentials API

All paths below are literally `/credentials/...` (the router is mounted with no version prefix). Every route requires owner/admin and the auth headers shown above. Each operation maps to a JavaScript and Python SDK method; see the [SDK ⇄ API parity matrix](/sdks/parity).

| Method & path                                | JavaScript                     | Python                                 | Purpose                                              |
| -------------------------------------------- | ------------------------------ | -------------------------------------- | ---------------------------------------------------- |
| `GET /credentials`                           | `credentials.list`             | `credentials.list`                     | List, grouped by integration (or flat when filtered) |
| `GET /credentials/{id}`                      | `credentials.get`              | `credentials.get`                      | Get one credential (masked)                          |
| `POST /credentials`                          | `credentials.create`           | `credentials.create`                   | Create a credential (type auto-detected)             |
| `PUT /credentials/{id}`                      | `credentials.update`           | `credentials.update`                   | Update display name / metadata                       |
| `DELETE /credentials/{id}`                   | `credentials.delete`           | `credentials.delete`                   | Delete a credential (`204`)                          |
| `POST /credentials/{id}/set-default`         | `credentials.setDefault`       | `credentials.set_default`              | Make default for its integration                     |
| `POST /credentials/test-temporary`           | `credentials.testTemporary`    | `credentials.test_temporary`           | Validate auth data **before** saving                 |
| `POST /credentials/{id}/test`                | `credentials.test`             | `credentials.test`                     | Test a saved credential                              |
| `GET /credentials/{id}/usage`                | `credentials.usage`            | `credentials.usage`                    | Usage statistics                                     |
| `GET /credentials/{id}/audit`                | `credentials.audit`            | `credentials.audit`                    | Audit log entries                                    |
| `POST /credentials/oauth2/initiate`          | `credentials.initiateOAuth2`   | `credentials.initiate_oauth2`          | Start an OAuth2 flow                                 |
| `GET /credentials/oauth2/callback`           | —                              | —                                      | Browser redirect target (no SDK method)              |
| `POST /credentials/{id}/oauth2/refresh`      | `credentials.refreshOAuth2`    | `credentials.refresh_oauth2`           | Manual refresh — **broken, do not use**              |
| `POST /credentials/mcp-server`               | `credentials.mcpServer`        | `credentials.create_mcp_server`        | Create an MCP server credential                      |
| `POST /credentials/{id}/refresh-discovery`   | `credentials.refreshDiscovery` | `credentials.refresh_mcp_discovery`    | Re-discover MCP tools                                |
| `GET /credentials/{id}/mcp-tools`            | `credentials.mcpTools`         | `credentials.mcp_tools`                | List discovered MCP tools                            |
| `POST /credentials/bulk-modulex-keys/stream` | `credentials.bulkModulexKeys`  | `credentials.bulk_modulex_keys_stream` | SSE: bulk-create `modulex_key` credentials           |

### List credentials

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

<ParamField query="integration_name" type="string">
  Return a flat list for this integration instead of the grouped shape.
</ParamField>

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

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

<ParamField query="offset" type="integer" default="0">
  Number of records to skip. Must be `≥ 0`.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl "https://api.modulex.dev/credentials?auth_type=oauth2&limit=50" \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

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

  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      grouped = await client.credentials.list(auth_type="oauth2", limit=50)
      github = await client.credentials.list(integration_name="github")  # flat shape
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  const grouped = await client.credentials.list({ authType: "oauth2", limit: 50 });
  const github = await client.credentials.list({ integrationName: "github" }); // flat shape
  ```
</CodeGroup>

<Expandable title="Grouped response shape (no integration_name)">
  ```json theme={null}
  {
    "integrations": {
      "github": {
        "integration_name": "github",
        "integration_type": "tool",
        "total_count": 1,
        "auth_types": ["oauth2"],
        "credentials": [
          {
            "credential_id": "b3f1c0de-1111-2222-3333-444455556666",
            "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_credentials": 1,
    "total_integrations": 1,
    "filters": { "auth_type": "oauth2" }
  }
  ```

  With `integration_name` set, the response is flat instead:

  ```json theme={null}
  {
    "credentials": [ /* CredentialResponse[] */ ],
    "total_count": 1,
    "integration_name": "github",
    "filters": { "auth_type": null }
  }
  ```
</Expandable>

### Create a credential

`POST /credentials` (returns `201`). The credential **type is auto-detected from the body** — you do not pass a route-level type. Match the trigger column from the [authentication types table](#the-six-authentication-types).

<ParamField body="integration_name" type="string" required>
  The integration to create the credential for (for example `openai`).
</ParamField>

<ParamField body="auth_data" type="object">
  The secret material. Its shape selects the type: `{"api_key": "…"}` → `api_key`; `{"token": "…"}` or `{"bearer_token": "…"}` → `bearer_token`; `{"access_token": "…"}` + a top-level `oauth_config` → `oauth2`.
</ParamField>

<ParamField body="auth_type" type="string">
  Set explicitly to `modulex_key` or `custom` to select those types. For other types the value is inferred from `auth_data`.
</ParamField>

<ParamField body="oauth_config" type="object">
  OAuth2 configuration (`token_url`, `client_id`, `client_secret`, …). Required to create an `oauth2` credential directly. Stored encrypted.
</ParamField>

<ParamField body="display_name" type="string">
  Human-readable label.
</ParamField>

<ParamField body="make_default" type="boolean" default="false">
  Set this credential as the default for its integration.
</ParamField>

<ParamField body="expires_at" type="string">
  ISO-8601 datetime after which the credential is treated as expired.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "integration_name": "openai",
      "display_name": "Production OpenAI key",
      "make_default": true,
      "auth_data": { "api_key": "sk-proj-abc123" }
    }'
  ```

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

  async with Modulex(
      api_key="mx_live_xxx",
      organization_id="11111111-1111-1111-1111-111111111111",
  ) as client:
      credential = await client.credentials.create(
          "openai",
          display_name="Production OpenAI key",
          make_default=True,
          auth_data={"api_key": "sk-proj-abc123"},
      )
  ```

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

  const client = new Modulex({
    apiKey: "mx_live_xxx",
    organizationId: "11111111-1111-1111-1111-111111111111",
  });

  const credential = await client.credentials.create({
    integrationName: "openai",
    displayName: "Production OpenAI key",
    makeDefault: true,
    authData: { api_key: "sk-proj-abc123" },
  });
  ```
</CodeGroup>

<Note>
  For OAuth2, prefer the [initiate flow](#initiate-the-oauth2-flow) over creating an `oauth2` credential directly — it runs PKCE and the code exchange for you.
</Note>

### Test a credential

Two operations validate credentials against the integration's configured test endpoint:

* `POST /credentials/test-temporary` validates auth data **before** you save it. It takes `integration_name`, `auth_type`, and `auth_data`.
* `POST /credentials/{id}/test` validates a **saved** credential. An expired credential returns `is_valid: false` with "Credential has expired".

A test response reports `is_valid`, a human-readable `message`, `tested_at`, and a `test_method` of `api_call`, `basic`, or `none`. Integrations without a configured test endpoint report `test_method: "none"` and `is_valid: true` with a "no test endpoint" message rather than failing.

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials/test-temporary \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "integration_name": "tavily",
      "auth_type": "api_key",
      "auth_data": { "api_key": "tvly-abc123" }
    }'
  ```

  ```python Python theme={null}
  result = await client.credentials.test_temporary(
      "tavily",
      "api_key",
      {"api_key": "tvly-abc123"},
  )
  saved = await client.credentials.test("b3f1c0de-1111-2222-3333-444455556666")
  ```

  ```javascript JavaScript theme={null}
  const result = await client.credentials.testTemporary({
    integrationName: "tavily",
    authType: "api_key",
    authData: { api_key: "tvly-abc123" },
  });
  const saved = await client.credentials.test("b3f1c0de-1111-2222-3333-444455556666");
  ```
</CodeGroup>

### Set default, update, and delete

* `POST /credentials/{id}/set-default` makes a credential the default for its integration and unsets the previous default.
* `PUT /credentials/{id}` updates only `display_name` and `metadata`. Secrets are immutable through this route — to change a secret, reconnect (OAuth2) or create a new credential and delete the old one. There is no rotate endpoint.
* `DELETE /credentials/{id}` permanently deletes a credential and returns `204 No Content`.

<CodeGroup>
  ```bash cURL theme={null}
  # Make default
  curl -X POST https://api.modulex.dev/credentials/b3f1c0de-1111-2222-3333-444455556666/set-default \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"

  # Delete (204)
  curl -X DELETE https://api.modulex.dev/credentials/b3f1c0de-1111-2222-3333-444455556666 \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111"
  ```

  ```python Python theme={null}
  await client.credentials.set_default("b3f1c0de-1111-2222-3333-444455556666")
  await client.credentials.update(
      "b3f1c0de-1111-2222-3333-444455556666",
      display_name="GitHub (CI)",
  )
  await client.credentials.delete("b3f1c0de-1111-2222-3333-444455556666")
  ```

  ```javascript JavaScript theme={null}
  await client.credentials.setDefault("b3f1c0de-1111-2222-3333-444455556666");
  await client.credentials.update("b3f1c0de-1111-2222-3333-444455556666", {
    displayName: "GitHub (CI)",
  });
  await client.credentials.delete("b3f1c0de-1111-2222-3333-444455556666");
  ```
</CodeGroup>

## MCP server credentials

A credential can also point at an external [Model Context Protocol (MCP) server](/integrations/building/custom-mcp). Creating one connects to the server (over `streamable_http` by default), discovers its tools, and stores the catalog in `credentials_metadata`. MCP server credentials are persisted with `integration_name: "mcp_server"` and `auth_type: "custom"`.

<ParamField body="server_url" type="string" required>
  The MCP server's URL.
</ParamField>

<ParamField body="headers" type="object">
  Headers to send when connecting, for example `{"Authorization": "Bearer mcp-token"}`.
</ParamField>

<ParamField body="display_name" type="string">
  Label for the credential.
</ParamField>

<ParamField body="make_default" type="boolean" default="false">
  Set as default.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.modulex.dev/credentials/mcp-server \
    -H "Authorization: Bearer mx_live_xxx" \
    -H "X-Organization-ID: 11111111-1111-1111-1111-111111111111" \
    -H "Content-Type: application/json" \
    -d '{
      "server_url": "https://mcp.example.com/sse",
      "display_name": "Internal MCP",
      "headers": { "Authorization": "Bearer mcp-token" }
    }'
  ```

  ```python Python theme={null}
  mcp = await client.credentials.create_mcp_server(
      "https://mcp.example.com/sse",
      display_name="Internal MCP",
      headers={"Authorization": "Bearer mcp-token"},
  )
  tools = await client.credentials.mcp_tools(mcp.credential_id)
  refreshed = await client.credentials.refresh_mcp_discovery(mcp.credential_id)
  ```

  ```javascript JavaScript theme={null}
  const mcp = await client.credentials.mcpServer({
    serverUrl: "https://mcp.example.com/sse",
    displayName: "Internal MCP",
    headers: { Authorization: "Bearer mcp-token" },
  });
  const tools = await client.credentials.mcpTools(mcp.credential_id);
  const refreshed = await client.credentials.refreshDiscovery(mcp.credential_id);
  ```
</CodeGroup>

* `GET /credentials/{id}/mcp-tools` returns the discovered tool list and a total count.
* `POST /credentials/{id}/refresh-discovery` re-discovers tools and reports what was added or removed. It is valid only for `mcp_server` credentials; calling it on any other credential returns `400`.

## Errors

Credentials endpoints return the standard `{"detail": "<string or object>"}` HTTPException envelope. There is **no `402`** on these routes — credit denial happens during run-time resolution, not on these CRUD calls (see [Where the billing gate lives](#stage-2--prepare-for-execution)). The status codes you will see across this subsystem:

| Status        | When                                                                     |
| ------------- | ------------------------------------------------------------------------ |
| `201`         | Credential created                                                       |
| `204`         | Credential deleted                                                       |
| `302`         | OAuth2 callback redirect (always, even on error)                         |
| `400`         | Validation error, unsupported auth, malformed body                       |
| `401` / `403` | Missing/invalid auth, not a member, or not owner/admin                   |
| `404`         | Credential or integration not found                                      |
| `429`         | Organization rate limit (with `X-RateLimit-*` and `Retry-After` headers) |
| `500`         | Unexpected server error                                                  |
| `503`         | OAuth state storage unavailable on initiate                              |

See [Errors & status codes](/api-reference/errors) for the full error model across surfaces and [SDK errors & retries](/sdks/errors-retries) for how the SDKs surface them.

## Where to go next

<CardGroup cols={2}>
  <Card title="Authentication & credentials" icon="plug" href="/integrations/authentication">
    The connect-it walkthrough and the six integration auth schema variants.
  </Card>

  <Card title="Managing credentials" icon="sliders" href="/integrations/managing-credentials">
    Create, scope, and rotate credentials in the app and via the API.
  </Card>

  <Card title="Data security & encryption" icon="shield-halved" href="/security/data-encryption">
    How ModuleX encrypts credentials and manages keys platform-wide.
  </Card>

  <Card title="Known limitations" icon="triangle-exclamation" href="/reference/known-limitations">
    Documented gaps, including the broken manual OAuth2 refresh path.
  </Card>
</CardGroup>
