Skip to main content
HQ is an OAuth 2.1 authorization server. A client (a browser extension, a CLI, a third-party integration) sends the user through HQ’s /v1/oauth/authorize endpoint, gets a single-use authorization code back, then exchanges that code for an opaque Bearer access token plus a rotating refresh token at /v1/oauth/token. Every authorization-code flow uses PKCE with S256plain is rejected. This guide walks the full lifecycle: register a client, build the authorize URL with PKCE, handle the redirect, exchange the code, call the API, refresh, then revoke and introspect.
All endpoints live under the base URL https://api.hq.zone. The token, introspect, and revoke endpoints accept either application/x-www-form-urlencoded (the OAuth wire default) or application/json. Client registration (Register a client) is JSON only.

Before you start: pick a client type

When you register, token_endpoint_auth_method decides the client type:
  • none → a public client. No secret. It proves itself at the token endpoint with PKCE alone. Use this for browser extensions, SPAs, mobile, and CLIs that can’t keep a secret.
  • client_secret_post or client_secret_basic → a confidential client. It gets a client_secret and must send it on every token, introspect, and revoke call (in addition to PKCE).
Registered clients are always third-party: first_party is forced to false, so the user is shown a consent screen the first time (and whenever the requested scopes grow). Only HQ’s own first-party clients skip consent.

The full walkthrough

1

Register a client (RFC 7591)

Registration is authenticated — you must present a valid HQ session or token, and the new client is owned by that account. POST your metadata as JSON to Register a client:
curl -X POST https://api.hq.zone/v1/oauth/register \
  -H "Authorization: Bearer $HQ_TOKEN" -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["https://app.example.com/callback"],
    "client_name": "Acme Integration",
    "token_endpoint_auth_method": "none",
    "scope": "documents:read conversations:write"
  }'
Request fields (all but redirect_uris are optional):
  • redirect_urisrequired, non-empty. Each must be https://... or an http:// loopback (localhost / 127.0.0.1), with no URL fragment.
  • client_name — display name; defaults to Unnamed app if omitted or blank.
  • token_endpoint_auth_methodnone (public, default), client_secret_post, or client_secret_basic.
  • grant_types — defaults to ["authorization_code"]; only authorization_code and refresh_token are accepted.
  • response_types — defaults to ["code"]; only code is accepted.
  • scoperequired, space-delimited capability scopes (e.g. documents:read conversations:write); every scope must be a known HQ capability.
A 201 returns the client info:
{
  "client_id": "hq_client_...",
  "client_secret": "hq_csec_...",
  "client_id_issued_at": 1733832000,
  "client_secret_expires_at": 0,
  "registration_access_token": "hq_ratk_...",
  "registration_client_uri": "https://api.hq.zone/v1/oauth/register/hq_client_...",
  "redirect_uris": ["https://app.example.com/callback"],
  "client_name": "Acme Integration",
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "scope": "documents:read conversations:write"
}
client_secret (confidential clients only) and registration_access_token are shown once, at registration. Store them now. The registration_access_token is what you’ll use to read, update, or delete the client later — not your HQ session.
For a none (public) client there is no client_secret / client_secret_expires_at in the response.
2

Generate a PKCE verifier and challenge

PKCE is standard client-side crypto. Generate a high-entropy code_verifier, then derive code_challenge = base64url(sha256(verifier)). HQ requires the S256 method.
function base64url(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

// 32 random bytes → the verifier (kept secret on the client).
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));

// challenge = base64url(SHA-256(verifier)).
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(digest);
Keep verifier on the client; you’ll send it at the token step. Only challenge goes in the authorize URL.
HQ verifies PKCE by computing base64url(sha256(code_verifier)) and comparing it to the stored challenge. It also rejects a code_challenge shorter than 32 characters — a real S256 challenge is 43 characters.
3

Send the user to the authorize endpoint

Redirect the user’s browser to Authorize. The user must have an active HQ session; if they aren’t signed in, HQ bounces them to sign-in and back. This is a browser redirect, not a fetch.
https://api.hq.zone/v1/oauth/authorize
  ?client_id=hq_client_...
  &redirect_uri=https://app.example.com/callback
  &code_challenge=<challenge>
  &code_challenge_method=S256
  &scope=documents:read%20conversations:write
  &state=<random-anti-csrf>
Query parameters:
  • client_idrequired. Must resolve to a known, active client.
  • redirect_urirequired. Must be on the client’s registered allowlist.
  • code_challengerequired. The S256 challenge from the previous step.
  • code_challenge_method — optional; defaults to S256 and must be S256 if sent (plain is rejected).
  • scope — optional, space-delimited. The granted scope is what you request intersected with the client’s allowed scopes. An empty/omitted scope grants all of the client’s allowed scopes. If you request only scopes the client isn’t allowed, you get invalid_scope.
  • state — optional opaque value echoed back on the redirect. Use it to bind the response to your request (CSRF protection).
On success HQ responds 302 and redirects to your redirect_uri with code and state appended:
https://app.example.com/callback?code=<authorization_code>&state=<random-anti-csrf>
A bad request (invalid_request, unknown client_id, disallowed redirect_uri, or invalid_scope) returns 400.
For a third-party (registered) client, HQ first redirects the browser to a consent screen; the code redirect only happens after the user approves. If the user denies, the redirect carries error=access_denied&state=... instead of a code.
4

Exchange the code for tokens

Your callback receives code. Verify state matches what you sent, then POST to Token with grant_type=authorization_code and the code_verifier you stashed. The OAuth default is form-encoded:
curl -X POST https://api.hq.zone/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=<authorization_code> \
  -d code_verifier=<verifier> \
  -d redirect_uri=https://app.example.com/callback \
  -d client_id=hq_client_...
  # confidential clients also send: -d client_secret=hq_csec_...
Required form fields for this grant:
  • grant_typeauthorization_code.
  • code — the single-use code from the redirect.
  • code_verifier — the PKCE verifier; HQ checks base64url(sha256(code_verifier)) against the stored challenge.
  • redirect_uri — must match the one used at /authorize.
  • client_id — must match the client that started the flow.
  • client_secretconfidential clients only; public clients omit it.
A 200 returns the token pair:
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "scope": "documents:read conversations:write"
}
Response fields: access_token (opaque Bearer), token_type (always Bearer), expires_in (seconds — the access token lives 1 hour), refresh_token (the rotating token, valid 30 days), and scope (space-delimited granted scopes).Failure modes: 400 for invalid_grant (bad/expired code, PKCE mismatch, redirect_uri or client_id mismatch) or invalid_request (missing fields); 401 for invalid_client (a confidential client’s secret didn’t verify).
The authorization code is single-use and short-lived (10-minute upper bound). Exchange it immediately. PKCE binds the exchange to the client that started the flow, so a leaked code is useless without the original code_verifier.
5

Call the API with the access token

Use the access_token as a Bearer on HQ API calls:
curl https://api.hq.zone/v1/api/conversations \
  -H "Authorization: Bearer <access_token>"
The token carries the granted scopes; the user’s role remains the runtime ceiling, so a token never grants more than the user themselves has.
6

Refresh before the access token expires (rotation)

The access token expires in an hour. Trade your refresh_token for a fresh pair at Token with grant_type=refresh_token:
curl -X POST https://api.hq.zone/v1/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=refresh_token \
  -d refresh_token=<refresh_token> \
  -d client_id=hq_client_...
  # confidential clients also send: -d client_secret=hq_csec_...
Required form fields for this grant:
  • grant_typerefresh_token.
  • refresh_token — the current refresh token. It must belong to the same client_id that was issued it.
  • client_id — must match the client the refresh token was bound to.
  • client_secretconfidential clients only.
The response is the same TokenResp shape as above, with a new access_token and a new refresh_token. The original scopes are carried forward unchanged.
Refresh tokens rotate: every refresh revokes the one you just used and returns a new one. Always replace your stored refresh token with the new one. If a previously-rotated (already-revoked) refresh token is replayed, HQ treats it as a compromise signal and revokes the entire token family — both the access and refresh tokens and all their rotations. You’ll then have to re-run the authorization flow.
Errors are 400 invalid_grant for an unknown, expired, reused, or mismatched-client refresh token; 401 invalid_client for a bad confidential secret.
7

Revoke a token when you're done (RFC 7009)

POST to Revoke. The calling client authenticates with its client_id (plus client_secret for confidential clients):
curl -X POST https://api.hq.zone/v1/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d token=<access_or_refresh_token> \
  -d client_id=hq_client_...
  # confidential clients also send: -d client_secret=hq_csec_...
Request fields: token (required — the token to revoke), token_type_hint (optional, ignored), client_id (required), client_secret (confidential clients only).If the token belongs to the authenticating client, it and its entire refresh family (the paired access + refresh tokens and all rotations) are revoked. Revoke always returns 200 — including for unknown, already-revoked, or not-yours tokens — so it never reveals whether a token exists or who owns it. (A bad confidential client_secret is the one exception: 401 invalid_client.)

Inspecting a token (RFC 7662)

To check whether a token is still valid, POST it to Introspect. The calling client authenticates the same way as revoke:
curl -X POST https://api.hq.zone/v1/oauth/introspect \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d token=<token> \
  -d client_id=hq_client_...
  # confidential clients also send: -d client_secret=hq_csec_...
Request fields: token (required), token_type_hint (optional, ignored), client_id (required), client_secret (confidential clients only). A 200 returns metadata only for a token the authenticating client itself issued and that is neither revoked nor expired:
{
  "active": true,
  "scope": "documents:read conversations:write",
  "client_id": "hq_client_...",
  "username": "<external_user_id>",
  "token_type": "Bearer",
  "exp": 1733835600,
  "iat": 1733832000,
  "sub": "<user_id>"
}
Response fields: active (always present), and when active also scope, client_id, username, token_type (Bearer), exp (Unix expiry), iat (Unix issued-at), and sub (the user id). For any other token — unknown, expired, revoked, or issued to a different client — the response is simply { "active": false }, with no leaked detail.
A bad confidential client_secret returns 401 invalid_client. Otherwise introspect always returns 200 with active true or false.

Managing a registered client (RFC 7592)

The three management endpoints authenticate with the registration_access_token from registration as a Bearer token — not your HQ session.
1

Read the client

curl https://api.hq.zone/v1/oauth/register/<client_id> \
  -H "Authorization: Bearer <registration_access_token>"
Get a client returns the current ClientInfo metadata (redirect_uris, client_name, token_endpoint_auth_method, grant_types, response_types, scope). The client_secret is never returned here. 200 on success, 401 for a bad registration token, 404 for an unknown client.
2

Update the client

curl -X PUT https://api.hq.zone/v1/oauth/register/<client_id> \
  -H "Authorization: Bearer <registration_access_token>" \
  -H "Content-Type: application/json" \
  -d '{
    "redirect_uris": ["https://app.example.com/callback", "https://app.example.com/cb2"],
    "client_name": "Acme Integration v2",
    "scope": "documents:read documents:write conversations:write"
  }'
Update a client re-validates and replaces the mutable metadata: name, redirect URIs, and requestable scopes. The client type (and its secret lifecycle) is fixed at registration and can’t change. Returns the updated ClientInfo (200); 400 for invalid metadata, 401 for a bad registration token.
3

Deregister the client

curl -X DELETE https://api.hq.zone/v1/oauth/register/<client_id> \
  -H "Authorization: Bearer <registration_access_token>"
Delete a client soft-disables the client so it can no longer be used. Returns 204 No Content; 401 for a bad registration token.
Each account is capped at 50 active registered clients. Deregister clients you no longer use to free up room.