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.
{
"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.
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.