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.

6 min Level: Intermediate ✓ Code tested

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. 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_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:

PlanCalls per minute
Hobby360
Basis1,700
Pro7,000
Enterpriseunlimited

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:

HeaderContent
X-RateLimit-LimitThe monthly quota
X-RateLimit-RemainingCalls left this month
X-RateLimit-ResetUnix time (seconds) of the next month boundary
X-RateLimit-Minute-LimitThe per-minute limit, or unlimited on Enterprise
X-RateLimit-Minute-RemainingCalls left this minute
X-RateLimit-Minute-ResetUnix time (seconds) when the minute window resets
X-Quota-Gracetrue when a Hobby account is over its quota and served from the grace buffer
X-Quota-RemainingCalls 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:

Shell#show-headers
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#headers-example
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

codeWhenRetry-AfterWhat to do
rate_limit_per_minuteThe per-minute limit is reachedSeconds until the minute resets (1-60)Wait and retry
rate_limit_per_visitorOne visitor hit the browser-key limitSeconds until the minute resets (1-60)Wait and retry. Debounce your address field
quota_exceededThe Hobby account's quota and grace buffer are used, or your budget cap is reachednoneStop. Wait until the 1st, upgrade or change the budget cap
rate_limit_exceededGeneral quota limit. Not switched on for the current plansnoneTreat 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#quota-exceeded-body
{
  "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#backoff-python
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+:

JavaScript#backoff-js
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.
  • 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.

Next steps

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →