Every endpoint reports errors the same way — flat, not nested, per RFC 6749 §5.2, because that is the shape every client library parses.
{
"error": "invalid_grant",
"error_description": "code_verifier does not match the code_challenge."
}⚠️ error_description is written for a human debugging an integration. The wording may change — branch on error and the HTTP status, never on the description.
⚠️ This shape differs from the Visit Korat Partner API at /v1/*, which nests under { error: { code, message } }. Two APIs, two contracts. If you integrate both, do not share an error handler.
OAuth error codes
| Code | What it means, and what usually causes it |
|---|---|
invalid_request | Malformed or incomplete. Usually: sending JSON instead of form-encoded, a missing code_verifier, or a missing state. |
invalid_client | We cannot tell who you are. Wrong secret, an application still awaiting review, a suspended one, or a confidential client calling from a browser. |
invalid_grant | The code or refresh token is no good. Expired (a code lives 60 seconds), already used, a redirect_uri that differs from the authorize step, a code_verifier that does not match, or consent that has been withdrawn. |
unsupported_grant_type | Only authorization_code and refresh_token are accepted. |
unsupported_response_type | Only response_type=code. No implicit or hybrid flow. |
invalid_scope | A scope your application is not registered for, a missing openid, or offline_access before Visit Korat has enabled it. |
access_denied | The person declined. Not a bug — say something gracious and stop. |
invalid_token | The access token is expired, revoked, or its consent was withdrawn. Refresh if you can; otherwise send the person through sign-in again. |
temporarily_unavailable | Rate limited. See Retry-After. |
server_error | Our fault. Retry, and tell us if it persists. |
Note that invalid_grantcovers “unknown”, “expired” and “already used” together. That is deliberate: telling them apart hands somebody holding a stolen code a way to test whether it still works.
HTTP status codes
| Status | When |
|---|---|
200 | Success — including /oauth/revoke, which always answers 200 whether or not the token existed. |
302 | Back to your redirect URI, on success and on a reportable error alike. |
400 | The request or the grant is no good. |
401 | We cannot tell who you are (invalid_client) or the token is no good (invalid_token). Bearer endpoints also send WWW-Authenticate. |
429 | Rate limited. Token: 60/min per application per address. UserInfo: 120/min per token. Authorize: 30/min per address. See Retry-After. |
500 | Our fault. |
Troubleshooting
The person sees an error page and is never redirected back
Your redirect_uri does not match what is registered, byte for byte. Check the trailing slash, http versus https, the port, and the case.
Not redirecting is deliberate: while we cannot verify the destination, sending the browser there with ?error= would hand anybody a free open redirect.
invalid_grant: code_verifier does not match the code_challenge
- The challenge must be base64url, not base64 — no
+,/or=. - It must be the SHA-256 of the verifier as bytes, not the hash of its hex representation.
- It must be the verifier from this same request. A mismatched session will not match.
Check against the RFC 7636 vector: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk → E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.
invalid_grant on a code you only just received
A code lives 60 seconds and can be used once. If your callback runs twice — a browser prefetch, somebody pressing reload — the second run always gets this.
invalid_request: terms_version and privacy_version are required
These two are specific to Visit Korat, so no standard client library sends them. Pass them as extra authorization parameters — see using a client library.
The browser blocks your call to /oauth/token (CORS)
- Your application must be public. Confidential applications are refused when an
Originis present, by design. - The origin must be in your allowed origins, matched exactly — scheme and port included.
https://app.example.comandhttps://app.example.com/are not the same string. Register it without the trailing slash.
invalid_client although the secret is right
The application may still be awaiting review, or suspended — check its status under your applications. Or you are using development credentials against production: the two are entirely separate registries.
Sign-in works, but /oauth/userinfo starts returning 401 soon after
The person disconnected your application at /account/connections. We cut the tokens immediately rather than waiting for them to expire. Send them through sign-in again, and delete the data you stored.
The ID token fails signature verification
- Use the JWKS for the right environment — development and production sign with different keys.
- On an unknown
kid, refetch the JWKS and retry. That is a key rotation, and most libraries handle it for you. issmust match byte for byte, includinghttps://and with no trailing slash.
Still stuck
Every response carries an x-request-id header. Send that, your client_id, and roughly when it happened, and we can find it much faster.
⚠️ Never send us a client secret, access token, refresh token or authorization code — not by email, not in a chat. We will never ask, and we do not need one to diagnose anything.