/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 S256 — plain 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_postorclient_secret_basic→ a confidential client. It gets aclient_secretand must send it on every token, introspect, and revoke call (in addition to PKCE).
The full walkthrough
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:Request fields (all but For a
redirect_uris are optional):redirect_uris— required, non-empty. Each must behttps://...or anhttp://loopback (localhost/127.0.0.1), with no URL fragment.client_name— display name; defaults toUnnamed appif omitted or blank.token_endpoint_auth_method—none(public, default),client_secret_post, orclient_secret_basic.grant_types— defaults to["authorization_code"]; onlyauthorization_codeandrefresh_tokenare accepted.response_types— defaults to["code"]; onlycodeis accepted.scope— required, space-delimited capability scopes (e.g.documents:read conversations:write); every scope must be a known HQ capability.
201 returns the client info:none (public) client there is no client_secret / client_secret_expires_at in the response.Generate a PKCE verifier and challenge
PKCE is standard client-side crypto. Generate a high-entropy Keep
code_verifier, then derive code_challenge = base64url(sha256(verifier)). HQ requires the S256 method.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.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.Query parameters:A bad request (
client_id— required. Must resolve to a known, active client.redirect_uri— required. Must be on the client’s registered allowlist.code_challenge— required. TheS256challenge from the previous step.code_challenge_method— optional; defaults toS256and must beS256if sent (plainis rejected).scope— optional, space-delimited. The granted scope is what you request intersected with the client’s allowed scopes. An empty/omittedscopegrants all of the client’s allowed scopes. If you request only scopes the client isn’t allowed, you getinvalid_scope.state— optional opaque value echoed back on the redirect. Use it to bind the response to your request (CSRF protection).
302 and redirects to your redirect_uri with code and state appended:invalid_request, unknown client_id, disallowed redirect_uri, or invalid_scope) returns 400.Exchange the code for tokens
Your callback receives Required form fields for this grant:Response fields:
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:grant_type—authorization_code.code— the single-use code from the redirect.code_verifier— the PKCE verifier; HQ checksbase64url(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_secret— confidential clients only; public clients omit it.
200 returns the token pair: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).Call the API with the access token
Use the 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.
access_token as a Bearer on HQ API calls:Refresh before the access token expires (rotation)
The access token expires in an hour. Trade your Required form fields for this grant:
refresh_token for a fresh pair at Token with grant_type=refresh_token:grant_type—refresh_token.refresh_token— the current refresh token. It must belong to the sameclient_idthat was issued it.client_id— must match the client the refresh token was bound to.client_secret— confidential clients only.
TokenResp shape as above, with a new access_token and a new refresh_token. The original scopes are carried forward unchanged.Errors are 400 invalid_grant for an unknown, expired, reused, or mismatched-client refresh token; 401 invalid_client for a bad confidential secret.Revoke a token when you're done (RFC 7009)
POST to Revoke. The calling client authenticates with its Request fields:
client_id (plus client_secret for confidential clients):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: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 (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 theregistration_access_token from registration as a Bearer token — not your HQ session.
Read the client
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.Update the client
ClientInfo (200); 400 for invalid metadata, 401 for a bad registration token.Deregister the client
204 No Content; 401 for a bad registration token.