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.
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: trueandX-Quota-Remainingwith the calls left in the buffer. Once the buffer is used too, the API answers429 quota_exceededuntil 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. If you want a ceiling, set a budget cap under Billing: stop at the included quota, or a maximum overage amount in DKK. Once the cap is reached, the API answers
429 quota_exceededuntil 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.
- 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:
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'
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.
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:
{
"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_dkkshows 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:
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+:
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=1000on/adresseris one call instead of ten. See Pagination. - Fetch whole data sets with replication. If you need every address in a municipality or the whole country, use
/replikering/udtraekrather 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-Remainingand pause, as the functions above do.
You can follow the account's usage under Usage.
Next steps
- The error format for the other error codes.
- Monitor usage
- Retries and backoff in production
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)