Errors
Error responses are JSON with a human-readable message. Schema violations add an issues array. There is no numeric error-code field; branch on the HTTP status.
{ "message": "Unknown or revoked API key" }
Status codes
| Status | When | Retry? |
|---|---|---|
400 | The request body failed schema validation, on any endpoint. | No. Fix the request. |
401 | Missing, malformed, unknown, or revoked credential. | No. Fix the credential. |
403 | Widget endpoint only: missing or disallowed request Origin. | No. Add the origin to the token's allowlist. |
404 | Unknown route. Body is {"message": "Not found"}. | No. Check the path. |
429 | Two distinct cases: credits exhausted, or a rate limit. See below. | Depends. Read on. |
Billing problems surface as 429, not 402 Payment Required. Schema violations surface as 400, not 422. Do not write handlers for statuses the API does not send.
400 Validation failed
Returned when the JSON body does not match the endpoint's schema, including when the body is missing or unparseable. issues lists each violation with the offending path.
{
"message": "Validation failed",
"issues": [
{
"code": "too_small",
"minimum": 1,
"path": ["query"],
"message": "Too small: expected string to have >=1 characters"
}
]
}
Common causes: no query and no components on validate, a country that is not exactly two characters, limit outside 1–10, or a source longer than 64 characters. The demo endpoint instead returns {"message": "Type an address first"}.
401 Unauthorized
| Message | Cause |
|---|---|
Missing API key. Send it as: Authorization: Bearer ak_live_… | No Authorization header, or it does not start with Bearer . |
Malformed API key | The token is not shaped like ak_live_… or ak_test_…. Often a truncated or whitespace-padded copy-paste. |
Unknown or revoked API key | The key does not exist or has been revoked. Revocations can take up to a minute to take effect. |
Missing publishable widget token | Widget endpoint: no bearer token. |
Malformed publishable widget token | Widget endpoint: the token is not shaped like gv_pk_live_… or gv_pk_test_…. Sending an ak_… key here lands in this case by design. |
Unknown, revoked, or origin-restricted publishable token | Widget endpoint: the token does not exist, was revoked, its project is archived, or the token is not allowed on this origin. |
403 Forbidden (widget only)
{ "message": "A permitted HTTPS Origin is required" }
The request carried no Origin header, or the origin was not a plain HTTPS origin. Origins must be exact and HTTPS, with no path, query, credentials, or wildcard: https://shop.example is valid, https://shop.example/checkout and http://shop.example are not. A CORS preflight for an origin no active token allows is answered with a bodyless 403.
429, case one: credits exhausted
{
"message": "Monthly credits exhausted (500 plan credits on the free plan). Upgrade or buy credits at https://geoverdict.com/dashboard"
}
The account used its monthly plan credits plus any purchased extra credits. The widget endpoint's equivalent is {"message": "Monthly project credits exhausted"}. No Retry-After header is sent. Do not retry: upgrade the plan, buy a credit pack, or wait for the monthly reset on the 1st (UTC). The message text includes the plan and, when relevant, purchased credits, so it is safe to surface to an operator but not to parse.
429, case two: rate limited
Test API key. Best-effort 60 requests per minute per key:
{ "message": "Test-key rate limit reached (60 requests per minute). Use a live key for production traffic." }
Widget endpoint. 120 requests per minute per token or per origin:
{ "message": "Widget autocomplete rate limit reached" }
Test widget tokens additionally have a best-effort 60/minute per-token limit:
{ "message": "Test widget-token rate limit reached (60 requests per minute)" }
No Retry-After. Back off and retry within the next minute.
Demo endpoint. The homepage demo reports every exhausted rolling window and sets a Retry-After header in seconds:
{
"message": "Demo request allowance reached.",
"limits": [
{ "window": "five_minute", "max": 10, "periodSeconds": 300, "retryAfterSeconds": 233 }
],
"retryAfterSeconds": 233,
"freeMonthlyCredits": 500
}
window is one of five_minute, daily, weekly, monthly; retryAfterSeconds at the top level is the longest wait among the exhausted windows. Classify 429s by the documented message: monthly credit exhaustion needs an upgrade or top-up, and per-minute limits need a short backoff.
Server errors
Unexpected failures surface as a 5xx status. These responses are not part of the documented JSON contract, so do not parse their bodies. Treat any 5xx as transient and retry with exponential backoff.
Note that a provider being slow or failing is not a server error: the engine records it in the routing trace and answers from whatever other providers responded. A lookup where every provider failed still returns 200 with verdict: "invalid" and reason no_results. Check the verdict, not just the status.
Handling pattern
const response = await fetch('https://geoverdict.com/v1/validate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GEOVERDICT_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, country: 'NL' }),
});
if (response.status === 429) {
const { message } = await response.json();
// Live exhaustion needs an upgrade/top-up; test rate limits need backoff.
// Classify the documented message before deciding whether to retry.
throw new Error(message);
}
if (response.status === 401) throw new Error('Check GEOVERDICT_API_KEY');
if (response.status === 400) {
const { issues } = await response.json();
throw new Error(`Bad request: ${JSON.stringify(issues)}`);
}
if (!response.ok) throw new Error(`Retryable server error ${response.status}`);
const result = await response.json();
if (result.verdict === 'invalid') {
// Ask the user to re-enter; result.reasons explains why.
}