# OAuth 2.1 for apps and AI clients

_Category: Authentication_

Use OAuth when your software acts for a HelpCenter.io user: an integration that other teams connect to their help centers, or an AI assistant. The person signs in to HelpCenter.io, approves your app and picks which of their help centers it may reach. Your app gets an access token for the REST API, and a refresh token that keeps it connected.

For scripts that work with your own help center, an [API key](https://developers.helpcenter.io/content/api-keys) is simpler. AI assistants such as Claude connect through the HelpCenter.io MCP server, which runs this flow for them: see [Connect an AI assistant to your help center](https://developers.helpcenter.io/content/connect-an-ai-assistant-to-your-help-center).

| At a glance | Details |
| --- | --- |
| Authorization server | `https://helpcenter.io`, discovery at `https://helpcenter.io/.well-known/oauth-authorization-server` |
| Grants | Authorization code with PKCE, and refresh token. No implicit, password or client credentials grant. |
| Clients | Dynamic registration (public or confidential), Client ID Metadata Documents, or a client HelpCenter.io registers for you |
| Access token | A signed JWT, valid for 1 hour. Send it as `Authorization: Bearer` to `https://api.helpcenter.io/v1`. |
| Refresh token | Valid for 30 days. Each refresh returns a new pair and revokes the old one. |
| Revoking | The person disconnects your app in **Connected Apps**. There is no revocation or introspection endpoint. |

## Before you start

- **A redirect URI.** An `https` URL, or for a native or command-line app, `http` on `127.0.0.1`, `[::1]` or `localhost`. Dynamic registration refuses custom schemes such as `myapp://`.
- **The scopes your app needs.** See [Scopes](https://developers.helpcenter.io/content/scopes).
- **A server or a native app.** The OAuth endpoints on `helpcenter.io` send no CORS headers, so JavaScript in a web page can't call them. Exchange and refresh tokens on your server.
- **A HelpCenter.io account that owns a help center**, to test with. OAuth works on every plan. Tokens for the MCP server work only for help centers on the Growth or Catalyst plan.

## How the flow works

1. You register your app once and get a `client_id`.
2. Your app creates a PKCE verifier and sends the person to HelpCenter.io's authorization endpoint.
3. The person signs in, reviews the permissions, picks help centers and clicks **Allow**.
4. HelpCenter.io redirects the browser back to your app with a `code`.
5. Your app exchanges the code for an access token and a refresh token.
6. Your app calls the API with the access token, and refreshes it when it expires.

The steps below walk through the flow with cURL, using the endpoints from the discovery document.

## Discovery

Read the endpoints from the discovery document instead of hard-coding them. It's public, and you can cache it for an hour.

```
curl https://helpcenter.io/.well-known/oauth-authorization-server
```

The response:

```
{
  "issuer": "https://helpcenter.io",
  "authorization_endpoint": "https://helpcenter.io/oauth/authorize",
  "token_endpoint": "https://helpcenter.io/oauth/token",
  "userinfo_endpoint": "https://helpcenter.io/oauth/userinfo",
  "jwks_uri": "https://helpcenter.io/oauth/jwks",
  "scopes_supported": [
    "openid", "profile", "email", "offline_access", "content.read",
    "content.write", "analytics.read", "webhooks.manage", "notes.read", "notes.write",
    "design.read", "design.write"
  ],
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic", "client_secret_post", "none"
  ],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"],
  "authorization_response_iss_parameter_supported": true,
  "service_documentation": "https://developers.helpcenter.io",
  "registration_endpoint": "https://helpcenter.io/oauth/register"
}
```

`https://helpcenter.io/.well-known/openid-configuration` returns the same document plus `claims_supported`.

## Step 1: Register your app

Register once, store the `client_id` and reuse it. Registering grants nothing: every person still approves your app on the consent screen. There are three ways to get a client.

### Dynamic client registration

POST your redirect URIs to the registration endpoint. With `"token_endpoint_auth_method": "none"`, the default, you get a public client, which proves itself with PKCE. With `client_secret_basic` or `client_secret_post` you get a confidential client and a `client_secret`. The secret appears only in this response and never expires.

```
curl -X POST https://helpcenter.io/oauth/register \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "client_name": "Docs sync CLI",
    "redirect_uris": ["http://127.0.0.1/callback"],
    "grant_types": ["authorization_code", "refresh_token"],
    "token_endpoint_auth_method": "none"
  }'
```

POST`/oauth/register`

Register an OAuth client and get its client_id (dynamic client registration, RFC 7591).

| Field | Type | Description |
| --- | --- | --- |
| `redirect_uris` | array | Required. 1 to 10 absolute URIs: `https`, or `http` on `127.0.0.1`, `[::1]` or `localhost`. No fragment. |
| `token_endpoint_auth_method` | string | `none` (the default) for a public client, or `client_secret_basic` or `client_secret_post` for a confidential one. Either confidential method can send the secret both ways. |
| `grant_types` | array | `authorization_code` and `refresh_token` are the only values accepted. Every client can refresh, and the response always lists both. |
| `client_name` | string | The name on the consent screen, up to 120 characters. Default: `Unnamed application`. |

Other fields are ignored. The endpoint accepts 10 requests an hour from one IP address, refused ones included.

**Loopback redirect URIs match on any port.** Register `http://127.0.0.1/callback`, and your app can listen on whichever port is free when it starts, for example `http://127.0.0.1:8791/callback`. The host and the path must match. Every other redirect URI must match exactly.

### Client ID Metadata Documents

Instead of registering, you can use an `https` URL as your `client_id`. The URL serves your app's metadata as JSON, and HelpCenter.io stores nothing:

```
{
  "client_id": "https://app.example.com/oauth/client.json",
  "client_name": "Acme Docs Sync",
  "redirect_uris": ["https://app.example.com/oauth/callback"]
}
```

`client_id` must equal the document's URL, and `redirect_uris` must not be empty. `client_name` is optional; without it, the consent screen shows the URL. HelpCenter.io fetches the document when your app authorizes and when it requests tokens, and keeps it for up to an hour. The URL must be at most 191 characters and publicly reachable, and must answer within 5 seconds, without a redirect, with at most 64 KB. Such a client is always public, so it uses PKCE. When the document can't be used, authorization fails with `invalid_client` and no reason. The discovery document doesn't advertise this option, but it works.

### A client registered by HelpCenter.io

If you'd rather not register the client yourself, [contact support](https://self.helpcenter.io/content/contact-support). HelpCenter.io can register a confidential client for your integration, and can rotate its secret later.

## Step 2: Send the person to HelpCenter.io

Create a PKCE code verifier (43 to 128 characters: letters, digits, `-`, `.`, `_` and `~`), its S256 challenge, and a random `state`. Keep the verifier and the state, for example in the person's session, and send the browser to the authorization URL:

```
# Build the authorization URL, with a PKCE verifier and challenge. Needs openssl.
CLIENT_ID="${HELPCENTER_CLIENT_ID:-hcio_your_client_id}"
REDIRECT_URI="http%3A%2F%2F127.0.0.1%3A8791%2Fcallback"

# Keep CODE_VERIFIER and STATE: the token request and the redirect check need them.
CODE_VERIFIER=$(openssl rand -hex 32)
STATE=$(openssl rand -hex 16)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary \
  | openssl base64 -A | tr '+/' '-_' | tr -d '=')

echo "https://helpcenter.io/oauth/authorize?response_type=code&client_id=$CLIENT_ID\
&redirect_uri=$REDIRECT_URI&scope=openid+email+content.read+content.write\
&state=$STATE&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256\
&resource=https%3A%2F%2Fapi.helpcenter.io"
```

```
// Build the authorization URL, with a PKCE verifier and challenge.
// Node.js 18 or later, no dependencies.
const crypto = require('node:crypto');

const clientId = process.env.HELPCENTER_CLIENT_ID ?? 'hcio_your_client_id';

// Keep codeVerifier and state (for example, in the user's session):
// the token request and the redirect check need them.
const codeVerifier = crypto.randomBytes(32).toString('base64url');
const state = crypto.randomBytes(16).toString('base64url');
const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');

const url = new URL('https://helpcenter.io/oauth/authorize');
url.search = new URLSearchParams({
  response_type: 'code',
  client_id: clientId,
  redirect_uri: 'http://127.0.0.1:8791/callback',
  scope: 'openid email content.read content.write',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
  resource: 'https://api.helpcenter.io',
}).toString();

console.log(url.toString());
console.log(JSON.stringify({ codeVerifier, state }));
```

```
# Build the authorization URL, with a PKCE verifier and challenge.
# Python 3.8 or later, standard library only.
import base64
import hashlib
import json
import os
import secrets
from urllib.parse import urlencode

client_id = os.environ.get("HELPCENTER_CLIENT_ID", "hcio_your_client_id")

# Keep code_verifier and state (for example, in the user's session):
# the token request and the redirect check need them.
code_verifier = secrets.token_urlsafe(32)
state = secrets.token_urlsafe(16)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")

query = urlencode({
    "response_type": "code",
    "client_id": client_id,
    "redirect_uri": "http://127.0.0.1:8791/callback",
    "scope": "openid email content.read content.write",
    "state": state,
    "code_challenge": code_challenge,
    "code_challenge_method": "S256",
    "resource": "https://api.helpcenter.io",
})

print("https://helpcenter.io/oauth/authorize?" + query)
print(json.dumps({"code_verifier": code_verifier, "state": state}))
```

```
<?php
// Build the authorization URL, with a PKCE verifier and challenge.
// PHP 8 or later.

function base64url(string $bytes): string
{
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

$clientId = getenv('HELPCENTER_CLIENT_ID') ?: 'hcio_your_client_id';

// Keep $codeVerifier and $state (for example, in the user's session):
// the token request and the redirect check need them.
$codeVerifier = base64url(random_bytes(32));
$state = base64url(random_bytes(16));
$codeChallenge = base64url(hash('sha256', $codeVerifier, true));

$query = http_build_query([
    'response_type' => 'code',
    'client_id' => $clientId,
    'redirect_uri' => 'http://127.0.0.1:8791/callback',
    'scope' => 'openid email content.read content.write',
    'state' => $state,
    'code_challenge' => $codeChallenge,
    'code_challenge_method' => 'S256',
    'resource' => 'https://api.helpcenter.io',
]);

echo "https://helpcenter.io/oauth/authorize?$query\n";
echo json_encode(['code_verifier' => $codeVerifier, 'state' => $state]), "\n";
```

| Parameter | Description |
| --- | --- |
| `response_type` | Required. `code`. |
| `client_id` | Required. Your client's id, or the URL of its metadata document. |
| `redirect_uri` | Required. One of your registered redirect URIs. On a loopback address, any port. |
| `scope` | The scopes your app needs, separated by spaces. An unknown scope fails the request. Without `scope`, the token has no scopes and can call only `GET /v1` and `GET /v1/account`. |
| `state` | A random value. HelpCenter.io returns it unchanged, so you can tie the redirect to this request. |
| `code_challenge` | The base64url SHA-256 hash of your code verifier, without padding. Send it from every client. |
| `code_challenge_method` | `S256`. Always send it. |
| `resource` | Recommended. The server the token is for: `https://api.helpcenter.io` for the REST API, or `https://mcp.helpcenter.io` for the MCP server. It goes into the token's `aud` claim. Any other value is refused with `invalid_target`. |
| `prompt` | Optional. `consent` shows the consent screen even when the person already approved your app. |

## Step 3: The person approves your app

HelpCenter.io asks the person to sign in if they aren't signed in, then shows **Authorize access**: your `client_name`, the **Permissions** you asked for, each with its description, and the help centers.

- **One help center:** it's shown under **Help center**, with nothing to choose.
- **Several:** **Help centers to share** lists them with checkboxes. Nothing is ticked the first time, and the person must tick at least one before **Allow** works. When they authorize your app again, the help centers they shared before are ticked.
- **Only help centers their account owns.** A help center the person works on for another account doesn't appear.

The permissions are all or nothing: the person approves the whole list or clicks **Deny**. When the token is for the MCP server, help centers whose plan doesn't include it are listed but can't be ticked.

If the person has already approved every scope you ask for, and the approval still covers one of their help centers, there's no screen: the browser comes straight back to your app. Send `prompt=consent` to show the screen anyway, for example to let the person change which help centers your app reaches.

## Step 4: Handle the redirect

After **Allow**, HelpCenter.io sends the browser to your redirect URI:

```
http://127.0.0.1:8791/callback?code=def50200…&state=f07d7b4ba8c53a16ccec66e8f3573504&iss=https%3A%2F%2Fhelpcenter.io
```

Check that `state` is the value you sent and that `iss` is `https://helpcenter.io`. Then exchange the code: it expires after 10 minutes and works once.

When something goes wrong, the browser comes back with an error instead of a code, or stops on HelpCenter.io:

| Cause | What happens |
| --- | --- |
| The person clicked **Deny** | Redirect with `error=access_denied`, your `state` and `iss`. |
| An unknown scope | Redirect with `error=invalid_scope` and `iss`. This redirect doesn't carry `state`. |
| An unknown `client_id`, or a `redirect_uri` you didn't register | `401` with `invalid_client`, shown on HelpCenter.io. No redirect. |
| A public client sent no `code_challenge` | `400` with `invalid_request` and the hint `Code challenge must be provided for public clients`. No redirect. |
| A `resource` other than the two above | `400` with `invalid_target`. No redirect. |

## Step 5: Exchange the code for tokens

POST the code to the token endpoint, form-encoded. A public client sends its `client_id`:

```
curl -X POST https://helpcenter.io/oauth/token \
  -H "Accept: application/json" \
  -d grant_type=authorization_code \
  -d client_id="$CLIENT_ID" \
  --data-urlencode redirect_uri=http://127.0.0.1:8791/callback \
  -d code="$CODE" \
  -d code_verifier="$CODE_VERIFIER"
```

A confidential client authenticates with HTTP Basic instead, or sends `client_secret` in the body:

```
curl -X POST https://helpcenter.io/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Accept: application/json" \
  -d grant_type=authorization_code \
  --data-urlencode redirect_uri=https://app.example.com/oauth/callback \
  -d code="$CODE" \
  -d code_verifier="$CODE_VERIFIER"
```

The response carries the tokens, with `Cache-Control: no-store`:

```
{
  "id_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9…",
  "refresh_token": "def5020069e24d99…"
}
```

`id_token` is there only when you asked for `openid`. The response has no `scope` field: the access token's `scopes` claim lists what it holds. `redirect_uri` must be exactly the one from step 2. `resource` is optional here: it defaults to the resource you authorized, and naming a different one fails with `invalid_target`. The endpoint also accepts a JSON body.

POST`/oauth/token`

Exchange an authorization code for an access token and a refresh token.

## Step 6: Call the API

Send the access token in the `Authorization` header, with `Bearer` written with a capital B. A lowercase `bearer` is refused with `401`.

```
curl https://api.helpcenter.io/v1/sites \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Accept: application/json"
```

The response lists the help centers your app can reach:

```
{
  "status": "success",
  "sites": [
    {
      "id": 1234,
      "uuid": "8f14e45f-ceea-4f6a-9d3b-2c5e1a7b9c01",
      "name": "Acme Help",
      "subdomain": "acme",
      "domain": "acme.helpcenter.io",
      "url": "https://acme.helpcenter.io",
      "default_language": "en",
      "visibility": "public",
      "publicly_accessible": true,
      "languages": ["en"],
      "created_at": "2026-09-30 08:03:11"
    }
  ],
  "meta": {
    "items_count": 1
  }
}
```

- When the token reaches several help centers, name one in each request with the `X-HCio-Site` header. See [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center).
- Every endpoint accepts OAuth tokens except change sets, which accept only API keys: a token gets `401` with `Unknown API key.`
- Your app has one rate limit budget per HelpCenter.io account, shared by every person and help center of that account. See [Rate limits](https://developers.helpcenter.io/content/rate-limits).

## Step 7: Refresh the access token

Access tokens expire after an hour (`expires_in` is `3600`). Exchange the refresh token for a new pair:

```
curl -X POST https://helpcenter.io/oauth/token \
  -H "Accept: application/json" \
  -d grant_type=refresh_token \
  -d client_id="$CLIENT_ID" \
  -d refresh_token="$REFRESH_TOKEN"
```

The response has the same shape as in step 5, with a new access token, a new refresh token and, with `openid`, a new `id_token`. A confidential client authenticates as it does in step 5.

Each refresh revokes the old access token and the old refresh token at once. Save the new pair before you use it, and don't refresh the same token from two processes: the second request fails.

A refresh token that was already used, that has expired after 30 days, or that the person revoked by disconnecting your app gets `401`:

```
{
  "error": "invalid_request",
  "error_description": "The refresh token is invalid.",
  "hint": "Token has been revoked",
  "message": "The refresh token is invalid."
}
```

An expired refresh token gets the same body with the hint `Token has expired`. Either way, send the person through step 2 again. A refresh can also pass `scope` with fewer scopes than the original token, never more. You get a refresh token whether or not you ask for `offline_access`.

## Access tokens

An access token is a JWT signed with RS256. Its payload looks like this:

```
{
  "jti": "37d8cba6ff63…",
  "iat": 1790756317.74425,
  "nbf": 1790756317.744252,
  "exp": 1790759917.722185,
  "sub": "42",
  "scopes": ["openid", "email", "content.read", "content.write"],
  "iss": "https://helpcenter.io",
  "aud": [
    "hcio_8gtf9f2ziiv5odm9kknlzs8s59acu7rz",
    "https://api.helpcenter.io"
  ],
  "azp": "hcio_8gtf9f2ziiv5odm9kknlzs8s59acu7rz"
}
```

- `sub` is the person's HelpCenter.io user id, as a string. `scopes` is an array, not the space-separated `scope` string some servers use.
- Without `resource`, `aud` is your `client_id` alone, as a string, and there is no `azp`. With `resource`, `aud` lists your `client_id` first and then the resource, and `azp` names your client.
- `iat`, `nbf` and `exp` carry fractions of a second.
- To verify a token yourself, use the key at `https://helpcenter.io/oauth/jwks`, whose `kid` is `hcio-oidc-1`. Access tokens have no `kid` in their header, so select the key by its algorithm rather than by `kid`.
- The MCP server accepts only tokens whose `aud` includes `https://mcp.helpcenter.io`. The REST API treats such a token as MCP traffic: it works only for help centers on the Growth or Catalyst plan, and gets `402` with `plan_upgrade_required` for the others. For direct API calls, ask for `resource=https://api.helpcenter.io`.

## Identity: id_token and userinfo

With the `openid` scope, the token response includes an `id_token`: an RS256 JWT with `kid` `hcio-oidc-1`, valid for an hour, with the claims `iss`, `aud` (your `client_id`), `sub`, `iat` and `exp`. The `email` scope adds `email` and `email_verified`; `profile` adds `name`, `given_name`, `family_name` and `picture`. The id_token carries no `nonce`, even when your authorization request sends one. The userinfo endpoint returns `sub` and the `email` and `profile` claims for an access token:

GET`/oauth/userinfo`

Get the signed-in person's identity claims (OpenID Connect userinfo).

## Revoking access

People manage their connections in their HelpCenter.io account, under **Connected Apps** (`https://helpcenter.io/app/account/connected-apps`). The consent screen links there. Each connected app shows its **Permissions** and the **Help centers it can reach**.

- **Save help centers** changes which help centers your app reaches. It applies to your next request, with the same token. A help center that was removed answers `403` with `site_forbidden`.
- **Disconnect** revokes, at once, every access token and refresh token your app holds for that person, and any code not yet exchanged. The next API request gets `401`.
- The owner of the HelpCenter.io account sees and manages every connection in the account. Other people see and manage only their own.

There is no endpoint your app can call to revoke a token. Deleting the tokens you hold doesn't revoke them; if the person wants your app gone, point them to **Connected Apps**.

## Troubleshooting

**The API answers 401 `{"status":"unauthorized"}`.** The access token expired, a refresh replaced it, or the person disconnected your app. Refresh it, and if the refresh fails, send the person through step 2. Also check that the header reads `Bearer`, with a capital B.

**The authorization page shows `invalid_client`.** The `client_id` is wrong, or `redirect_uri` isn't one you registered. Loopback URIs may differ only in the port.

**The token endpoint answers `invalid_grant` with `Failed to verify \`code_verifier\`.`** The `code_verifier` isn't the one behind the `code_challenge` you sent. Use the same verifier, and `S256`.

**The token endpoint answers `invalid_request` with `Authorization code has been revoked`.** The code was already exchanged. Each code works once; start again at step 2.

**Content requests answer `400` `site_required` with an empty `sites` list.** The person's account owns no help center, so the token reaches none. Ask them to connect your app from an account that owns the help center, or use an API key from that help center.

**A request answers `403` `insufficient_scope`.** Add the scope named in the message to your authorization request and send the person through step 2. See [Scopes](https://developers.helpcenter.io/content/scopes).

**The API answers `402` `plan_upgrade_required`.** The token was made for the MCP server. Ask for `resource=https://api.helpcenter.io` for direct API calls.

**Registration answers `429` `temporarily_unavailable`.** The endpoint takes 10 requests an hour from one IP address, refused ones included. Register once and reuse your `client_id`.

## Related

- [Scopes](https://developers.helpcenter.io/content/scopes)
- [Choose which help center a request acts on](https://developers.helpcenter.io/content/choosing-a-help-center)
- [Connect an AI assistant to your help center](https://developers.helpcenter.io/content/connect-an-ai-assistant-to-your-help-center)
- [API keys](https://developers.helpcenter.io/content/api-keys)
