# Credentials API

> Connect OAuth services, service accounts, and MCP servers for your workspace. Credentials supply tokens and keys to apps and agents at run time.

**URL:** https://inference.sh/docs/api/rest/credentials
**Last updated:** 2026-11-01

---

Connect OAuth services, service accounts, and MCP servers for your workspace. Credentials supply tokens and keys to apps and agents at run time.

→ [Extend credentials](/docs/extend/credentials) — declare requirements in `inf.yml`  
→ [Connectors overview](/docs/connectors/overview) — MCP connectors and agent tools

Requires API key scopes `credentials:read` (list, capabilities, check) and `credentials:write` (connect, disconnect, call tools).

> Paths under `/integrations` (the name before 2026-09-23) answer `410 Gone` with a `Link` header naming the `/credentials` path.

Connect stores non-OAuth credentials (service accounts, MCP bearer tokens) and OAuth tokens in encrypted JSON on the credential row — tokens are never returned in API responses. Disconnect loads stored tokens, calls provider cleanup when supported, then soft-deletes the connection.

MCP tool proxy (`/mcps/{slug}/tools`) is not part of the credentials namespace.

### Vaults

Credentials and secrets are grouped into [vaults](/docs/api/rest/vaults). Workspace- and user-scoped credentials are linked to the workspace's default vault when they are created. Workspace-scoped secrets are linked on create as well. Use `GET /vaults/{id}/credentials` and `GET /vaults/{id}/secrets` to list members, and the `POST`/`DELETE` membership endpoints to manage links without deleting the underlying credential or secret.

---

## Capabilities catalog

`GET /credentials/capabilities`

Returns credential keys your apps can declare (for example `google.sheets`, `google-sa.files`). Each capability includes `provider`, `type`, OAuth `scopes`, and whether it is available on this server.

```bash
curl https://api.inference.sh/credentials/capabilities \
  -H "Authorization: Bearer inf_your_key"
```

**Response:**

```json
{
  "capabilities": [
    {
      "key": "google.sheets",
      "provider": "google",
      "type": "oauth",
      "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
      "display_name": "Google Sheets",
      "available": true
    }
  ]
}
```

Use this to build setup UIs or validate `inf.yml` credential keys before deploy.

---

## Credential configs

`GET /credentials/configs`

Returns the merged credential view: the provider catalog joined with your workspace's connection state in one response. Each registered provider (OAuth, BYOK, service accounts) appears once with catalog metadata (display names, descriptions, icons, docs links, BYOK form fields, `has_managed`, `connection_scope`) and, when connected, a nested `credential` object with live status, account info, and OAuth scopes. `accounts` lists every login you can use for the provider. `credential` is `accounts[0]`: the login [`GET /credentials/{provider}`](#list-connected-credentials) returns without `identifier`, which is the connected login a run would use when there is one, otherwise the first login on the [resolution ladder](#multiple-accounts-per-provider) (for example a `disconnected` key login that still needs its keys), so a setup UI can prompt for them. The other entries follow the ladder (your own before the workspace's, each owner's default before its other accounts, then oldest). A disconnected or expired personal login is not shown as the active login while a connected workspace key is what runs use. OAuth providers may also include an `app` object — the OAuth **application** row (client id/secret), separate from the **login** in `credential`. Each entry has a unique `slug` (for example `google`, `google-sa`, `gcp`, `slack`). Providers whose default `connection_scope` is `user` include your personal connection in `credential` when you are authenticated.

Connected MCP servers are appended as standalone rows at the end of the array: `provider` is `mcp`, `slug` is the server's slug, and `credential.account_identifier` is the server URL.

Requires `credentials:read`.

```bash
curl https://api.inference.sh/credentials/configs \
  -H "Authorization: Bearer inf_your_key"
```

**Response** (array of config objects):

| Field | Type | Description |
|-------|------|-------------|
| `slug` | string | URL-friendly identifier for this provider (for example `google`, `google-sa`, `gcp`, `slack`). Used in the web app's detail page URLs (`/credentials/{slug}`). Defaults to `provider` when not set. |
| `provider` | string | Provider key used in `POST /credentials` connect calls (for example `google`, `google-sa`, `gcp`, `slack`) |
| `type` | string | Credential type (`oauth`, `service_account`, …) |
| `name` | string | Display name |
| `short_name` | string | Short label |
| `description` | string | Provider description |
| `icon_url` | string | Logo URL |
| `how_it_works` | string[] | Setup steps shown in the UI |
| `instructions` | string | Markdown from the auth provider on how to get what it asks for (links allowed). Omitted when empty. See [Setup instructions](/docs/api/rest/auth-providers#setup-instructions) |
| `docs_url` | string | Link to provider docs |
| `expires_after_days` | integer | For `api_key` providers: how many days a saved key counts as valid (`0` or omitted = never). See [API key lifetime](#api-key-lifetime) |
| `secret_fields` | array | BYOK form fields (`key`, `label`, `placeholder`, `sensitive`, `optional`, `pattern`). When a field has a `pattern`, saving a value that does not match it is refused (see [Pasted-key fields](/docs/api/rest/auth-providers#pasted-key-fields)). For catalogued `api_key` providers, keys declared in deploying apps' `credentials:` `secrets` lists are merged onto the platform template and surfaced here. |
| `allows_byok` | boolean | Workspace can supply its own credentials instead of a managed login. Always `true` for `api_key` providers. |
| `available` | boolean | Provider is registered on this server |
| `has_managed` | boolean | Platform has managed credentials configured (workspaces can use without BYOK) |
| `company` | object | The **company** this credential slug belongs to (`CompanySummary`): `slug`, `name`, `icon_url`, `website`, `docs_url`, `terms_url`, `privacy_url` (the last two when the catalog records them). Google's `google`, `google-sa` and `google-gemini` entries all carry `company.slug: "google"`. `provider` (the string) is the credential slug. |
| `connection_scope` | string | Default owner for **new** connections: `user` (each member connects their own account) or `team` (shared workspace connection), plus `org` / `platform` where applicable. Drives connect-button copy — see [Managed credentials and grant type](#managed-credentials-and-grant-type). |
| `app` | object | OAuth **app** credential (`grant: "credentials"`) when logins go through a configured client id/secret (workspace, org, or platform). Omitted when the provider does not use an app row or none is set up yet. Configure with [`PUT /credentials/{provider}/app`](#configure-oauth-app). |
| `hook_url` | string | Where the configured **app** receives provider webhook deliveries — for example the GitHub App webhook URL to paste in GitHub's app settings (`https://api.inference.sh/triggers/hooks/github/{app id}`, for the platform's app and a [workspace-owned GitHub App](/docs/credentials/github-app#use-your-own-github-app) alike). Omitted when the provider's apps do not deliver events to a hook. |
| `auth_provider_id` | string | The [auth provider](/docs/api/rest/auth-providers)'s id when it is one the team defined — enables edit/remove in the UI |
| `credential` | object | The login in `accounts[0]`: the one a run resolves to when possible, else the first login on the ladder; omitted when there is none |
| `accounts` | array | Every login the resolution ladder can return for this provider, including when there is only one: your personal and workspace logins, org-scope logins shared across your organization, and (in the platform workspace only) platform-scope logins. Same objects as [`GET /credentials/{provider}`](#list-connected-credentials) returns, the login in use first and the rest in ladder order. Omitted when there is none |

Native providers (OAuth, BYOK, service accounts) appear once per credential slug. MCP servers are not in the provider catalog, but MCP entries (`type: "mcp"`, `provider: "mcp"`, `slug` = the workspace's server slug when it has one) are included for every server the caller has **connected**, and for every server host whose **OAuth app is set up but nobody has connected yet**: that entry carries `app`, no `credential`, `secret_fields` for the app's client id and secret, and `connection_scope: "user"`. A connected MCP entry also carries `app` when its host has one. To browse or connect new MCP servers, use the [MCP servers API](/docs/api/rest/mcp-servers) or `POST /credentials` with `provider: "mcp"`.

For MCP browse and connect UIs, authenticated `GET /mcps` and `GET /mcp-servers` responses carry `connection_status` for each `server_url`. Use those endpoints instead of merging this configs array with MCP server lists.

Provider names are globally unique for native providers, and each maps to exactly one connection type. MCP supports several connections per workspace under the one provider `mcp`, told apart by `account_identifier` (the server URL); name one with `?identifier={server_url}` on get and disconnect. Use `slug` for dashboard links and `provider` for `POST /credentials`, `GET /credentials/{provider}`, and `DELETE /credentials/{provider}`.

**JavaScript SDK:** `client.credentials.getConfigs()`

---

## Company catalog

`GET /companies` · `GET /companies/{slug}`

The companies behind credential slugs (`company.slug` on a config). A company has one or more [auth providers](/docs/api/rest/auth-providers) (Google: `google`, `google-sa`, `google-gemini`, …). Public: no API key required. `GET /companies` takes an optional `category` query parameter (`ai_ml`, `media`, `search`, `cloud`, `social`, `tools`). `GET /companies/{slug}` returns one entry, or `404` for an unknown slug.

Before INF-1079 these were `GET /providers` and `GET /providers/{slug}`. Those paths are no longer served; use `GET /companies` and `GET /companies/{slug}`.

```bash
curl https://api.inference.sh/companies?category=ai_ml
curl https://api.inference.sh/companies/google
```

Each entry (`CompanyDTO`):

| Field | Type | Description |
|-------|------|-------------|
| `slug` | string | Company slug (`google`, `openai`) |
| `display_name` | string | Display name |
| `description` | string | Short description |
| `icon_url` | string | Logo URL |
| `docs_url` | string | Documentation link |
| `website` | string | Company home page |
| `terms_url` | string | Terms of service |
| `privacy_url` | string | Privacy policy |
| `supported_types` | string[] | Credential types the company offers |
| `category` | string | Catalog category |
| `status` | string | `active`, `beta`, or `deprecated` |
| `data_residency`, `legal`, `partner_config`, `metadata` | object | Structured catalog data. `website` reads `metadata.website`; `terms_url` and `privacy_url` read `legal` |
| `created_at`, `updated_at` | string | ISO timestamps |

---

## Available providers

`GET /credentials/available`

Returns a simple list of provider names that can be connected on this server (for example `google`, `google-sa`, `gcp`, `slack`). Requires `credentials:read`.

```bash
curl https://api.inference.sh/credentials/available \
  -H "Authorization: Bearer inf_your_key"
```

**JavaScript SDK:** `client.credentials.listAvailable()`

---

## List connected credentials

`GET /credentials` or `POST /credentials/list`

Cursor-paginated list of credentials connected for your workspace (and per-user connections when applicable). Use this when you need to page through all connections or look up a single provider by key. For setup UIs that show catalog metadata alongside connection state, prefer [`GET /credentials/configs`](#credential-configs), which returns the merged view in one call.

```bash
curl https://api.inference.sh/credentials \
  -H "Authorization: Bearer inf_your_key"
```

CLI equivalent: `belt credentials list` (add `--limit` and `--cursor` to page; table mode prints `next page:` / `prev page:` hints with the cursor value). The table's columns are `company`, `slug` (credential / provider slug), `sign-in`, `status`, `owner`, and `keys`, and it also lists OAuth apps set up for a slug nobody has connected yet. With `--json`, stdout is the current page's `items` array; `--json --apps` prints `{"items": [...], "apps": [...]}`. The CLI calls `POST /credentials/list` and reads each row's `company` from configs.

`GET /credentials/{provider}` returns a single credential by provider key (for example `google`, `google-sa`, `gcp`, `slack`, `mcp`). When a provider has several accounts, `?identifier=` names one (for MCP, the server URL); without it you get the default.

Optional `identifier` selects one login when several accounts are connected for the same provider. Pass the provider's stable `account_id` (for example Google's `sub`, an X user id, or an MCP server URL) or the display label in `account_identifier` (email, @handle, …). **Prefer `account_id` when the credential object includes it** — labels can change and legacy logins may have been created before ids were stored. Omit `identifier` to get the [default login](#multiple-accounts-per-provider) for that provider.

```bash
curl https://api.inference.sh/credentials/google \
  -H "Authorization: Bearer inf_your_key"

curl 'https://api.inference.sh/credentials/google?identifier=user%40company.com' \
  -H "Authorization: Bearer inf_your_key"

curl https://api.inference.sh/credentials/google-sa \
  -H "Authorization: Bearer inf_your_key"
```

CLI equivalent: `belt credentials get <provider>` (for example `belt credentials get google`, `belt credentials get google-sa`, or `belt credentials get mcp`). The CLI calls `GET /credentials/{provider}` and best-effort merges `GET /credentials/configs` so table output can show the provider name, linked **company** (`company`), website, and docs.

**JavaScript SDK:** `client.credentials.get(provider)` — same response as the REST endpoint.

### Credential object fields

Each connected credential (`CredentialDTO`) includes:

| Field | Type | Description |
|-------|------|-------------|
| `id` | string | Stable identifier for this credential row. Stays the same when you reconnect, complete OAuth again, or grant additional scopes; **disconnect** removes the row and the next connect issues a new `id`. A login without an account that is folded into an older duplicate also loses its `id` |
| `scope` | string | `team` (shared workspace connection), `user` (your personal connection), `org` (shared across all workspaces in the organization), or `platform` (managed by the platform). There is no `agent` scope: agents use the same vault logins as apps |
| `grant` | string | For managed OAuth providers: `credentials` (OAuth app row — never a login) or `token` (a connection: OAuth login, API key, service account, MCP authorization). See [Managed credentials and grant type](#managed-credentials-and-grant-type). |
| `app_credential_id` | string | When this row is a login (`grant: "token"`) that signed in through an OAuth app, the id of that app credential row |
| `provider` | string | Provider key |
| `type` | string | Credential type |
| `status` | string | `connected`, `disconnected`, `expired`, or `error` (see below; `pending` is no longer written) |
| `display_name` | string | User-facing label |
| `scopes` | string[] | Granted OAuth scopes, in the provider's own names (for example `https://www.googleapis.com/auth/gmail.send`, `Mail.Read`) |
| `allowed_scopes` | string[] | On an OAuth **app** row (`grant: "credentials"`): every scope a login through this client may request. Absent when any scope may be (a workspace's own client by default). See [Allowed scopes](#allowed-scopes) |
| `account_id` | string | Provider-stable account key (Google `sub`, Slack user id, MCP server URL, …). Logins are matched, deduplicated, and selected by this value when present |
| `account_identifier` | string | Display label for the account (email, @handle, hostname). The provider can change it; reconnecting updates the label on the same `account_id` |
| `account_name` | string | Display name for the account |
| `expires_at` | string | ISO timestamp. For OAuth logins, when the access token expires (the platform refreshes it when it has a refresh token). For `api_key` logins of a provider with `expires_after_days`, when the saved key stops counting as valid; set on each key save. Omitted when the key never expires |
| `error_message` | string | Human-readable detail when `status` is `expired` or `error` (for example `"No refresh token available"`) |
| `is_primary` | boolean | Whether this login is the **default account** for its owner and provider. Lookups that omit `identifier` use the default (after preferring your own `user` login). MCP rows are separate servers, not accounts of one provider — each MCP connection is its own row and `is_primary` does not pick a default among them |
| `vault_id` | string | Optional vault reference for encrypted secret storage |
| `metadata` | object | Provider-specific configuration (env var mapping, allowed hosts, …) |
| `secrets` | array | The secrets linked to a key-based login, as `CredentialSecretRef` objects: `id` (secret row id), `key` (vault key name, such as `OPENAI_API_KEY`), `updated_at` (when the value last changed). Never the values. Filled on `api_key` logins in [`GET /credentials/configs`](#credential-configs); omitted elsewhere. Reveal a value with [`GET /secrets/{id}/reveal`](/docs/api/rest/secrets#reveal-secret-by-id) |

A credential row exists only once it holds a login (OAuth sign-in completed) or a key login was started by saving a key. The API creates no empty placeholder rows: not when an agent run is blocked on a provider (the blocked tool waits in the chat until you connect or save keys), not when you start OAuth. Saving a key creates a `disconnected` login that turns `connected` once its keys are stored. Deploying an app whose declared keys are missing creates a login only when deploy can adopt unlinked vault secrets onto it; otherwise the next run asks for the keys and saving them creates the login. The `pending` status (a keyless placeholder login) is no longer written, and the remaining placeholder rows were retired on 2026-10-28. Runs that require a credential use only `connected` logins.

Raw tokens are never returned. Workspace-scoped credentials (`scope: "team"`) are shared connections created by connect flows; user-scoped rows (`scope: "user"`) are personal OAuth logins (`grant: "token"`); org-scoped rows (`scope: "org"`) are shared across all workspaces in the organization; platform-scoped rows (`scope: "platform"`) are platform keys inference.sh provides (managed credentials when `has_managed` is true on the provider config). When several rows match at run time, secrets passed with the task come first, then your own `user` connection, then the workspace `team` **default** login (`is_primary: true`, else oldest), then `org`, then a `platform` key. A `platform`-scope login is on the lookup ladder only in the platform workspace; other workspaces get platform keys through the run-time access list below, never as a login they can read or name. Each platform key has an access list deciding which other workspaces' runs may use it: which apps and functions, store-reviewed versions only, on cloud infrastructure only. When no key admits the run, the requirement stays unmet and `POST /run` returns **412**; add your own key for that provider.

### Multiple accounts per provider

OAuth and other account-based providers can have **several logins** for the same owner (for example two Google accounts on one workspace). Connecting another account adds a new login instead of replacing the first.

| Concept | Behavior |
|---------|----------|
| Default login | The owner's first login for a provider becomes the default (`is_primary: true`). Additional accounts connect alongside it with `is_primary: false`. |
| Lookup without `identifier` | `GET /credentials/{provider}`, add-scopes, disconnect and run-time resolution walk one ladder: your `user` login, then the workspace `team` login, then `org`, then (platform workspace only) `platform`. Each rung uses that owner's **default** login (`is_primary`, else oldest `created_at`). Runs skip logins that are not `connected` or whose key lifetime has passed, and then fall back to a `platform` key whose access list admits the run. Without `identifier`, `GET /credentials/{provider}` returns the login a run would use when there is one, otherwise the first login on the ladder (for example a `disconnected` key login) so you can finish setup. [`GET /credentials/configs`](#credential-configs) makes the same choice for `credential`. |
| Lookup with `identifier` | Get, disconnect, set-default and add-scopes walk the same ladder, matching the named `account_id` or `account_identifier` on each rung. A teammate's personal login is never returned. A workspace naming the account of a platform login gets **404** `not_found`. |
| Choose account at connect | Set `another_account: true` on `POST /credentials` for OAuth providers that distinguish accounts. The consent URL asks the provider to show its account picker (for example Google `prompt=select_account consent`, Microsoft `prompt=select_account`) instead of reusing the remembered account. |
| Account identity | Connect and add-scopes match an existing login by `account_id` when the provider supplies one. A login saved before ids were stored is found once by its label and then keeps the id. Reconnecting with the same id updates `account_identifier` when the label changed (for example a renamed X handle). The same label on a **different** `account_id` creates a second login. |
| `identifier` in requests | Use `account_id` when present on the row you mean; fall back to `account_identifier` only when no id was stored yet. The web app shows emails and handles in the UI but sends the stable id on disconnect, set-default, add-scopes, and permission changes. |
| Set default | `PUT /credentials/{provider}/default?identifier=...` makes that login the default (`identifier` is `account_id` or `account_identifier`). Requires permission to manage the login (same rules as disconnect). |
| Disconnect default | When the default login is disconnected, the owner's **oldest** remaining login for that provider becomes the new default. |
| Add scopes | `POST /credentials/{provider}/scopes?identifier=...` targets one account (`account_id` or `account_identifier`). Completing OAuth for a **different** account (different `account_id`) during an add-scopes flow returns `already_exists` (409). |
| Duplicate detection | When a login without `account_id` later learns its account and another login for the same owner already has that id, the API **folds** the duplicate into the existing login (the older row is kept; the newer row is retired without revoking tokens at the provider, so the kept login's grant stays valid). Labels refresh when the login is next used. |

MCP servers remain **one row per server URL** (`provider: "mcp"`, `account_identifier` = the server URL). Each MCP connection is its own server, not another account of a shared provider slug.

Only platform admins can replace or remove a `platform` credential, and only org admins an `org` credential. A shared `team` credential needs the workspace admin role unless you created it. When `allows_byok` is true on the provider config, a workspace can override an inherited credential with its own: OAuth providers via `POST /credentials`, and `api_key` providers by saving a workspace secret with the `provider` field set (see [Create secret](/docs/api/rest/secrets#create-secret)). For `api_key` providers the credential's `status` is `connected` while at least one linked key secret exists and `disconnected` after the last one is removed.

### API key lifetime

An `api_key` [auth provider](/docs/api/rest/auth-providers) (or an app's `credentials:` entry) can set `expires_after_days`. It is copied to `expires_after_days` on [`GET /credentials/configs`](#credential-configs).

| Value | Behavior |
|-------|----------|
| `0` or omitted | Keys do not expire by age; `expires_at` stays unset |
| `1`–`3650` | Each time a key is saved for the provider (create, replace, update, attach or adopt), `expires_at` is set to that many days ahead. Saving again restarts the count |

Once `expires_at` has passed, the login reads as `expired`, its key is not injected at run time, and [`POST /credentials/check`](#check-requirements-pre-flight) and **412** responses ask for a new key (`your {name} key expired on {date}; add a new one`), with the provider's `instructions` on the action. A login with no `expires_at` never expires this way.

OAuth logins use the same `expires_at` field for the access token, unrelated to `expires_after_days`.

### Managed credentials and grant type

Each config entry exposes `connection_scope` — who owns **new** connections by default. Use it for setup copy:

| `connection_scope` | Meaning | Web app UI |
|--------------------|---------|------------|
| `team` | One shared workspace connection (or a ready-to-use platform token) | **connect {name}** |
| `user` | Each member authorizes their own account | **connect your {name} account**; detail pages use **your account** / **disconnect your account** |

When the provider signs in through an OAuth application, `app` holds that **app** row (`grant: "credentials"` — client id/secret only, not a login). `credential` holds the **login** (`grant: "token"` for OAuth connections). Completed OAuth stores which app was used on the login as `app_credential_id`.

`grant` on `CredentialDTO` (including nested `app` and `credential` objects) distinguishes app rows from connection rows:

| `grant` | Row kind | Typical `scope` |
|---------|----------|-----------------|
| `credentials` | OAuth application (client credentials) | `team`, `org`, or `platform` |
| `token` | Connection (OAuth login, API key, service account, MCP authorization) | `user`, `team`, `org`, or `platform` |

`grant=credentials` is only valid for `type: "oauth"` app rows. Per-user OAuth providers use `connection_scope: "user"` on the config; each member's login appears in `credential` with `scope: "user"`.

Check `scope` on the connected `CredentialDTO` to distinguish personal vs workspace connections in list and detail UIs.

When a provider signs in through an OAuth app, new logins use the first **complete** app in this order: the workspace's own (`scope: "team"`), then the organization's (`scope: "org"`), then the platform's (`scope: "platform"`). `GET /credentials/configs` returns that choice in `app`. Refresh tokens always use the app that issued the login (`app_credential_id` on the login row).

---

## Configure OAuth app

OAuth providers whose config includes `secret_fields` (for example `SLACK_CLIENT_ID` / `SLACK_CLIENT_SECRET`) require an OAuth **application** row before `POST /credentials` can return an `auth_url`. The app holds client credentials only (`grant: "credentials"`); user and workspace **logins** stay separate (`grant: "token"` in `credential`).

### Set or update app

`PUT /credentials/{provider}/app`

Requires `credentials:write` and the **workspace admin** role (platform operators on the platform workspace when setting a platform app). The body is a `CredentialAppRequest`:

| Field | Description |
|-------|-------------|
| `values` | Map of vault key names from `secret_fields` (and optional app keys) to new values. Keys omitted from the map, or sent as blank strings, **keep** their stored values on update. |
| `allowed_scopes` | Optional. Replaces the scopes a login through this app may request (see [Allowed scopes](#allowed-scopes)). `[]` allows any scope; omitted keeps the stored list. |

The app row's `status` becomes `connected` once both the client id and client secret keys are non-empty. Register the provider's redirect URI in the third-party console (for example `https://app.inference.sh/settings/secrets/oauth/{slug}`).

```bash
curl -X PUT https://api.inference.sh/credentials/slack/app \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "values": {
      "SLACK_CLIENT_ID": "123.456",
      "SLACK_CLIENT_SECRET": "abc..."
    }
  }'
```

**Response:** `CredentialDTO` for the app row (`grant: "credentials"`).

| Error | HTTP | When |
|-------|------|------|
| `validation_error` | 400 | Provider does not sign in through an OAuth app, or a key in `values` is not part of that provider's app |
| `team_role_required` | 403 | Caller is not a workspace admin (or platform operator for a platform app) |

### Remove app

`DELETE /credentials/{provider}/app`

Deletes the caller's own OAuth app row for the provider (workspace or platform scope) and removes linked vault secrets. Requires the same admin roles as `PUT`. Responds `400` `validation_error` when one or more logins still reference the app — disconnect those connections first — and `404` when you have no app row of your own for the provider.

### Allowed scopes

An OAuth app can carry `allowed_scopes`: the list of scopes a login through that client may request. The platform's clients are restricted to the scopes the provider has approved them for (the platform's Google client, for example, to the scopes it passed Google's verification with); a workspace's or org's own client has no list and is unrestricted unless its admin sets one.

- **Empty or absent**: any scope. This is what a workspace's own client gets by default.
- **Set**: every scope a [connect](#connect-a-credential) or [add-scopes](#add-oauth-scopes) request names, after the auth method's `required_scopes` are merged in, must be on the list. Scopes are compared in the method's wire form, so an alias (`gmail.send`) and the provider's own form (`https://www.googleapis.com/auth/gmail.send`) name the same scope, on the list and in the request. The method's `required_scopes` (identity and refresh) are never refused.

The check runs before any authorize URL is built. On **connect**, the app is the one a new login uses: the caller's workspace client, else its org's, else the platform's. On **[add scopes](#add-oauth-scopes)**, it is the app that issued the login (`app_credential_id`); a login with no stored app uses the one a new connect would take. Widening a login never moves it to another client, because a refresh token works only with the app that issued it. A request for a scope outside that app's list is refused with `400` `validation_error`:

```json
{
  "error": "validation_error",
  "detail": "scope https://www.googleapis.com/auth/youtube.readonly is not approved for the platform's Google client; connect through your own OAuth app or ask an admin"
}
```

Either set up your own OAuth app for the provider with [`PUT /credentials/{provider}/app`](#set-or-update-app) (logins then go through it, with no list) or ask a platform admin to approve the scope. Platform admins edit the platform client's list in the admin console or as data through `PUT /admin/credentials/{provider}` with `allowed_scopes` (the response is the app row); a workspace admin edits their own client's list in the vault's OAuth app dialog (**allowed scopes**) or through `PUT /credentials/{provider}/app`.

A deploy whose `inf.yml` asks the platform's client for a scope outside its list is accepted with a `unapproved_platform_scope` [deploy warning](/docs/extend/credentials#deploy-checks); the connect is where it is refused.

### Optional app keys

Some providers take extra keys on the OAuth app (the auth method's `app_keys`: signing secrets, bot tokens, tenant ids). Store them in the same `values` map on `PUT /credentials/{provider}/app` as the client id and secret, for example `SLACK_SIGNING_SECRET` and `SLACK_BOT_TOKEN` for Slack ([Slack guide](/docs/credentials/slack)).

When the API needs one of these keys (channel webhook verification, outbound messages, provider-specific OAuth steps), it reads them from the OAuth app in use for the provider: the app a login was made through, else the one a new login would use (workspace, org, then platform). When the provider has such an app, its keys are the only source. Only when there is no app does the API fall back to a workspace secret with the same key name, then to keys on the caller's personal (`scope: "user"`) login for the provider.

---

## Connect a credential

`POST /credentials`

Starts OAuth, creates a service account, or registers an MCP server connection.

### OAuth providers

```json
{
  "provider": "google",
  "type": "oauth",
  "scopes": ["https://www.googleapis.com/auth/spreadsheets"]
}
```

To connect a **second** Google account on the same workspace, set `another_account: true` so the provider shows its account chooser:

```json
{
  "provider": "google",
  "type": "oauth",
  "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
  "another_account": true
}
```

**Client credentials.** When the slug's [auth method](/docs/api/rest/auth-providers#auth-method) is `oauth2` with `flow: client_credentials`, there is no browser step: the API exchanges the slug's client id and secret at the token endpoint and the response carries `credential` with `status: "connected"`. The token is re-minted before it expires. `api_key` and `http` methods are not connected here: save their fields with `POST /secrets` and `provider` set to the slug (the same path as catalogued API-key providers; see [Secrets API](/docs/api/rest/secrets)).

**Response** when the user must complete OAuth in a browser:

```json
{
  "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "state": "state_abc123",
  "code_verifier": "..."
}
```

Open `auth_url`, then complete the flow with `POST /credentials/oauth/complete`:

```json
{
  "provider": "google",
  "type": "oauth",
  "code": "authorization_code",
  "state": "state_abc123",
  "code_verifier": "..."
}
```

Some providers add extra query parameters on the redirect (QuickBooks `realmId`, Shopify `shop`, and so on). Pass them in optional `params` on the complete request (every key except `code` and `state`, which stay top-level). Auth method templates read them as `{{callback.*}}` — see [Callback query parameters on OAuth complete](/docs/api/rest/auth-providers#callback-query-parameters-on-oauth-complete).

Omit `code_verifier` when you started connect with the same `state` — the API stores the PKCE verifier from `POST /credentials` and claims it when completing OAuth. Pass `code_verifier` explicitly when your client held it locally and did not use the server-stored challenge.

| Error | HTTP | When | Meaning |
|-------|------|------|---------|
| `already_exists` | 409 | Connect | Non-OAuth provider already has a connected login for this owner (service account, API key, and similar single-slot types) |
| `already_exists` | 409 | Complete OAuth | Add-scopes flow completed with a **different** account than the login you started from — restart add-scopes for the intended account or connect the other account with `another_account: true` |
| `invalid_grant` | 400 | Complete OAuth | OAuth state or PKCE challenge expired — restart connect |
| `invalid_request` | 400 | Connect, Complete OAuth, add-scopes | Unknown provider; the detail names the slug (`there is no provider called "nemox"`) |
| `validation_error` | 400 | Connect, add scopes | A requested scope is not on the OAuth app's [allowed scopes](#allowed-scopes) (`scope … is not approved for the platform's Google client; connect through your own OAuth app or ask an admin`) |

### Account identity on OAuth complete

When the slug's auth method says how to learn the account ([`identifier`](/docs/api/rest/auth-providers#identity-and-disconnect) or `identity_hook`), completing OAuth must yield a non-empty account. If the provider answers the identity lookup without one (a **401** or **403**, or a **200** with no matching field), the connect fails and nothing is saved. The response is **500** `internal_error` with the generic detail `failed to complete OAuth flow`. The usual cause is an OAuth app or grant missing the scope the userinfo or lookup endpoint needs: fix the app permissions or scopes and connect again.

**Temporary lookup failures** are different. When the identity lookup times out, cannot reach the provider, or gets **408**, **429**, or **5xx**, and the lookup URL is the auth method's own (not one named by the token response), the login is saved without an account. The API asks the provider again when the login is next used, at most every 15 minutes, and then fills `account_id`, `account_identifier`, `account_name`, and metadata. When the lookup URL came from the token response, a temporary failure fails the connect like a refusal.

Auth methods that do not define `identifier` or `identity_hook` can still connect without an account id (unusual for user OAuth).

When reconnecting an `expired` or `error` credential, you can call `POST /credentials` with the same `provider` and an empty `scopes` array (or omit `scopes`). The API carries forward previously granted scopes in the OAuth consent URL so the user restores all permissions in one step instead of starting from zero.

Completing OAuth (`POST /credentials/oauth/complete`) for an existing login — reconnect, incremental scope grant, or MCP re-authorization — **updates that row in place**. The credential `id`, vault membership links, and anything that references the id stay put. New tokens, scopes, account fields, and `status` change. Only **disconnect** removes the row; the next connect creates a new id. One exception: a login saved without an account that later learns an account another login of the same owner already has is folded into the **older** login, and the newer `id` is retired (see [Multiple accounts per provider](#multiple-accounts-per-provider)).

### Required scopes on connect

OAuth slugs resolve through an [auth provider](/docs/api/rest/auth-providers) and its `oauth2` auth method. When you pass `scopes` on connect, the API still merges the method's `required_scopes` into the authorize URL. Those permissions are not optional: they let the platform read account id and display name after token exchange (and request refresh tokens when the method defines them).

Without them the provider can refuse the identity lookup, and then the connect fails and nothing is saved (see [Account identity on OAuth complete](#account-identity-on-oauth-complete)). Built-in providers (Google, Microsoft, X, Discord, GitLab, LinkedIn, Linear, Atlassian, Xero, and others) declare `required_scopes` on their platform row. A team's own auth provider should set `required_scopes` in `auth_methods` when identity templates depend on specific grants.

The same merge applies on [`POST /credentials/{provider}/scopes`](#add-oauth-scopes): requested scopes plus `required_scopes` go to the provider.

### Connection scope

Optional `connection_scope` on `POST /credentials` sets who owns the resulting credential. It is separate from `scopes`, which are OAuth permission scopes.

```json
{
  "provider": "google",
  "type": "oauth",
  "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
  "connection_scope": "team"
}
```

| `connection_scope` | Who can use it | Requirement |
|--------------------|----------------|-------------|
| `user` | Only the connecting user | Any member |
| `team` | The whole workspace | Workspace admin |
| `org` | All workspaces in the organization | Org admin, calling from the organization workspace |
| `platform` | Other workspaces' runs that the key's access list admits (store-reviewed versions on cloud) | Platform admin, calling from the platform workspace |

Omit `connection_scope` to use the provider's default: the scope the provider declares (`user` for MCP), `user` for `grant=credentials` providers, `team` otherwise.

Choose `connection_scope` at connect time, or on [`POST /secrets`](/docs/api/rest/secrets#create-secret) when saving a BYOK API key with `provider`. To change who owns an existing connection without revoking tokens, use [Change connection scope](#change-connection-scope).

For OAuth flows the chosen scope is stored server-side with the OAuth `state` and applied when `oauth/complete` runs. The completing client does not resend `connection_scope`.

| Error | HTTP | When |
|-------|------|------|
| `team_role_required` | 403 | `connection_scope: "team"` without workspace admin, or `"org"` without org admin |
| `validation_error` | 400 | An unknown `connection_scope`, `org` when the workspace is not part of an organization, or `org` / `platform` requested from a workspace other than the organization or platform workspace. `detail` says which. |
| `forbidden` | 403 | `connection_scope: "platform"` without platform admin |

### Service accounts

Google Sheets/Docs/Drive service accounts use the `google-sa` provider (separate from Google OAuth's `google` and GCP WIF's `gcp`):

```json
{
  "provider": "google-sa",
  "type": "service_account"
}
```

Returns `credential` when the connection is created immediately (no redirect). Declare `google-sa.files` in `inf.yml` (replaces the former `google.sa` and interim `gcp.sa` capability keys). Existing workspace connections that used `provider: "google"` or `provider: "gcp"` with `auth: "service_account"` were migrated to `google-sa` in a one-time database update, so no reconnect is required.

### Custom MCP servers

Connect an arbitrary HTTPS MCP endpoint (same flow as `belt mcp connect`):

```json
{
  "provider": "mcp",
  "type": "mcp",
  "metadata": {
    "server_url": "https://mcp.example.com"
  }
}
```

For bare origins, the API may resolve the MCP path via `/.well-known/mcp-server-card` or UCP discovery — see [Browsing connectors](/docs/connectors/browsing#custom-mcp-servers).

**CLI equivalent:** `belt credentials connect <provider>` (for example `belt credentials connect google`). The CLI resolves the provider from `GET /credentials/configs`. OAuth providers open a browser and poll until connected. **`api_key` providers** are not connected through this endpoint — the CLI collects each declared key and saves it with `POST /secrets` (`belt secrets set <KEY> --provider <provider>`). MCP servers use `belt mcp connect <slug>` instead. `belt creds` and `belt integrations` are aliases for `belt credentials`.

#### Bearer token / API key connect

When the remote MCP server accepts a pre-issued bearer token (API key, personal access token, or similar) instead of OAuth, pass it in `metadata.bearer_token`. The API skips OAuth discovery, stores the token as an encrypted workspace secret, and returns `credential` immediately:

```json
{
  "provider": "mcp",
  "type": "mcp",
  "metadata": {
    "server_url": "https://mcp.example.com",
    "bearer_token": "your-api-key-or-pat",
    "display_name": "My MCP Server"
  }
}
```

| Metadata field | Required | Description |
|----------------|----------|-------------|
| `server_url` | Yes | MCP endpoint or bare origin (resolved via discovery when needed) |
| `bearer_token` | Yes (for this flow) | Token sent as `Authorization: Bearer` on MCP requests |
| `display_name` | No | Label in the connected-apps list (defaults from server introspection or hostname) |

The connect response omits the token. Use `GET /credentials/mcp?identifier={server_url}` or `belt credentials get mcp` to confirm the connection is `connected` before calling tools.

→ [Browsing connectors — API key servers](/docs/connectors/browsing#api-key-servers)

#### Connect errors

When the API can't sign in to the server, connect answers `400` with a `validation_error` whose `detail` says what went wrong:

| Situation | `detail` (example) |
|-----------|--------------------|
| `server_url` missing, not absolute, or not `https` | `server_url must use https (got "http")` |
| The URL can't be reached | `Could not reach https://mcp.example.com. Check the URL is the server's MCP endpoint.` |
| The URL answers, but not as an MCP server with OAuth discovery (for example a web page or a settings page) | `https://app.example.com/settings/mcp did not answer as an MCP server with OAuth sign-in. Check the URL is the MCP endpoint (not a web page), connect with a token, or set up its OAuth app by hand with the authorize and token URLs.` |
| The authorization server publishes no usable metadata | `The sign-in server for https://mcp.example.com (https://auth.example.com) did not publish usable OAuth metadata. Set up its OAuth app by hand with the authorize and token URLs.` |

```json
{
  "type": "https://api.inference.sh/errors/validation_error",
  "title": "Bad Request",
  "status": 400,
  "detail": "https://app.example.com/settings/mcp did not answer as an MCP server with OAuth sign-in. ..."
}
```

When OAuth discovery doesn't work for a server, set its OAuth app up by hand with [`PUT /credentials/mcp/app`](#set-mcp-oauth-app) and connect again.

#### MCP OAuth client identity

When the remote MCP server requires OAuth, the API acts as a public OAuth client (PKCE, no client secret). Client identity is resolved in this order:

1. **BYOK**: `oauth_client_id` on a workspace MCP server registration, or a platform-managed secret keyed by hostname
2. **Client ID Metadata Document (CIMD, [SEP-991](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/991))**: when the authorization server advertises `client_id_metadata_document_supported`, the API uses the metadata document URL on the app origin as `client_id` (no Dynamic Client Registration)
3. **Dynamic Client Registration (DCR)**: registers a new client when the server exposes a registration endpoint and CIMD is unavailable

The CIMD document is public (no API key). It is served on the **app** origin, so `client_id` shares an origin with `client_uri` and the redirect URI (some authorization servers require that):

```
GET https://app.inference.sh/.well-known/mcp-client.json
```

The app serves this path by proxying the document the API builds at `GET https://api.inference.sh/.well-known/mcp-client-app.json` (same JSON body and `Cache-Control`; **502** with `Cache-Control: no-store` when the API does not answer).

```json
{
  "client_id": "https://app.inference.sh/.well-known/mcp-client.json",
  "client_name": "inference.sh",
  "client_uri": "https://app.inference.sh",
  "redirect_uris": ["https://app.inference.sh/settings/secrets/oauth/mcp"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

The `client_id` URL must match the document location. On self-hosted deployments, set `APP_URL` on the API to the public app base URL (for example `https://app.example.com`), and serve `/.well-known/mcp-client.json` on that origin from the API's `/.well-known/mcp-client-app.json`. If `APP_URL` is unset, CIMD is disabled and the connect flow falls back to DCR or BYOK credentials.

`GET https://api.inference.sh/.well-known/mcp-client.json` (the same document with the API origin as `client_id`) stays for MCP connections registered under that id before; they refresh with it.

When CIMD and DCR are unavailable (for example Microsoft Entra ID), connections need a registered OAuth **application** for the MCP server's host — see [Configure MCP OAuth app](#configure-mcp-oauth-app) below.

#### Authorization server profiles

MCP OAuth does not use an auth provider row. After discovery, the API selects an **authorization server profile** from the authorization server's metadata (issuer host, then authorize URL host, then token URL host). Unmatched servers use the standard profile (discovered endpoints, RFC 8707 `resource` on authorize, no scopes on refresh).

Profiles encode provider-specific rules: how authorize and token URLs are built from app fields, whether a `resource` parameter is sent, whether scopes are resent on refresh, extra app form fields, admin consent links, and how error responses are classified. The profile name is stored on the OAuth flow state and on the connection's `metadata.as_profile` after connect. Profile names are listed under `auth_server_profiles` in [`GET /auth-providers/hooks`](/docs/api/rest/auth-providers#list-auth-provider-hooks).

Today the API registers `microsoft_entra` for Microsoft Entra ID authorities (`login.microsoftonline.com` and related hosts). Entra apps may include a `tenant_id` field for single-tenant registrations; multi-tenant platform apps use the `organizations` tenant segment. Entra does not use the `resource` parameter and repeats scopes on token refresh.

### Configure MCP OAuth app

Some MCP servers sign in through an authorization server that offers neither CIMD nor dynamic client registration. Others publish no OAuth discovery metadata at all. For both, configure an OAuth application **per MCP host** (not per workspace MCP registration row); for the second, the app also carries the authorize and token URLs ([endpoints by hand](#endpoints-by-hand)). An app can be set for any server, not only one that needs it: connections then sign in through it instead of registering a client. Resolution order for a new connection matches native providers: workspace app, then org app, then platform app.

Requires `credentials:read` (get status) or `credentials:write` (create or update). Who may manage an app depends on `manage_scopes` in the status response (workspace admin, org admin from the org workspace, or platform operator).

#### Get MCP OAuth app status

`GET /credentials/mcp/app?server_url={url}`

Returns how connections to that MCP server sign in, for connect and admin UIs.

**Response** (`MCPOAuthAppStatus`):

| Field | Description |
|-------|-------------|
| `server_url` | Echo of the query URL (after normalization) |
| `account` | MCP host used as the app key (for example `mcp.dev.azure.com`) |
| `app` | OAuth app a new connection would use (team, org, or platform), if any |
| `apps` | Every app configured for this host that the caller may use |
| `manage_scopes` | Which scopes (`team`, `org`, `platform`) the caller may configure; empty means ask an admin |
| `redirect_url` | Redirect URI the OAuth app must register (MCP callback) |
| `authorization_server` | Issuer from discovery, when known |
| `needs_app` | `true` when discovery found no DCR and an app must be configured before connect |
| `app_fields` | Extra non-secret keys the authorization server's profile requires on the app (for Entra: `tenant_id` for single-tenant apps) |
| `admin_consent_url` | Link a Microsoft admin opens once to approve the app for their tenant, when the profile supports admin consent and an app exists |
| `scopes` | OAuth scopes the next connection will request |
| `recommended_headers` | Static MCP request headers suggested for this host from the public directory entry (when set), for example toolset filters |

Each app in `app` / `apps` (`MCPOAuthAppDTO`) includes `client_id`, `has_secret`, `scope`, `status`, and `fields` (values for `app_fields`). Secrets are never returned.

```bash
curl "https://api.inference.sh/credentials/mcp/app?server_url=https%3A%2F%2Fmcp.dev.azure.com%2Fcontoso" \
  -H "Authorization: Bearer inf_your_key"
```

#### Set MCP OAuth app

`PUT /credentials/mcp/app`

**Body** (`MCPOAuthAppRequest`):

| Field | Description |
|-------|-------------|
| `server_url` | MCP server URL (host selects the app row) |
| `scope` | Owner: `team` (default — caller's workspace), `org`, or `platform` |
| `client_id` | OAuth client id |
| `client_secret` | OAuth client secret; omit or send blank to keep the stored secret |
| `fields` | Values for keys in `app_fields`, plus the [endpoints by hand](#endpoints-by-hand) keys |

Only keys declared by the active authorization server profile, and the endpoint keys below, are accepted. The profile validates field values (for example Entra tenant id format).

#### Endpoints by hand

For a server whose OAuth metadata is missing or wrong, put the authorization server's endpoints on the app. With both endpoints set, connecting skips discovery and signs in through them.

| `fields` key | Description |
|--------------|-------------|
| `authorization_endpoint` | The authorize URL, for example `https://auth.example.com/oauth/authorize`. `https` (`http` only on localhost) |
| `token_endpoint` | The token URL, for example `https://auth.example.com/oauth/token`. `https` (`http` only on localhost) |
| `scopes` | Scopes to request, space or comma separated. Optional |

A key sent with an empty value clears it; a key left out keeps its stored value. Clearing both endpoints puts the server back on discovery.

```bash
curl -X PUT https://api.inference.sh/credentials/mcp/app \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "server_url": "https://mcp.example.com/mcp",
    "client_id": "your-client-id",
    "client_secret": "your-client-secret",
    "fields": {
      "authorization_endpoint": "https://auth.example.com/oauth/authorize",
      "token_endpoint": "https://auth.example.com/oauth/token",
      "scopes": "read write"
    }
  }'
```

Connections made this way send the client id and secret to the token URL with HTTP Basic auth, and use PKCE. They send no RFC 8707 `resource` parameter and don't check an `iss` on the redirect, since there is no discovered issuer to check against. The server URL still has to be the MCP endpoint: tools are listed and called there with the token.

#### Remove MCP OAuth app

`DELETE /credentials/mcp/app?server_url={url}&scope={team|org|platform}`

Removes the app for that host and scope. Refused while active logins still reference the app.

Vault keys for MCP apps use the `MCP_` prefix: `MCP_CLIENT_ID`, `MCP_CLIENT_SECRET`, and `MCP_<FIELD>` for each entry in `app_fields` (for example `MCP_TENANT_ID`) and each endpoint by hand (`MCP_AUTHORIZATION_ENDPOINT`, `MCP_TOKEN_ENDPOINT`, `MCP_SCOPES`).

### OAuth callback without an authorization code

`POST /oauth-callback/notice`

Public endpoint (no API key). The web app's MCP OAuth callback uses this when the redirect carries **no** `code` — typically an error from the authorization server or a Microsoft **admin consent** landing that approves the app without connecting a user.

**Body** (`OAuthCallbackNoticeRequest`):

| Field | Description |
|-------|-------------|
| `provider` | Provider key (`mcp` for MCP connectors) |
| `state` | OAuth `state` from the redirect, when present |
| `params` | Other query parameters from the redirect (error codes, descriptions, Entra `admin_consent` flags, and so on) |

**Response** (`OAuthCallbackNotice`):

| Field | Description |
|-------|-------------|
| `outcome` | `error` (sign-in failed) or `approved` (admin approved the app for their organization; no credential was created) |
| `message` | User-facing explanation |
| `admin_consent_url` | Optional link to send to an admin when consent is still required |
| `command` | Optional one-time shell command an admin can run (for example creating a service principal) when directory setup data provides a resource application id |

---

## Set default account

`PUT /credentials/{provider}/default?identifier={account_id_or_label}`

Makes one login the default for its owner and provider. Other logins for the same provider stay connected with `is_primary: false`. Requires `credentials:write` and permission to manage the login (same as [disconnect](#disconnect)).

```bash
curl -X PUT 'https://api.inference.sh/credentials/google/default?identifier=user%40company.com' \
  -H "Authorization: Bearer inf_your_key"
```

**Response:** the updated `CredentialDTO` with `is_primary: true`.

`identifier` is required. Omitting it returns a validation error.

---

## Add OAuth scopes

`POST /credentials/{provider}/scopes`

Request additional scopes for an existing OAuth credential (incremental authorization). Optional `identifier` query parameter selects which account to extend (same as [`GET /credentials/{provider}`](#list-connected-credentials)); omit it to use the default login.

```json
{
  "scopes": ["https://www.googleapis.com/auth/drive.readonly"]
}
```

**Response** (same shape as [Connect a credential](#connect-a-credential)):

```json
{
  "auth_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "state": "st_abc123",
  "code_verifier": ""
}
```

Redirect the user to `auth_url` to approve the new scopes, then complete the flow with `POST /credentials/oauth/complete` (or your app's OAuth callback handler). Omit `code_verifier` when the API returned a non-empty value from connect or add-scopes — it is stored with the `state` challenge.

The authorize URL and the OAuth complete use the OAuth app that issued the login (`app_credential_id`), not whichever app a new connect would pick today, so token refresh stays on the client that issued the refresh token. A login saved before apps were recorded uses the app a new connect would take. If the issuing app row was deleted, add scopes answers `400` `validation_error` (`the {provider} app this login was made through is gone; connect again`).

The requested scopes are held to that app's [allowed scopes](#allowed-scopes): a scope the platform's client is approved for does not help when your workspace's app issued the login, and the other way round.

### Incremental vs full re-authorization

Providers that support incremental authorization (for example `google`, `microsoft`, `slack`, `salesforce`, `discord`) return a consent URL that adds only the requested scopes. Existing grants are preserved.

Providers that do not support incremental auth (for example `x`) fall back to a full re-authorization URL that includes **all** existing scopes plus the new ones. The user must approve the combined scope list again.

### Account pre-selection (`login_hint`)

When the credential has an `account_identifier` (email or username from the original connect), add-scopes `auth_url` values for **Google** and **Microsoft** include `login_hint` set to that identifier. The provider can skip the account chooser and go straight to consent for the same account. Custom OAuth [auth providers](/docs/api/rest/auth-providers) can set `account_hint_param` (for example `login_hint`) to get the same behavior.

`login_hint` is not sent on the initial `POST /credentials` connect URL, only when re-authorizing for more scopes. It is omitted when `account_identifier` is empty — which is why OAuth complete refuses to save a login when the scheme expects an account id but the provider did not supply one (see [Account identity on OAuth complete](#account-identity-on-oauth-complete)). A login saved during a temporary lookup outage gets `account_identifier` once it is next used, so a later add-scopes round trip can send `login_hint`.

---

## Change connection scope

`PUT /credentials/{provider}/scope`

Rescopes an existing connection in place — same credential row and tokens, new owner level. Requires **`credentials:write`**. The same access rules apply as on connect: you must be allowed to use the connection at its current scope, and you must meet the admin requirements for the target scope (`team` needs workspace admin, `org` needs org admin, and so on).

**Body:**

```json
{
  "scope": "team"
}
```

| `scope` | Who can use the connection after the change |
|---------|---------------------------------------------|
| `user` | Only the member who connected it |
| `team` | The whole workspace |
| `org` | All workspaces in the organization |
| `platform` | All workspaces on the platform |

**MCP connections:** Pass `identifier` as a query parameter with the MCP `server_url` (same as [Disconnect](#disconnect)):

```bash
curl -X PUT 'https://api.inference.sh/credentials/mcp/scope?identifier=https%3A%2F%2Fmcp.linear.app%2Fsse' \
  -H "Authorization: Bearer inf_your_key" \
  -H "Content-Type: application/json" \
  -d '{"scope":"team"}'
```

**Rules:**

- Only the member who connected a row may narrow a **shared** connection to `user` (personal). A workspace admin cannot re-scope someone else's team connection to their own user row (**403** `forbidden`).
- A member without workspace admin cannot widen a personal connection to `team`.
- When the workspace already has a shared connection for the same provider account, rescoping your personal row to that level returns **409** `conflict` — disconnect the duplicate or pick a different target scope.
- Call from the workspace that owns the connection. From any other workspace the API returns **400** `validation_error`.
- The credential's default vault membership is updated to match the new scope.

**Response:** `CredentialDTO` for the updated connection.

| Error | HTTP | When |
|-------|------|------|
| `team_role_required` | 403 | Target scope needs admin and the caller does not have it |
| `forbidden` | 403 | Caller cannot access the connection at its current scope, or narrows someone else's shared row to `user` |
| `conflict` | 409 | Target scope already has a shared row for this account |
| `validation_error` | 400 | Missing or unknown `scope`, a target scope this workspace cannot connect at, or the call comes from a workspace that does not own the connection |
| `not_found` | 404 | No matching connection for `provider` (and `identifier` when required) |

---

## Disconnect

`DELETE /credentials/{provider}`

Revokes tokens with the provider when supported, then soft-deletes the workspace's connection for that provider. Vault membership links for the credential are removed as well. Apps that require the credential will fail until it is reconnected.

When the disconnected login was the default (`is_primary: true`), the owner's oldest remaining login for that provider becomes the new default.

An optional `identifier` query parameter selects one connection by `account_id` or `account_identifier`. `belt mcp disconnect` uses it with the MCP server URL (the stable id for MCP logins):

```bash
curl -X DELETE 'https://api.inference.sh/credentials/mcp?identifier=https%3A%2F%2Fmcp.linear.app%2Fsse' \
  -H "Authorization: Bearer inf_your_key"
```

With `identifier`, the API walks the same ladder as a lookup without one, matching the named account on each rung (see [Multiple accounts per provider](#multiple-accounts-per-provider)). Platform-scope logins are on that ladder only in the platform workspace.

CLI equivalent: `belt credentials disconnect <provider>` (for example `belt credentials disconnect google` or `belt credentials disconnect google-sa`).

---

## Check requirements (pre-flight)

`POST /credentials/check`

Validate secrets and credentials **before** calling `POST /run`. Returns **200** with a structured result (not HTTP 412):

```json
{
  "secrets": [
    { "key": "OPENAI_API_KEY", "optional": false }
  ],
  "credentials": [
    { "key": "google.sheets", "optional": false }
  ]
}
```

**Response:**

```json
{
  "satisfied": false,
  "errors": [
    {
      "type": "secret",
      "key": "OPENAI_API_KEY",
      "message": "Missing secret: OPENAI_API_KEY",
      "action": { "type": "add_secret" }
    },
    {
      "type": "credential",
      "key": "google.sheets",
      "message": "connect Google Account to use this app",
      "action": {
        "type": "connect",
        "provider": "google",
        "provider_name": "Google Account",
        "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
        "scope_descriptions": {
          "https://www.googleapis.com/auth/spreadsheets": "View and manage your spreadsheets"
        }
      }
    },
    {
      "type": "scope",
      "key": "google.sheets",
      "message": "Google Account connected but missing permissions: [https://www.googleapis.com/auth/spreadsheets]",
      "action": {
        "type": "add_scopes",
        "provider": "google",
        "provider_name": "Google Account",
        "scopes": ["https://www.googleapis.com/auth/spreadsheets"],
        "scope_descriptions": {
          "https://www.googleapis.com/auth/spreadsheets": "View and manage your spreadsheets"
        }
      }
    }
  ]
}
```

Each `errors[]` entry has `type` (`secret`, `credential`, or `scope`), `key`, `message`, and optional `action` (`add_secret`, `connect`, `add_scopes`).

| `action.type` | Meaning | Web app deep link |
|---------------|---------|---------------------|
| `add_secret` | User must add a workspace secret | [vaults → secrets](https://app.inference.sh/vaults?tab=secrets) |
| `connect` | User must connect a credential | `https://app.inference.sh/credentials/{slug}` |
| `add_scopes` | User must grant additional OAuth scopes | `https://app.inference.sh/credentials/{slug}?grant={scope}`, with `grant` repeated for each scope in `action.scopes` ([permission grant links](/docs/credentials/overview#permission-grant-links)) |

| `action` field | When present | Purpose |
|----------------|--------------|---------|
| `provider` | `connect`, `add_scopes` | Provider key for API connect calls and CLI (for example `google`, `google-sa`, `gcp`, `slack`) |
| `provider_name` | `connect`, `add_scopes` | Human-readable label for UI copy (for example `Google Account`, `Slack`) — also used in `message` |
| `scopes` | `connect`, `add_scopes` | OAuth scopes to request or add |
| `scope_descriptions` | `connect`, `add_scopes` | Map of scope key → friendly display name from provider capabilities (for UI lists without hardcoded labels) |
| `instructions` | `connect`, `add_scopes`, `add_secret` | The provider's [setup instructions](/docs/api/rest/auth-providers#setup-instructions) (markdown, links allowed), when it has them. Show them for step-by-step guidance; `message` is the one-line summary |

Use `provider` (not `provider_name`) when calling `POST /credentials` or `belt credentials connect`. Use `provider_name` or `message` when showing setup prompts to end users.

For dashboard links, resolve `{slug}` from `GET /credentials/configs` — match `provider` and the capability `type` from `GET /credentials/capabilities`. For most providers `slug` equals `provider`; Google OAuth uses `google`, the Sheets/Docs/Drive service account uses `google-sa`, and GCP WIF uses `gcp`. → [Credentials overview — detail page](/docs/credentials/overview#credential-detail-page)

When requirements are missing at run time, `POST /run` and `POST /apps/run` return **412** with a plain JSON body (not problem+json):

```json
{
  "satisfied": false,
  "errors": [ /* the same RequirementError objects as check */ ]
}
```

Other routes refused for incomplete setup (for example an agent message whose run needs a credential) answer **412** `application/problem+json` with code `requirements_not_met`; the same list is the problem's top-level `errors` member and is repeated under `meta.errors`. Use check when you want the list without submitting a task (it always answers **200**).

**CLI:** `belt app run` and `belt agent run` catch **412** responses and print categorized hints — `belt secrets set` for missing secrets, `belt credentials connect` for OAuth providers, `belt mcp connect` for MCP requirements, or `connect at: https://app.inference.sh/vaults` when the requirement names no provider. Next to `belt credentials connect <provider>` the CLI also prints `or: https://app.inference.sh/vaults/<provider>`.

→ [Secrets](/docs/extend/secrets) · [CLI setup](/docs/extend/cli-setup) · [Troubleshooting](/docs/extend/troubleshooting)

---

## MCP tool proxy

After connecting an MCP server (`provider: "mcp"`), call tools over HTTP:

| Endpoint | Method | Scope | Purpose |
|----------|--------|-------|---------|
| `/mcps/{slug}/tools` | GET | `credentials:read` | List tools exposed by the server |
| `/mcps/{slug}/tools/{tool}` | POST | `credentials:write` | Invoke a tool (body is forwarded to the MCP server) |

`{slug}` is the MCP server slug (for example `linear` from the connector directory). Tool calls are recorded in your workspace's credential history.

CLI equivalent: `belt mcp tools <slug>` and `belt mcp run <slug>:<tool>`.

→ [Connectors browsing](/docs/connectors/browsing) · [MCP server](/docs/connectors/mcp-server)

---

## JavaScript SDK

```typescript
import { inference } from '@inferencesh/sdk';

const client = inference({ apiKey: 'inf_your_key' });

const caps = await client.credentials.getCapabilities();
const { auth_url } = await client.credentials.connect({
  provider: 'google',
  type: 'oauth',
  scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
await client.credentials.checkRequirements({
  secrets: [{ key: 'OPENAI_API_KEY' }],
  credentials: [{ key: 'google.sheets' }],
});
```

→ [SDK overview](/docs/api/sdk/overview) — `client.credentials` namespace

---

## Related

- [REST overview](/docs/api/rest/overview) — authentication and response format
- [Entitlements](/docs/api/rest/entitlements) — connector limits on your plan
- [Agent connector tools](/docs/api/agent/connector-tools) — use credentials from agents
