# The error format: DAWA paths and /api/v1

> Read error responses from both parts of the API with one helper, and react to the stable code instead of the error text.
>
> https://danadresse.dk/en/guides/fejlformat · verified 2026-11-02
The API has two error formats. The DAWA-compatible paths (`/adresser`, `/autocomplete`, `/postnumre` and the rest without `/api/v1`) answer in DAWA's RFC 7807 format, so existing DAWA clients can read the errors. Our own endpoints under `/api/v1` use an `error` envelope. Both formats carry a `code` field you can build your error handling on.

## DAWA paths: type, title, details and code

```json id=dawa-401 run=no
{
  "type": "UnauthorizedError",
  "title": "The provided API key is invalid or revoked.",
  "details": null,
  "code": "invalid_api_key"
}
```

- `type` is DAWA's error type. It depends only on the status code.
- `title` is text for humans. It may be reworded, so do not use it in `if` statements.
- `details` is `null`, an object or a list. For validation errors it is a list of the parameters that were wrong.
- `code` does not exist in DAWA. We added it so you can tell, for example, `key_blocked` from `invalid_api_key` without reading the text. A DAWA client normally ignores fields it does not know, so the addition does not break existing code.

| Status | `type` |
|---|---|
| 400 | `QueryParameterFormatError` |
| 401 | `UnauthorizedError` |
| 403 | `ForbiddenError` |
| 404 | `ResourceNotFoundError` |
| 429 | `RateLimitExceededError` |
| 500 | `InternalServerError` |
| 410 | `GoneError` |
| 502 | `BadGatewayError` |
| 503 | `ServiceUnavailableError` |

A validation error on a DAWA path always has status 400 and `code` `invalid_query_parameter`. `details` shows which parameter and why:

```json id=dawa-400 run=no
{
  "type": "QueryParameterFormatError",
  "title": "Invalid query parameter",
  "details": [
    {
      "type": "less_than_equal",
      "loc": ["query", "per_side"],
      "msg": "Input should be less than or equal to 1000",
      "input": "5000",
      "ctx": {"le": 1000}
    }
  ],
  "code": "invalid_query_parameter"
}
```

## /api/v1: the error envelope

Errors from `/api/v1` have an `error` object with `code`, `message` and optionally `details`. When the error comes from the API's own checks (key, quota, access), the same object also sits under `detail`, so clients written for FastAPI's default format keep working:

```json id=v1-403 run=no
{
  "detail": {
    "error": {
      "code": "browser_key_scope",
      "message": "Domain-locked browser keys cover address lookups only. Use a server-side key for enrich, datavask/parse and stats."
    }
  },
  "error": {
    "code": "browser_key_scope",
    "message": "Domain-locked browser keys cover address lookups only. Use a server-side key for enrich, datavask/parse and stats."
  }
}
```

Validation errors on `/api/v1` have status 422 and only the `error` object:

```json id=v1-422 run=no
{
  "error": {
    "code": "validation_error",
    "message": "Invalid input",
    "details": [
      {"type": "missing", "loc": ["query", "betegnelse"], "msg": "Field required", "input": null}
    ]
  }
}
```

Always read `error.code`. It is present in every `/api/v1` error, while `detail` is only in some.

## The format holds for every error

The format follows the path, wherever the error comes from:

- **500** answers with `type` `InternalServerError` and `code` `internal_error` on the DAWA paths, and with `{"error":{"code":"internal_error",…}}` on `/api/v1`.
- **403 with `code` `forbidden`** comes from our abuse filter, before the call reaches the API. It also has the DAWA format (`type` `ForbiddenError`) on the DAWA paths and the `error` envelope on `/api/v1`.
- **404 on a path that does not exist** has `code` `route_not_found` in both formats.

Every error response from the API carries `Access-Control-Allow-Origin: *`, so JavaScript in a browser can read the status and body instead of getting a CORS error.

Only **502 and 504** fall outside. They come from the proxy in front of the API and have an HTML page as the body. Your code must cope with a response that is not JSON.

## The codes

| `code` | Status | Meaning | What to do |
|---|---|---|---|
| `missing_api_key` | 401 | No key in header or URL | Send `X-Api-Key`. See [Error 401](/en/guides/fejl-401) |
| `invalid_api_key` | 401 | The key does not exist or was deleted | Check spelling and environment variable |
| `account_suspended` | 403 | The account is suspended | Write to us via [support](/en/support) |
| `key_blocked` | 403 | The key is blocked as leaked | Create a new key or lock it to domains |
| `origin_not_allowed` | 403 | Browser key used from another domain | Add the domain or use a server key |
| `browser_key_scope` | 403 | Browser key on enrich/parse/stats | Call from your server with a server key |
| `sandbox_scope` | 403 | Test key on enrich/parse/stats | Use a live key |
| `ip_not_allowed` | 403 | IP-locked key used from another IP | Add the IP to the key |
| `forbidden` | 403 | The abuse filter has blocked your IP | Write to us via [support](/en/support) |
| `invalid_query_parameter` | 400 | Wrong parameter on a DAWA path, e.g. an `srid` other than 4326 and 25832 | Read `details[].loc` and `msg` |
| `invalid_kvh`, `invalid_kvhx` | 400 | The key has the wrong length | 12 characters for kvh, 19 for kvhx |
| `invalid_cirkel`, `invalid_polygon` | 400 | Wrong geometry | See [Error 400 and 404](/en/guides/fejl-400-og-404) |
| `area_too_large` | 400 | `cirkel` or `polygon` covers too many addresses | Make the area smaller, or add `postnr` or `kommunekode` |
| `validation_error` | 422 | Wrong input on `/api/v1` | Read `error.details` |
| `not_found` | 404 | The id or code does not exist | Check the id type |
| `route_not_found` | 404 | The path does not exist | Check the URL |
| `unknown_entity` | 404 | Unknown replication entity | `details.supported` lists the valid ones |
| `quota_exceeded` | 429 | Free monthly quota (grace included) or your budget cap is used up | Wait until the 1st, upgrade or change the budget cap. See `details.reason` |
| `rate_limit_per_minute` | 429 | Too many calls this minute | Wait `Retry-After` seconds |
| `rate_limit_per_visitor` | 429 | One visitor has called too much via a browser key | Wait `Retry-After` seconds |
| `upstream_unavailable` | 503 | A public register is not answering | Retry after `Retry-After` |
| `upstream_busy` | 503 | Short throttle on cold enrichments | Retry after `Retry-After` |
| `internal_error` | 500 | Our fault | Retry, and write to us via [support](/en/support) if it persists |

## One helper for both formats

This function gives you `status`, `code`, `message`, `details` and `retry_after`, whether the error came from a DAWA path, from `/api/v1` or from the proxy. The example looks up an id that does not exist:

```python id=read-error-python run=live expect="not_found"
import os
import requests


def read_error(resp: requests.Response) -> dict:
    """Normalise an error response from api.danadresse.dk."""
    info = {
        "status": resp.status_code,
        "code": None,
        "message": resp.reason,
        "details": None,
        "retry_after": resp.headers.get("Retry-After"),
    }
    try:
        body = resp.json()
    except ValueError:
        info["message"] = resp.text[:200] or resp.reason  # e.g. HTML from a 502
        return info
    if not isinstance(body, dict):
        return info
    err = body.get("error")
    if isinstance(err, dict):  # /api/v1
        info.update(code=err.get("code"), message=err.get("message"),
                    details=err.get("details"))
    elif "type" in body or "title" in body:  # DAWA paths (RFC 7807)
        info.update(code=body.get("code") or body.get("type"),
                    message=body.get("title"), details=body.get("details"))
    return info


r = requests.get(
    "https://api.danadresse.dk/adresser/00000000-0000-0000-0000-000000000000",
    headers={"X-Api-Key": os.environ["DANADRESSE_API_KEY"]},
    timeout=10,
)
if not r.ok:
    err = read_error(r)
    print(err["status"], err["code"], "-", err["message"])
```

Output:

```text id=read-error-output run=no
404 not_found - Adresse 00000000-0000-0000-0000-000000000000 not found
```

The same function in JavaScript, for Node 18+ and browsers:

```js id=read-error-js run=live expect="not_found"
export async function readError(res) {
  const info = {
    status: res.status,
    code: null,
    message: res.statusText,
    details: null,
    retryAfter: res.headers.get("Retry-After"),
  };
  const text = await res.text();
  let body;
  try {
    body = JSON.parse(text);
  } catch {
    info.message = text.slice(0, 200) || res.statusText; // e.g. HTML from a 502
    return info;
  }
  if (body && typeof body.error === "object" && body.error !== null) {
    info.code = body.error.code ?? null;
    info.message = body.error.message ?? info.message;
    info.details = body.error.details ?? null;
  } else if (body && ("type" in body || "title" in body)) {
    info.code = body.code ?? body.type ?? null;
    info.message = body.title ?? info.message;
    info.details = body.details ?? null;
  }
  return info;
}

const res = await fetch(
  "https://api.danadresse.dk/adresser/00000000-0000-0000-0000-000000000000",
  { headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY } },
);
if (!res.ok) {
  const err = await readError(res);
  console.log(err.status, err.code, "-", err.message);
}
```

`Retry-After` is one of the headers the API exposes through CORS, so `retryAfter` works in a browser too. See [Limits, quotas and 429](/en/guides/graenser-og-429).

## Build the logic on code

An example of how the codes split up in a client:

```python id=branch-on-code run=syntax
RETRY = {"rate_limit_per_minute", "rate_limit_per_visitor", "upstream_unavailable",
         "upstream_busy", "internal_error"}
FIX_CONFIG = {"missing_api_key", "invalid_api_key", "key_blocked", "origin_not_allowed",
              "browser_key_scope", "sandbox_scope", "ip_not_allowed", "account_suspended"}


def classify(err: dict) -> str:
    """Return 'retry', 'config', 'stop' or 'bug' for a normalised error response."""
    if err["code"] in RETRY or err["status"] in (502, 504):
        return "retry"
    if err["code"] in FIX_CONFIG:
        return "config"
    if err["code"] == "quota_exceeded":
        return "stop"
    return "bug"
```

- `retry`: try again after a wait. See [Limits, quotas and 429](/en/guides/graenser-og-429).
- `config`: do not retry. The key or its settings must be fixed.
- `stop`: stop calling until the quota resets.
- `bug`: wrong parameters or ids in your code. Log the whole response.

## Next steps

- [Limits, quotas and 429](/en/guides/graenser-og-429)
- [Error 400 and 404](/en/guides/fejl-400-og-404)
- [Error 403: access denied](/en/guides/fejl-403)
