Authentication

OAuth 2.1 for apps and AI clients

Export
Download Markdown Use with AI

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

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.

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

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

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.

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.

Was this article helpful?