Skip to content
worldgovdata
Documentation / Core concepts

Core concepts

Errors

One envelope, one machine-readable code, and a message written for the person reading the log. Branch on the code; show the message.

Every failure replaces the whole body with a single error object. There is no partial success and no mixed response.

The envelope

Every error

{
  "error": {
    "code": "indicator_not_found",
    "message": "Unknown indicator: wdi.gdpp"
  }
}
  • code — a stable, machine-readable slug. This is the contract. Switch on it.
  • message — one sentence for a human. Show it, log it, but never parse it: the wording may be improved at any time and is not versioned.

Some codes add fields. The extras are always additive, so a client that reads only code and message keeps working:

  • 402 adds required, balance and top_up_url.
  • 429 adds retry_after, mirroring the Retry-After header.
  • A parameter that fails validation adds details — an array naming the parameter and the rule it broke.

Validation failure · 400

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "bad_request",
    "message": "Invalid or missing query parameters",
    "details": [
      { "loc": "query.limit", "msg": "Input should be less than or equal to 500" }
    ]
  }
}

Every code

The full list, grouped by status. If you implement handling for six of these, make them invalid_key, insufficient_credits, display_only, too_many_rows, rate_limited and internal.

400 — the request is malformed

Something in the query string is wrong. These are deterministic: the same request will fail the same way every time, so retrying is never the answer.

400 — the request is malformed
HTTPCodeCauseWhat to do
400bad_requestA query parameter failed validation — wrong type, or out of range.Read the details array in the body; it names the parameter and the rule it broke.
400bad_formatformat was neither json nor csv on /v1/data.Send format=json or format=csv. Nothing else is accepted.
400bad_rangefrom is greater than to on /v1/data.Swap them. Both bounds are inclusive.
400bad_yearyear on /v1/rankings was neither latest nor four digits.Send year=latest or year=2024.
400bad_sortsort on /v1/indicators is not one of the five accepted values.Use name, coverage, latest, topic or source.
400bad_dirdir on /v1/indicators was not asc or desc.Use asc or desc — and note dir only applies when sort is also set.
400too_many_rowsA /v1/data query matches more than 100,000 rows.The message carries the real count. Chunk by countries or narrow the year range. This costs 0 credits.

401, 402, 403 — credentials, credits, licence

Three different refusals that are easy to confuse. 401 is who you are, 402 is what you hold, 403 is what the data permits — and only the second is fixed by money.

401, 402, 403 — credentials, credits, licence
HTTPCodeCauseWhat to do
401unauthorizedNo credentials on a route that costs credits.Send Authorization: Bearer wgd_live_… — or call one of the free endpoints instead.
401invalid_keyThe key is unknown, malformed, revoked, expired, or its account is closed.One code covers all of those on purpose. Check the key you deployed; if in doubt, mint a new one.
402insufficient_creditsYour balance is below what the request needs.The body carries required, balance and top_up_url. Top up, then retry the identical request.
403display_onlyThe indicator's licence permits display but not redistribution.Not a billing problem and topping up will not clear it. Read /docs/licensing.
403forbiddenThe generic 403, for a refusal that is not one of the two specific codes above. Rare on the data API.Check the indicator on /v1/indicators/{id}. If it is absent from the catalogue, it is not servable at all.
403key_scopeThe key lacks the scope a route requires.Reserved. Every key today carries read, and no public endpoint requires more.

404, 405, 409 — not there

The route, the resource, or the method does not exist.

404, 405, 409 — not there
HTTPCodeCauseWhat to do
404indicator_not_foundNo enabled, redistributable indicator has that id.Ids are exact and case-sensitive. Search for it with /v1/indicators?q=…
404country_not_foundNo enabled country has that ISO3 code.Use the three-letter ISO 3166-1 alpha-3 code. /v1/countries lists every one we serve.
404no_rankingsThe indicator exists but has no ranked years at all.Indicators flagged no_rank are never ranked. Read the values from /v1/data instead.
404not_foundThe path does not exist.Check the base URL and the spelling. Every public route is listed on /docs/endpoints.
405method_not_allowedYou sent something other than GET.The whole public API is read-only. Every route is a GET.
409conflictAn account operation collided with existing state.Account API only; the public data API never returns it.

423, 429 — too many

Both carry Retry-After, and it is authoritative.

423, 429 — too many
HTTPCodeCauseWhat to do
423account_lockedToo many failed sign-in attempts from one address or one account.Wait for the period in Retry-After. Sign-in only — a valid key is never locked out this way.
429rate_limitedYou exceeded the per-minute, per-day or concurrent limit for your tier.Sleep for Retry-After seconds, then retry. See /docs/rate-limits for a correct backoff.

500, 503 — our side

Retry these, with backoff. Nothing about your request needs to change.

500, 503 — our side
HTTPCodeCauseWhat to do
500internalWe broke. Never your fault, never a stack trace in the body.Retry with backoff. If it persists, send us the X-Request-Id.
503accounts_unavailableThe accounts layer is deploying or not yet migrated.Free endpoints keep working throughout. Retry the metered ones shortly.
503payments_unavailableNo payment provider is configured.Account API only. Credits can still be granted; the data API is unaffected.

The ones you will actually meet

401 · no key, or a key we do not accept

unauthorized · 401

HTTP/1.1 401 Unauthorized

{
  "error": {
    "code": "unauthorized",
    "message": "Authentication required"
  }
}

unauthorized means the header was missing or empty on a route that costs credits. invalid_key means we read a credential and rejected it — unknown, malformed, revoked, expired, or belonging to a closed account. All five collapse into one code deliberately: telling them apart would help someone probing the key space and helps you not at all, because the fix is the same in every case. Check what you deployed. See Authentication.

402 · out of credits

insufficient_credits · 402

HTTP/1.1 402 Payment Required

{
  "error": {
    "code": "insufficient_credits",
    "message": "This request needs 1 credit and your balance is 0.",
    "required": 1,
    "balance": 0,
    "top_up_url": "https://worldgovdata.com/account/billing"
  }
}

Nothing was served and nothing was charged. Do not retry — a 402 is not transient. Stop the loop, record where you stopped, top up, and re-send the identical request. Full handling advice on Credits.

403 · the licence says no

display_only · 403

HTTP/1.1 403 Forbidden

{
  "error": {
    "code": "display_only",
    "message": "Indicator 'oecd.example_series' is display-only: viewable on the portal with attribution, but not available for CSV/JSON export."
  }
}

This is the one refusal money cannot clear. The indicator’s provider permits display but not redistribution, so the value is visible on the portal and not exportable here. Check access_tier on the indicator record before you build a pipeline around a series. Licensing has the detail.

400 · too many rows

too_many_rows · 400

HTTP/1.1 400 Bad Request

{
  "error": {
    "code": "too_many_rows",
    "message": "Query matches 412903 rows, over the 100000 limit. Narrow by countries or year range."
  }
}

The message carries the real count, which is what makes it actionable — divide it by 100,000 and you know how many chunks you need. Nothing was served and no credits were spent: the count runs before any data is read. Pagination has a complete chunking recipe.

429 · slow down

rate_limited · 429

HTTP/1.1 429 Too Many Requests
Retry-After: 37
Cache-Control: no-store
X-Request-Id: 0f3a91c4e2b7
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1785315600

{
  "error": {
    "code": "rate_limited",
    "message": "Rate limit exceeded.",
    "retry_after": 37
  }
}

Sleep for Retry-After seconds, then retry the same request. It costs no credits. A correct backoff, in five languages, is on Rate limits.

5xx · our fault

A 500 never leaks a stack trace, a SQL fragment or an internal hostname; what it does carry is a request id, which also appears in our logs. Retry with backoff. If it persists, send us the X-Request-Id from the response headers and we can find the exact request without you having to reproduce anything.

503 accounts_unavailable is a specific, expected state: the accounts layer is deploying or has not been migrated yet. The free catalogue endpoints keep working throughout, by design — a deploy of the metering layer never takes the data API down.

Handling errors well

  • Retry 429, 5xx and transport failures. Nothing else. Every other 4xx is deterministic; retrying is guaranteed not to help and spends your quota proving it.
  • Treat 402 as a budget event, not an outage. Page whoever owns the account, not whoever is on call.
  • Treat 403 display_only as data, not as a failure. It is a permanent property of that indicator. Record it and skip the series; do not build a retry around it.
  • Log the request id. One field, attached to every response, that turns “it failed sometimes last Tuesday” into a single log line we can look up.
  • Default unknown codes to non-retryable. New codes may appear. Failing loudly on one you do not recognise is safer than looping on it.