# The token endpoint

`POST /api/public/oauth/token`, form encoded. It serves two grant types and returns the RFC 6749 error shape rather than Haven's envelope.

## Authenticating your client

Confidential clients may use HTTP Basic or form parameters. Basic is the standard's requirement and every library's default; form parameters exist because a meaningful number of HTTP stacks make setting an `Authorization` header on a token call awkward. Sending both is an error.

```
Authorization: Basic base64(client_id:client_secret)
```

Public clients send `client_id` and no secret. That is the correct configuration for native apps, CLIs and MCP servers: a secret shipped inside a binary is not a secret, and recording one in the database would make a security review read better than the system actually is. PKCE is what authenticates you.

Presenting a secret as a public client is refused rather than ignored, because it usually means someone copied a confidential integration and believes they are authenticated.

## Exchanging an authorization code

```
grant_type=authorization_code
code=hvoc_...
redirect_uri=https://app.example.com/callback
code_verifier=<the verifier for the challenge you sent>
```

`redirect_uri` must be byte-identical to the one you sent to `/authorize`. Sending `scope` here is an error; scope is fixed at authorization.

```json
{
  "access_token": "hvat_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "hvrt_...",
  "scope": "account:read listings:read calendar:write"
}
```

Access tokens live one hour, or twenty-four hours for a sandbox grant, because a developer at a REPL should not re-authenticate hourly. Refresh tokens live sixty days and rotate on every use.

## Refreshing

```
grant_type=refresh_token
refresh_token=hvrt_...
scope=listings:read            # optional, narrowing only
```

Covered in [Refreshing and rotation](04-refreshing-and-rotation.md).

## Errors

The body is `{"error": "...", "error_description": "..."}` with an optional `error_uri`.

| Condition                                                                              | `error`                   | Status |
| -------------------------------------------------------------------------------------- | ------------------------- | ------ |
| Body unparseable, over 8 KiB, or not form encoded                                      | `invalid_request`         | 400    |
| `grant_type` missing                                                                   | `invalid_request`         | 400    |
| `grant_type` not one of the two                                                        | `unsupported_grant_type`  | 400    |
| Two client authentication methods present                                              | `invalid_request`         | 400    |
| Client unknown, secret wrong, revoked or expired                                       | `invalid_client`          | 401    |
| Confidential client sent no secret                                                     | `invalid_client`          | 401    |
| Public client sent a secret                                                            | `invalid_client`          | 401    |
| Application not approved, suspended or revoked                                         | `invalid_client`          | 401    |
| Required parameter missing                                                             | `invalid_request`         | 400    |
| `scope` sent on an authorization_code grant                                            | `invalid_request`         | 400    |
| Code unknown, expired, already used, wrong client, redirect mismatch, or PKCE mismatch | `invalid_grant`           | 400    |
| Refresh token unknown, revoked, expired, or belonging to another client                | `invalid_grant`           | 400    |
| Refresh token reused after the grace window                                            | `invalid_grant`           | 400    |
| Host's grant no longer active                                                          | `invalid_grant`           | 400    |
| `scope` on refresh is not a subset                                                     | `invalid_scope`           | 400    |
| Rate limited                                                                           | `temporarily_unavailable` | 429    |

### The five identical failures

Unknown code, expired code, wrong client, redirect mismatch and PKCE mismatch all return byte-identical `invalid_grant` responses. Distinguishing them for you would distinguish them for someone probing, so the documentation carries what the response cannot:

Check them in this order. Is the code more than sixty seconds old? Has this code already been exchanged, including by a retry of a request that timed out? Is the `redirect_uri` byte-identical to the authorize call, including trailing slash and case? Is the verifier the one whose challenge you sent, rather than a fresh one? Are you authenticating as the client the code was issued to?

The common cause in practice is the second: a client retrying a failed exchange. Codes are consumed on first presentation, so the retry cannot succeed. Re-run the authorization.

### Rate limits

The token endpoint is limited by attempts, not by successful mints. A refresh bug will not lock you out for the day; repeatedly guessing a client secret will slow you down. Failure buckets are consumed only on failure, so a healthy integration never touches them.

Haven does not lock accounts out. A tripped limit returns 429 with `Retry-After` and drains on its own.

## Introspection

`POST /api/public/oauth/introspect`, RFC 7662, confidential clients only.

It exists for one reason. Effective scope is recomputed per request, so a host or Haven narrowing your access takes effect immediately and silently from your side; without introspection your first signal is a 403 in production. Introspection answers "is this token still good, and what does it still allow" from your control plane, and works on a token that is already dead.

An inactive token returns exactly `{"active": false}` and nothing else, for every reason it might be inactive. An active one returns its scope, client id, expiry and a `sub` identifying the connection.
