Errors and refusals
Where each kind of refusal arrives, the one envelope every API error uses, and the step-up that is not a denial.
Refusals arrive in three places
A sign-in can be refused before the person sees anything, at your redirect URI, or at the token endpoint. Each has its own shape, and your code meets all three.
Before the redirect URI is trustedJSON, no redirect- A missing or unknown
client_id, or aredirect_urithat is not registered for it, is answered as an API error —invalid_requestorunauthorized_client, status400— and never redirected, because the only place to send the person is the one that failed validation. At your redirect URIRFC 6749 query parameters- Once the client and its redirect URI are validated, every later refusal comes back to you as
?error=…&error_description=…&state=…. The codes a relying party may be told areinvalid_request,unsupported_response_type,invalid_scope,unauthorized_client,access_deniedandserver_error. At the token endpointbare OAuth errorPOST /v1/oauth/tokenanswers{ "error", "error_description" }with no envelope:invalid_grant,invalid_client(status401, withWWW-Authenticate: Basic),unsupported_grant_type,invalid_request. A person no longer in good standing is a403; a budget or credit refusal is a402.
The API error envelope
Every API error outside the OAuth routes — developer routes, userinfo, the public routes — is one object with an error member. There is no top-level status field; the HTTP status carries that.
{
"error": {
"code": "invalid_token",
"message": "A valid OAuth access token is required.",
"requestId": "7015cb0d-ac1a-427c-9b4f-0dacb1a2235e"
}
}code- A stable identifier. Branch on it. There are a few hundred across the platform; the reference lists the ones on the developer surface.
message- Written for the developer reading a log. It is rewritten without notice; never match on it and never show it to the person as-is.
requestId- What support needs. Log it against your own correlation id on every non-2xx; never log the request body beside it.
details- Optional. A plain object with more for your code —
retryAfterSecondson a429,redirectUrlon a redirectable authorization error.
Userinfo refusals use OAuth's own codes inside that envelope: invalid_token (401) for a missing, expired or revoked token, and insufficient_scope (403) for a resource the token's scopes do not cover.
What the status tells you
| Status | Means | Your move |
|---|---|---|
| 400 | The request is malformed or a value is invalid | Fix the call. Never retry unchanged |
| 401 | Not authenticated, or the token is expired or invalid | Re-authenticate, then retry once |
| 402 | A budget or credit refusal | Stop. Credits are added in the developer console by the application's owner, or by a team member with the billing role on one of the owner's active production applications (to the owner's balance; the receipt is in the payer's name). Only the owner raises the application's budget |
| 403 | Authenticated, but not permitted — including step-up | Do not retry. Check the code for a step-up |
| 404 | No such resource, or you cannot see it | Treat as absent. Do not distinguish the two to end users |
| 409 | Conflicts with current state | Re-read state and decide. Retrying unchanged will fail again |
| 429 | Rate limited | Wait Retry-After seconds |
| 5xx | Our fault, or an upstream provider | Retry with backoff on idempotent calls only |
Step-up is a recovery path
Some console actions require the developer to confirm with a passkey before they proceed — issuing or revoking a client secret, registering a webhook endpoint, changing a live application. They answer 403 with a code ending in STEP_UP_REQUIRED, and they are not permission denials: the caller is allowed, they have just not confirmed recently enough.
const STEP_UP = /STEP_UP_REQUIRED$/;
if (response.status === 403 && STEP_UP.test(body.error.code)) {
// Send them to confirm, and come back to this exact page afterwards.
return redirectToPasskeyConfirmation({ returnTo: currentPath });
}What to show the person
Map codes to your own copy, in your own voice, telling them what to do next. A denial at your callback is a path your product supports — tell them what happens now and what they can still do — not an exception screen.
Do not leak whether a resource exists. A 404 on someone else's application and a 404 on a deleted one look identical on purpose, and your interface should keep them that way.
What this does not do
- There is no error that tells you why a person was refused. By design. If your product needs to know, ask the person.
- Messages are not a contract. The API renames them to be more useful — the
invalid_scopemessage now names the scope — and will again.
Verified against API commit 234dba22 on 2026-10-18. The routes, scopes, checklist, events and limits on these pages are read from that export, not typed.