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 |
|
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 |
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
httpsURL, or for a native or command-line app,httpon127.0.0.1,[::1]orlocalhost. Dynamic registration refuses custom schemes such asmyapp://.The scopes your app needs. See Scopes.
A server or a native app. The OAuth endpoints on
helpcenter.iosend 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
You register your app once and get a
client_id.Your app creates a PKCE verifier and sends the person to HelpCenter.io's authorization endpoint.
The person signs in, reviews the permissions, picks help centers and clicks Allow.
HelpCenter.io redirects the browser back to your app with a
code.Your app exchanges the code for an access token and a refresh token.
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"
}'
/oauth/registerRegister an OAuth client and get its client_id (dynamic client registration, RFC 7591).
Field | Type | Description |
|---|---|---|
| array | Required. 1 to 10 absolute URIs: |
| string |
|
| array |
|
| string | The name on the consent screen, up to 120 characters. Default: |
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 |
|---|---|
| Required. |
| Required. Your client's id, or the URL of its metadata document. |
| Required. One of your registered redirect URIs. On a loopback address, any port. |
| The scopes your app needs, separated by spaces. An unknown scope fails the request. Without |
| A random value. HelpCenter.io returns it unchanged, so you can tie the redirect to this request. |
| The base64url SHA-256 hash of your code verifier, without padding. Send it from every client. |
|
|
| Recommended. The server the token is for: |
| Optional. |
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 |
An unknown scope | Redirect with |
An unknown |
|
A public client sent no |
|
A |
|
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.
/oauth/tokenExchange 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-Siteheader. 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
401withUnknown 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"
}
subis the person's HelpCenter.io user id, as a string.scopesis an array, not the space-separatedscopestring some servers use.Without
resource,audis yourclient_idalone, as a string, and there is noazp. Withresource,audlists yourclient_idfirst and then the resource, andazpnames your client.iat,nbfandexpcarry fractions of a second.To verify a token yourself, use the key at
https://helpcenter.io/oauth/jwks, whosekidishcio-oidc-1. Access tokens have nokidin their header, so select the key by its algorithm rather than bykid.The MCP server accepts only tokens whose
audincludeshttps://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 gets402withplan_upgrade_requiredfor the others. For direct API calls, ask forresource=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:
/oauth/userinfoGet 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
403withsite_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.