> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hq.zone/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth 2.1 with PKCE

> Register a client, run the authorization-code + PKCE flow, and manage access, refresh, introspection, and revocation against HQ as the authorization server.

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

<Note>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](/api-reference/auth/register-client)) is JSON only.</Note>

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

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

## The full walkthrough

<Steps>
  <Step title="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](/api-reference/auth/register-client):

    ```bash theme={null}
    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_uris` — **required**, 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_method` — `none` (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.
    * `scope` — **required**, space-delimited capability scopes (e.g. `documents:read conversations:write`); every scope must be a known HQ capability.

    A `201` returns the client info:

    ```json theme={null}
    {
      "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"
    }
    ```

    <Warning>`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.</Warning>

    For a `none` (public) client there is no `client_secret` / `client_secret_expires_at` in the response.
  </Step>

  <Step title="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.

    ```js theme={null}
    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.

    <Note>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.</Note>
  </Step>

  <Step title="Send the user to the authorize endpoint">
    Redirect the user's browser to [Authorize](/api-reference/auth/oauth-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.

    ```text theme={null}
    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_id` — **required**. Must resolve to a known, active client.
    * `redirect_uri` — **required**. Must be on the client's registered allowlist.
    * `code_challenge` — **required**. 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:

    ```text theme={null}
    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`.

    <Tip>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`.</Tip>
  </Step>

  <Step title="Exchange the code for tokens">
    Your callback receives `code`. Verify `state` matches what you sent, then POST to [Token](/api-reference/auth/oauth-token) with `grant_type=authorization_code` and the `code_verifier` you stashed. The OAuth default is form-encoded:

    ```bash theme={null}
    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_type` — `authorization_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_secret` — **confidential clients only**; public clients omit it.

    A `200` returns the token pair:

    ```json theme={null}
    {
      "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).

    <Warning>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`.</Warning>
  </Step>

  <Step title="Call the API with the access token">
    Use the `access_token` as a Bearer on HQ API calls:

    ```bash theme={null}
    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.
  </Step>

  <Step title="Refresh before the access token expires (rotation)">
    The access token expires in an hour. Trade your `refresh_token` for a fresh pair at [Token](/api-reference/auth/oauth-token) with `grant_type=refresh_token`:

    ```bash theme={null}
    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_type` — `refresh_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_secret` — **confidential 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.

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

    Errors are `400 invalid_grant` for an unknown, expired, reused, or mismatched-client refresh token; `401 invalid_client` for a bad confidential secret.
  </Step>

  <Step title="Revoke a token when you're done (RFC 7009)">
    POST to [Revoke](/api-reference/auth/oauth-revoke). The calling client authenticates with its `client_id` (plus `client_secret` for confidential clients):

    ```bash theme={null}
    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`.)
  </Step>
</Steps>

## Inspecting a token (RFC 7662)

To check whether a token is still valid, POST it to [Introspect](/api-reference/auth/oauth-introspect). The calling client authenticates the same way as revoke:

```bash theme={null}
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:

```json theme={null}
{
  "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.

<Note>A bad confidential `client_secret` returns `401 invalid_client`. Otherwise introspect always returns `200` with `active` true or false.</Note>

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

<Steps>
  <Step title="Read the client">
    ```bash theme={null}
    curl https://api.hq.zone/v1/oauth/register/<client_id> \
      -H "Authorization: Bearer <registration_access_token>"
    ```

    [Get a client](/api-reference/auth/get-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.
  </Step>

  <Step title="Update the client">
    ```bash theme={null}
    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](/api-reference/auth/update-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.
  </Step>

  <Step title="Deregister the client">
    ```bash theme={null}
    curl -X DELETE https://api.hq.zone/v1/oauth/register/<client_id> \
      -H "Authorization: Bearer <registration_access_token>"
    ```

    [Delete a client](/api-reference/auth/delete-client) soft-disables the client so it can no longer be used. Returns `204 No Content`; `401` for a bad registration token.
  </Step>
</Steps>

<Tip>Each account is capped at 50 active registered clients. Deregister clients you no longer use to free up room.</Tip>
