# Limits, quotas and 429

> Understand the monthly quota and the per-minute limit, read the X-RateLimit headers, and handle each 429 code with a backoff that honours Retry-After.
>
> https://danadresse.dk/en/guides/graenser-og-429 · verified 2026-11-02
The API has two kinds of limits: a monthly quota and a per-minute limit. They are counted separately and each has its own 429 response. This guide shows how to read them from the headers and how your code should react.

## Two limits

**The monthly quota** is the number of calls your plan includes per calendar month. It resets on the 1st of the month at 00:00 UTC.

- On the Hobby plan the quota is 2,000 calls. All live keys on the account share the same quota. Once it is used, you are still served up to 2,400 calls (a grace buffer of 20 %). Those responses carry `X-Quota-Grace: true` and `X-Quota-Remaining` with the calls left in the buffer. Once the buffer is used too, the API answers `429 quota_exceeded` until the 1st of next month.
- On paid plans calls beyond the quota are not refused. They are billed as overage. See the prices under [Pricing](/#pricing). If you want a ceiling, set a budget cap under [Billing](/dashboard/billing#budget): stop at the included quota, or a maximum overage amount in DKK. Once the cap is reached, the API answers `429 quota_exceeded` until the 1st.

Calls refused with 429 are not counted as usage and are not billed. They are not served either, so a client that retries in a loop gets nowhere.

**The per-minute limit** protects against spikes. It is hard on every plan except Enterprise, which has no per-minute limit:

| Plan | Calls per minute |
|---|---|
| Hobby | 360 |
| Basis | 1,700 |
| Pro | 7,000 |
| Enterprise | unlimited |

The minute window follows the clock: it starts at each full minute and resets at the next one.

Three special cases:

- **Test keys** are metered per client IP with the Hobby plan's limits. See [Test keys and the sandbox](/en/guides/testnoegler-sandbox).
- **Browser keys** (locked to domains) also have a per-visitor limit: 120 calls a minute from the same IP address.
- **MCP calls** (from our MCP server) have their own monthly quota, separate from the API quota.

## The headers

Every response to a call with a key carries these headers:

| Header | Content |
|---|---|
| `X-RateLimit-Limit` | The monthly quota |
| `X-RateLimit-Remaining` | Calls left this month |
| `X-RateLimit-Reset` | Unix time (seconds) of the next month boundary |
| `X-RateLimit-Minute-Limit` | The per-minute limit, or `unlimited` on Enterprise |
| `X-RateLimit-Minute-Remaining` | Calls left this minute |
| `X-RateLimit-Minute-Reset` | Unix time (seconds) when the minute window resets |
| `X-Quota-Grace` | `true` when a Hobby account is over its quota and served from the grace buffer |
| `X-Quota-Remaining` | Calls left in the grace buffer. Only together with `X-Quota-Grace` |

On Enterprise only `X-RateLimit-Minute-Limit: unlimited` is sent and the other two minute headers are absent. See your own numbers:

```bash id=show-headers run=live expect="x-ratelimit-minute-remaining"
curl -s -D - -o /dev/null "https://api.danadresse.dk/postnumre/8000" \
  -H "X-Api-Key: $DANADRESSE_API_KEY" | tr 'A-Z' 'a-z' | grep '^x-ratelimit'
```

```http id=headers-example run=no
x-ratelimit-minute-limit: 360
x-ratelimit-minute-remaining: 41
x-ratelimit-minute-reset: 1792072860
x-ratelimit-limit: 2000
x-ratelimit-remaining: 1873
x-ratelimit-reset: 1796083200
```

The numbers are only an example.

> [!NOTE]
> Every header in the table and `Retry-After` is exposed through CORS. JavaScript in a browser can read them with `headers.get()`, on a 429 response too.

## The four 429 codes

| `code` | When | `Retry-After` | What to do |
|---|---|---|---|
| `rate_limit_per_minute` | The per-minute limit is reached | Seconds until the minute resets (1-60) | Wait and retry |
| `rate_limit_per_visitor` | One visitor hit the browser-key limit | Seconds until the minute resets (1-60) | Wait and retry. Debounce your address field |
| `quota_exceeded` | The Hobby account's quota and grace buffer are used, or your budget cap is reached | none | Stop. Wait until the 1st, upgrade or change the budget cap |
| `rate_limit_exceeded` | General quota limit. Not switched on for the current plans | none | Treat like `quota_exceeded` |

`quota_exceeded` deliberately has no `Retry-After`. The honest value would be "in a few weeks", and HTTP clients that honour `Retry-After` automatically would hang that long. Use `details.resets_at` (ISO-8601, UTC), `details.reset_at` (Unix time) or `X-RateLimit-Reset` instead:

```json id=quota-exceeded-body run=no
{
  "type": "RateLimitExceededError",
  "title": "The free quota of … API calls/month is used up (including the grace buffer up to … calls). …",
  "details": {
    "limit": 2000,
    "used": 2400,
    "reset_at": 1796083200,
    "resets_at": "2026-12-01T00:00:00Z",
    "upgrade_url": "https://danadresse.dk/dashboard/billing",
    "docs_url": "https://danadresse.dk/docs",
    "reason": "free_quota",
    "grace_limit": 2400
  },
  "code": "quota_exceeded"
}
```

The `title` is written for people, in Danish or English depending on `Accept-Language`. Without the header it is Danish. On `/api/v1` the same fields sit under `error`. `details.reason` tells you why:

- `free_quota`: the Hobby quota and the grace buffer are used.
- `budget_stop_at_quota`: your budget cap stops at the included quota.
- `budget_cap_dkk`: your overage cap is reached. `details.budget_cap_dkk` shows the amount.

For the two budget reasons `details.budget_url` points to the page where you change the cap. Show `title` and `details.upgrade_url` to whoever owns the account, and do not retry automatically.

A 429 for the per-minute limit has `Retry-After` and `details.retry_after` with the seconds until the minute resets, but not the minute headers. You find those on the previous, successful response.

## A backoff that reads the headers

This function retries on a per-minute 429, 502, 503 and 504. The wait comes from `Retry-After`, otherwise from `X-RateLimit-Minute-Reset`, otherwise it grows exponentially. On `quota_exceeded` it stops at once. It also slows down by itself when `X-RateLimit-Minute-Remaining` hits 0:

```python id=backoff-python run=live expect="Aarhus"
import os
import random
import time

import requests

API = "https://api.danadresse.dk"
session = requests.Session()
session.headers["X-Api-Key"] = os.environ["DANADRESSE_API_KEY"]


class QuotaExceeded(Exception):
    pass


def _code(resp: requests.Response) -> str | None:
    try:
        body = resp.json()
    except ValueError:
        return None
    if not isinstance(body, dict):
        return None
    err = body.get("error")
    return err.get("code") if isinstance(err, dict) else body.get("code")


def _wait(resp: requests.Response, attempt: int) -> float:
    retry_after = resp.headers.get("Retry-After", "")
    if retry_after.isdigit():
        return int(retry_after)
    reset = resp.headers.get("X-RateLimit-Minute-Reset", "")
    if reset.isdigit():
        return max(1, int(reset) - time.time())
    return min(60, 2 ** attempt) + random.random()


def get(path: str, params: dict | None = None, attempts: int = 5) -> requests.Response:
    for attempt in range(attempts):
        resp = session.get(API + path, params=params, timeout=15)
        if resp.status_code == 429:
            if _code(resp) in ("quota_exceeded", "rate_limit_exceeded"):
                reset = resp.headers.get("X-RateLimit-Reset", "?")
                raise QuotaExceeded(f"Monthly quota used. Resets at {reset}")
        elif resp.status_code not in (502, 503, 504):
            if resp.headers.get("X-RateLimit-Minute-Remaining") == "0":
                time.sleep(_wait(resp, attempt))  # slow down before the next call
            return resp
        time.sleep(_wait(resp, attempt))
    return resp


r = get("/postnumre/8000")
r.raise_for_status()
print(r.json()["navn"], "| left this minute:", r.headers.get("X-RateLimit-Minute-Remaining"))
```

The same logic in JavaScript for Node 18+:

```js id=backoff-js run=live expect="Aarhus"
const API = "https://api.danadresse.dk";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export class QuotaExceeded extends Error {}

async function errorCode(res) {
  try {
    const body = await res.clone().json();
    return body?.error?.code ?? body?.code ?? null;
  } catch {
    return null;
  }
}

function waitMs(res, attempt) {
  const retryAfter = Number(res.headers.get("Retry-After"));
  if (retryAfter > 0) return retryAfter * 1000;
  const reset = Number(res.headers.get("X-RateLimit-Minute-Reset"));
  if (reset > 0) return Math.max(1000, reset * 1000 - Date.now());
  return Math.min(60_000, 2 ** attempt * 1000) + Math.random() * 1000;
}

export async function get(path, { attempts = 5 } = {}) {
  let res;
  for (let attempt = 0; attempt < attempts; attempt++) {
    res = await fetch(API + path, {
      headers: { "X-Api-Key": process.env.DANADRESSE_API_KEY },
      signal: AbortSignal.timeout(15_000),
    });
    if (res.status === 429) {
      const code = await errorCode(res);
      if (code === "quota_exceeded" || code === "rate_limit_exceeded") {
        throw new QuotaExceeded(`Monthly quota used. Resets at ${res.headers.get("X-RateLimit-Reset")}`);
      }
    } else if (![502, 503, 504].includes(res.status)) {
      if (res.headers.get("X-RateLimit-Minute-Remaining") === "0") {
        await sleep(waitMs(res, attempt)); // slow down before the next call
      }
      return res;
    }
    await sleep(waitMs(res, attempt));
  }
  return res;
}

const res = await get("/postnumre/8000");
const postnummer = await res.json();
console.log(postnummer.navn, "| left this minute:", res.headers.get("X-RateLimit-Minute-Remaining"));
```

## Avoid 429 in the first place

- **Debounce autocomplete.** Only send a call once the user has paused for 150-250 ms. An address field without debounce spends one call per keystroke.
- **Cache responses that do not change.** Postcodes, municipalities and an address looked up by id rarely change. Keep them for hours or days.
- **Use large pages.** `per_side=1000` on `/adresser` is one call instead of ten. See [Pagination](/en/guides/paginering).
- **Fetch whole data sets with replication.** If you need every address in a municipality or the whole country, use `/replikering/udtraek` rather than thousands of page calls.
- **Spread out batch jobs.** An import with thousands of calls must stay under the per-minute limit. Read `X-RateLimit-Minute-Remaining` and pause, as the functions above do.

You can follow the account's usage under [Usage](/dashboard/usage).

## Next steps

- [The error format](/en/guides/fejlformat) for the other error codes.
- [Monitor usage](/en/guides/overvaag-forbrug)
- [Retries and backoff in production](/en/guides/retries-og-backoff)
