> ## Documentation Index
> Fetch the complete documentation index at: https://api-docs.voicecheap.ai/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Exchange an authorization code or rotate a refresh token

> Obtain short-lived MCP access tokens using a public client's S256 authorization code or its current refresh token.

Obtain short-lived MCP access tokens using a public client's S256 authorization code or its current refresh token.

```http theme={null}
POST https://api.voicecheap.ai/oauth/token
```

## Authentication

No HTTP client authentication. Send client\_id in the URL-encoded body. Any Authorization header, client\_secret, or client\_assertion is rejected.

## Request body

<Expandable title="grant_type: authorization_code">
  <ParamField body="client_id" type="string" required>
    The client\_id returned by dynamic registration, or the exact public HTTPS URL of the client metadata document.

    Minimum length: 1. Maximum length: 2048. Pattern: `^[^\u0000-\u001F\u007F]+$`.
  </ParamField>

  <ParamField body="resource" type="string" required>
    Required exact resource audience: use the resource value from this environment's protected-resource metadata. In production this is [https://api.voicecheap.ai/mcp](https://api.voicecheap.ai/mcp). A different origin, path, or trailing slash is rejected.

    Format: "uri".
  </ParamField>

  <ParamField body="grant_type" type="field" required>
    Value defined by the complete input schema.

    Required value: "authorization\_code".
  </ParamField>

  <ParamField body="code" type="string" required>
    Single-use code returned to the exact registered callback.

    Minimum length: 1. Maximum length: 256. Pattern: `^mcp_code_[A-Za-z0-9_-]{43}$`.
  </ParamField>

  <ParamField body="redirect_uri" type="string" required>
    Must exactly equal the callback bound to this code and registration.

    Minimum length: 1. Maximum length: 2048. Pattern: `^[^\u0000-\u001F\u007F]+$`.
  </ParamField>

  <ParamField body="code_verifier" type="string" required>
    Original PKCE verifier used to calculate the authorization request's S256 code\_challenge.

    Minimum length: 43. Maximum length: 128. Pattern: `^[A-Za-z0-9._~-]{43,128}$`.
  </ParamField>
</Expandable>

<Expandable title="grant_type: refresh_token">
  <ParamField body="client_id" type="string" required>
    The client\_id returned by dynamic registration, or the exact public HTTPS URL of the client metadata document.

    Minimum length: 1. Maximum length: 2048. Pattern: `^[^\u0000-\u001F\u007F]+$`.
  </ParamField>

  <ParamField body="resource" type="string" required>
    Required exact resource audience: use the resource value from this environment's protected-resource metadata. In production this is [https://api.voicecheap.ai/mcp](https://api.voicecheap.ai/mcp). A different origin, path, or trailing slash is rejected.

    Format: "uri".
  </ParamField>

  <ParamField body="grant_type" type="field" required>
    Value defined by the complete input schema.

    Required value: "refresh\_token".
  </ParamField>

  <ParamField body="refresh_token" type="string" required>
    The current unconsumed refresh token from a client registered for refresh\_token.

    Minimum length: 1. Maximum length: 256. Pattern: `^mcp_refresh_[A-Za-z0-9_-]{43}$`.
  </ParamField>

  <ParamField body="scope" type="string">
    Space-delimited subset of the original permissions, optionally including offline\_access. Unsupported names, empty values, and added permissions are rejected; duplicates are removed. An explicit scope keeps only the listed product and identity permissions and may remove every product permission. Omit scope to retain the current grant, except identity permissions are dropped if OpenID Connect is unavailable.

    Minimum length: 1. Maximum length: 2048. Pattern: `^ *(?:account:read|usage:read|settings:read|settings:write|team:read|team:write|projects:read|projects:write|projects:delete|translations:write|transcription:read|transcription:write|subtitles:read|subtitles:write|tts:read|tts:write|voices:read|voices:write|images:read|images:write|thumbnails:read|thumbnails:write|folders:read|folders:write|glossary:read|glossary:write|webhooks:read|webhooks:write|publishing:read|publishing:write|offline_access|openid|email)(?: +(?:account:read|usage:read|settings:read|settings:write|team:read|team:write|projects:read|projects:write|projects:delete|translations:write|transcription:read|transcription:write|subtitles:read|subtitles:write|tts:read|tts:write|voices:read|voices:write|images:read|images:write|thumbnails:read|thumbnails:write|folders:read|folders:write|glossary:read|glossary:write|webhooks:read|webhooks:write|publishing:read|publishing:write|offline_access|openid|email))* *$`.
  </ParamField>
</Expandable>

<Accordion title="Technical schema">
  ```json theme={null}
  {
    "oneOf": [
      {
        "type": "object",
        "properties": {
          "client_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "pattern": "^[^\\u0000-\\u001F\\u007F]+$",
            "description": "The client_id returned by dynamic registration, or the exact public HTTPS URL of the client metadata document."
          },
          "resource": {
            "type": "string",
            "format": "uri",
            "description": "Required exact resource audience: use the resource value from this environment's protected-resource metadata. In production this is https://api.voicecheap.ai/mcp. A different origin, path, or trailing slash is rejected.",
            "examples": [
              "https://api.voicecheap.ai/mcp"
            ]
          },
          "grant_type": {
            "const": "authorization_code"
          },
          "code": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "pattern": "^mcp_code_[A-Za-z0-9_-]{43}$",
            "description": "Single-use code returned to the exact registered callback."
          },
          "redirect_uri": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "pattern": "^[^\\u0000-\\u001F\\u007F]+$",
            "description": "Must exactly equal the callback bound to this code and registration."
          },
          "code_verifier": {
            "type": "string",
            "minLength": 43,
            "maxLength": 128,
            "pattern": "^[A-Za-z0-9._~-]{43,128}$",
            "description": "Original PKCE verifier used to calculate the authorization request's S256 code_challenge."
          }
        },
        "required": [
          "grant_type",
          "client_id",
          "resource",
          "code",
          "redirect_uri",
          "code_verifier"
        ],
        "not": {
          "anyOf": [
            {
              "required": [
                "client_secret"
              ]
            },
            {
              "required": [
                "client_assertion"
              ]
            }
          ]
        },
        "additionalProperties": true
      },
      {
        "type": "object",
        "properties": {
          "client_id": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "pattern": "^[^\\u0000-\\u001F\\u007F]+$",
            "description": "The client_id returned by dynamic registration, or the exact public HTTPS URL of the client metadata document."
          },
          "resource": {
            "type": "string",
            "format": "uri",
            "description": "Required exact resource audience: use the resource value from this environment's protected-resource metadata. In production this is https://api.voicecheap.ai/mcp. A different origin, path, or trailing slash is rejected.",
            "examples": [
              "https://api.voicecheap.ai/mcp"
            ]
          },
          "grant_type": {
            "const": "refresh_token"
          },
          "refresh_token": {
            "type": "string",
            "minLength": 1,
            "maxLength": 256,
            "pattern": "^mcp_refresh_[A-Za-z0-9_-]{43}$",
            "description": "The current unconsumed refresh token from a client registered for refresh_token."
          },
          "scope": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "pattern": "^ *(?:account:read|usage:read|settings:read|settings:write|team:read|team:write|projects:read|projects:write|projects:delete|translations:write|transcription:read|transcription:write|subtitles:read|subtitles:write|tts:read|tts:write|voices:read|voices:write|images:read|images:write|thumbnails:read|thumbnails:write|folders:read|folders:write|glossary:read|glossary:write|webhooks:read|webhooks:write|publishing:read|publishing:write|offline_access|openid|email)(?: +(?:account:read|usage:read|settings:read|settings:write|team:read|team:write|projects:read|projects:write|projects:delete|translations:write|transcription:read|transcription:write|subtitles:read|subtitles:write|tts:read|tts:write|voices:read|voices:write|images:read|images:write|thumbnails:read|thumbnails:write|folders:read|folders:write|glossary:read|glossary:write|webhooks:read|webhooks:write|publishing:read|publishing:write|offline_access|openid|email))* *$",
            "description": "Space-delimited subset of the original permissions, optionally including offline_access. Unsupported names, empty values, and added permissions are rejected; duplicates are removed. An explicit scope keeps only the listed product and identity permissions and may remove every product permission. Omit scope to retain the current grant, except identity permissions are dropped if OpenID Connect is unavailable.",
            "examples": [
              "account:read projects:read offline_access"
            ]
          }
        },
        "required": [
          "grant_type",
          "client_id",
          "resource",
          "refresh_token"
        ],
        "not": {
          "anyOf": [
            {
              "required": [
                "client_secret"
              ]
            },
            {
              "required": [
                "client_assertion"
              ]
            }
          ]
        },
        "additionalProperties": true
      }
    ]
  }
  ```
</Accordion>

## Request examples

Follow the documented encoding and authentication for this route. The action API's envelope and idempotency rules apply only to action invocations.

<RequestExample>
  ```bash cURL theme={null}
  curl --request POST 'https://api.voicecheap.ai/oauth/token' \
    --header 'Content-Type: application/x-www-form-urlencoded' \
    --data-urlencode 'grant_type=authorization_code' \
    --data-urlencode 'client_id=mcp_client_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' \
    --data-urlencode 'code=mcp_code_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC' \
    --data-urlencode 'redirect_uri=https://assistant.example.com/oauth/callback' \
    --data-urlencode 'code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk' \
    --data-urlencode 'resource=https://api.voicecheap.ai/mcp'
  ```

  ```javascript JavaScript theme={null}
  const input = {
    grant_type: 'authorization_code',
    client_id: 'mcp_client_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA',
    code: 'mcp_code_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC',
    redirect_uri: 'https://assistant.example.com/oauth/callback',
    code_verifier: 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk',
    resource: 'https://api.voicecheap.ai/mcp',
  };
  const response = await fetch('https://api.voicecheap.ai/oauth/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams(input),
    redirect: 'manual',
  });
  console.log(response.status, await response.text());
  ```

  ```python Python theme={null}
  import os
  import requests

  response = requests.request(
      "POST",
      "https://api.voicecheap.ai/oauth/token",
      headers={"Content-Type":"application/x-www-form-urlencoded"},
      data={"grant_type": "authorization_code", "client_id": "mcp_client_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "code": "mcp_code_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", "redirect_uri": "https://assistant.example.com/oauth/callback", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk", "resource": "https://api.voicecheap.ai/mcp"},
      timeout=120,
      allow_redirects=False,
  )
  print(response.status_code, response.text)
  ```
</RequestExample>

## Response

An opaque Bearer access token, its remaining lifetime in seconds, granted scopes, and a refresh token when the client registered refresh\_token. expires\_in is at most 900 and may be smaller near grant expiry.

```json HTTP 200 theme={null}
{
  "access_token": "mcp_access_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "mcp_refresh_EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE",
  "scope": "account:read projects:read offline_access"
}
```

## Response headers

```json theme={null}
{
  "Cache-Control": "no-store",
  "Pragma": "no-cache"
}
```

## Operation notes

* Recognized scalar parameters must be supplied once as nonempty strings without ASCII control characters or DEL. Repeated query/form fields decode to arrays and are rejected when read as scalars.
* Use URL-encoded form data, such as curl --data-urlencode per field. A JSON token body is rejected with invalid\_request.
* The resource audience is required for both grant types and must exactly match the MCP URL of the environment that issued the code or refresh token.
* Authorization-code exchange requires the original client\_id, exact redirect\_uri, and matching S256 code\_verifier. Codes expire after 2 minutes and are single-use.
* The token endpoint rejects every supplied Authorization header, including Basic and Bearer, plus any client\_secret or client\_assertion body field.
* Access tokens last at most 15 minutes. With refresh\_token registered, the connection and refresh family expire 30 days after the user approved it; rotation does not extend that deadline.
* Whether refresh\_token is returned depends on the registered grant types. It does not depend on including offline\_access in the authorization request. Returned scope appends offline\_access whenever a refresh token is returned.
* To renew, send grant\_type=refresh\_token with client\_id, the current refresh\_token, resource, and optionally a narrowed scope. Atomically replace the stored refresh token with the new value.
* Reusing a consumed authorization code or rotated refresh token revokes the whole connection, including all of its access tokens. Do not concurrently redeem the same code or refresh token.
* Refresh requests never add permissions. With an explicit scope, only the requested product and identity permissions are retained; offline\_access does not preserve omitted product permissions.
* Tokens are bound to the granting user, client, resource audience, current team ownership, and active paid access. A revoked grant or changed ownership requires reconnecting.
* The public token limit is 60 requests per minute per client IP, shared across workers. URL-encoded bodies are limited to 32 KiB.
* OAuth failures return error and error\_description. Body-parser failures can be returned earlier in the integration parser's error form.
* openid and email are identity permissions, separate from product permissions, and are accepted only when advertised. email requires openid. During initial authorization, a request containing only identity scopes uses the default product permissions shown on the consent screen.
* The client\_id returned by dynamic registration, or the exact public HTTPS URL of the client metadata document.
* When openid is granted, authorization-code exchange also returns an RS256-signed id\_token. Validate its signature, issuer, audience, expiry, and nonce when supplied. Granted identity scopes appear in the token response scope; refresh responses do not issue a new id\_token.
* If OpenID Connect becomes unavailable, refreshing without scope keeps the product permissions and removes identity permissions. Explicitly requesting unavailable identity scopes returns invalid\_scope.
* If the account was deleted or disabled, the token endpoint returns invalid\_grant with HTTP 400.

## Errors

| HTTP status | Code | Meaning or recovery |
| - | - | - |
| 400 | `invalid_request` | The body or a recognized parameter is invalid, missing, duplicated, or has the wrong encoding. |
| 400 | `invalid_client` | The client\_id is unknown, client HTTP authentication was supplied, or a client\_secret/client\_assertion was supplied. |
| 400 | `invalid_target` | The resource audience is missing or does not exactly match the MCP resource. |
| 400 | `unsupported_grant_type` | The grant is unsupported, or refresh\_token was not registered for this client. |
| 400 | `invalid_grant` | The code or token is invalid, expired, revoked, replayed, mismatched, or no longer authorized for the current account/team. |
| 400 | `invalid_scope` | A refresh request attempts to add permissions or contains unsupported scopes. |
| 413 | `REQUEST_TOO_LARGE` | The request exceeds the allowed body size. JSON is limited to 1 MiB and URL-encoded forms to 32 KiB. |
| 415 | `UNSUPPORTED_CONTENT_TYPE` | The integration body parser accepts JSON or URL-encoded forms only. The route may require one of those encodings specifically. |
| 429 | `temporarily_unavailable` | The public OAuth request rate limit was reached. Wait for the current window before retrying. |
| 500 | `server_error` | Authorization is temporarily unavailable. Retry later. |


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.