A Node.js client with fetch and @danadresse/client
Write a Node client with fetch, timeouts, error codes and Retry-After, or use the npm package @danadresse/client with a retry layer.
Node 18 and later ship fetch and AbortSignal.timeout, so a Danadresse client needs no dependencies. This guide first shows a small fetch module that covers autocomplete, lookup, datavask (address cleansing), reverse geocoding, error codes and 429. Then it shows the npm package @danadresse/client and what you need to add on top of it.
The code runs on the server. The key in DANADRESSE_API_KEY must never be sent to the browser. If the browser should call the API itself, use a domain-locked key, see Browser keys and allowed domains.
The fetch module
Save the file as danadresse.mjs. Import it and you get the functions. Run it directly with node danadresse.mjs and it makes six calls as a demo:
// danadresse.mjs: en lille klient til Danadresse API med fetch (Node 18+) import { realpathSync } from "node:fs"; import { pathToFileURL } from "node:url"; const BASE_URL = process.env.DANADRESSE_BASE_URL ?? "https://api.danadresse.dk"; const API_KEY = process.env.DANADRESSE_API_KEY; const TIMEOUT_MS = 10_000; const MAX_RETRIES = 2; export class DanadresseError extends Error { constructor(status, code, message, retryAfter, details = null) { super(`HTTP ${status} ${code}: ${message}`); this.status = status; this.code = code; // stabil kode, fx "invalid_api_key" eller "rate_limit_per_minute" this.retryAfter = retryAfter; // sekunder, eller null this.details = details; // fx resets_at og upgrade_url ved quota_exceeded } } const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function toError(res) { let body = {}; try { body = await res.json(); } catch { // ikke JSON (fx en proxy-fejlside) } // DAWA-stier: {type,title,details,code} /api/v1: {error:{code,message}} const err = body?.error && typeof body.error === "object" ? body.error : body ?? {}; const ra = res.headers.get("retry-after"); return new DanadresseError( res.status, err.code ?? null, err.title ?? err.message ?? res.statusText, ra && /^\d+$/.test(ra) ? Number(ra) : null, err.details ?? null, ); } export async function get(path, params = {}) { const url = new URL(path, BASE_URL); for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v)); for (let attempt = 0; ; attempt++) { let res; try { res = await fetch(url, { headers: { "X-Api-Key": API_KEY, Accept: "application/json" }, signal: AbortSignal.timeout(TIMEOUT_MS), }); } catch (e) { // timeout (TimeoutError) eller netværksfejl (TypeError) if (attempt >= MAX_RETRIES) throw e; await sleep(1000 * 2 ** attempt); continue; } if (res.ok) return res.json(); const err = await toError(res); // 429 med Retry-After (minutgrænse) og 503 kan prøves igen. quota_exceeded har ingen Retry-After. if ((res.status === 429 || res.status === 503) && err.retryAfter && attempt < MAX_RETRIES) { await sleep(Math.min(err.retryAfter, 30) * 1000); continue; } throw err; } } function fromDawa(d, ag) { const [lon, lat] = ag.adgangspunkt?.koordinater ?? [null, null]; return { id: d.id, betegnelse: d.adressebetegnelse, postnr: ag.postnummer?.nr ?? null, kommunekode: ag.kommune?.kode ?? null, lon, lat, }; } export const autocomplete = (q, perSide = 5) => get("/autocomplete", { q, per_side: perSide }); export async function adresse(id) { const d = await get(`/adresser/${encodeURIComponent(id)}`); return fromDawa(d, d.adgangsadresse); } export async function vask(betegnelse) { const svar = await get("/datavask/adresser", { betegnelse }); const bedste = svar.resultater?.[0]; // aktueladresse = den gældende version (afviger, hvis den fundne er nedlagt) const a = bedste?.aktueladresse ?? bedste?.adresse ?? null; return { kategori: svar.kategori, // "A" | "B" | "C", kun på topniveau kandidater: svar.resultater?.length ?? 0, sikker: (svar.kategori === "A" || svar.kategori === "B") && a !== null, afstand: bedste?.vaskeresultat?.afstand ?? null, // 0 = præcis match id: a?.id ?? null, tekst: a ? `${a.vejnavn} ${a.husnr}, ${a.postnr} ${a.postnrnavn}` : null, }; } export async function reverse(lon, lat) { const d = await get("/adgangsadresser/reverse", { x: lon, y: lat }); return fromDawa(d, d); } // Demo: køres kun, når filen startes direkte (node danadresse.mjs) if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) { for (const f of await autocomplete("Rådhuspladsen 1, 4200", 3)) console.log("forslag:", f.forslagstekst); const a = await adresse("0a3f50ae-b4ee-32b8-e044-0003ba298018"); console.log("opslag:", a.betegnelse, a.lon, a.lat); for (const tekst of ["Strandvejen 100, 2900 Hellerup", "Rentemestervej 8, 2. tv, 2400 København NV"]) { const v = await vask(tekst); console.log("datavask:", v.kategori, v.kandidater, v.sikker ? v.tekst : "lad brugeren vælge"); } console.log("reverse:", (await reverse(12.5683, 55.6761)).betegnelse); try { await adresse("00000000-0000-0000-0000-000000000000"); } catch (e) { if (!(e instanceof DanadresseError)) throw e; console.log("fejl:", e.status, e.code); } }
Output:
forslag: Rådhuspladsen 1, 4200 Slagelse forslag: Rådhuspladsen 1, st. tv, 4200 Slagelse forslag: Rådhuspladsen 1, st. th, 4200 Slagelse opslag: Rådhuspladsen 1, st. th, 4200 Slagelse 11.35623868 55.40464424 datavask: A 1 Strandvejen 100, 2900 Hellerup datavask: C 7 lad brugeren vælge reverse: Rådhuspladsen 3, 1550 København V fejl: 404 not_found
What the module does
Timeout. AbortSignal.timeout(10_000) aborts the call after 10 seconds. Without it, fetch can in principle wait forever on a hanging connection.
Two error formats, one code. Errors on the DAWA paths have the shape {type, title, details, code}. Errors on /api/v1/* have {error: {code, message, details}}. That holds for every error, 500 included. toError() reads both and gives you err.code, which is stable, and err.details. Branch on it, never on the text. All codes are listed in The error format.
429 and Retry-After. The per-minute limit (rate_limit_per_minute) and the per-visitor limit on browser keys (rate_limit_per_visitor) answer with Retry-After, and the module waits that long and retries. Retry-After is 1 to 60 seconds, the time until the minute window resets. quota_exceeded means a free account has used its monthly quota and the grace buffer, or a paid account has reached the budget cap it set. That response has no Retry-After, and the module throws immediately: retrying cannot help before err.details.resets_at. Show err.details.upgrade_url or alert yourself. 503 upstream_unavailable is returned when a public register is temporarily down, and it carries Retry-After too. Refused calls are not counted or billed. Within the grace buffer (up to 2,400 calls) responses carry X-Quota-Grace: true.
Datavask. vask() returns the top-level category (A, B or C), the number of candidates, afstand and the best address. The category is only at the top level, and there is no score field. A is an exact match. B is exactly one safe match with small spelling differences: store it, with the official text. C is uncertain: show the candidates and let the user pick. In the demo, "Strandvejen 100, 2900 Hellerup" gives A with one candidate, and "Rentemestervej 8, 2. tv, 2400 København NV" gives C with seven, because that number has no 2. tv. betegnelse reads floor and door itself.
Use the module in Express
Let the browser call your own server, so the key stays on the server. The routes translate DanadresseError into a response your frontend can use:
// server.mjs import express from "express"; import { autocomplete, adresse, DanadresseError } from "./danadresse.mjs"; const app = express(); app.get("/api/adresse/forslag", async (req, res, next) => { const q = String(req.query.q ?? "").trim(); if (q.length < 2) return res.json([]); try { const forslag = await autocomplete(q, 8); res.json(forslag.map((f) => ({ tekst: f.forslagstekst, id: f.data.id ?? null, type: f.type }))); } catch (e) { next(e); } }); app.get("/api/adresse/:id", async (req, res, next) => { try { res.json(await adresse(req.params.id)); } catch (e) { next(e); } }); app.use((err, req, res, next) => { if (err instanceof DanadresseError) { // 404 videre som 404. Alt andet er vores problem, ikke brugerens. const status = err.status === 404 ? 404 : 502; return res.status(status).json({ error: err.code ?? "upstream_error" }); } next(err); }); app.listen(3000);
For suggestions of type vejnavn, data.id is empty, because the user has not picked a house number yet. See Address or access address for the difference between the types.
The npm package @danadresse/client
The package is on npm (npm install @danadresse/client) and wraps the most used endpoints:
| Method | Call |
|---|---|
autocomplete(q, { type, fuzzy, perSide }) | GET /autocomplete |
adresse(id) | GET /adresser/{id} |
adresser(params) | GET /adresser |
adgangsadresser(params) | GET /adgangsadresser |
reverse(x, y, srid) | GET /adgangsadresser/reverse |
datavask(betegnelse) | GET /datavask/adresser?betegnelse=… |
postnumre(params) | GET /postnumre |
enrichAll(id), enrichBbr(id), enrichEjendom(id) | GET /api/v1/enrich/adresser/{id}/… |
The constructor takes apiKey, baseUrl and timeoutMs (default 10 seconds). Errors are thrown as DanadresseError with status and body, where body is the response text as a string.
The package does not retry on its own, and it does not give you the response headers, so you cannot read Retry-After or X-RateLimit-*. The error body of the per-minute limit carries the same wait in details.retry_after, so a small retry layer on top can use that:
// adresser.mjs import { DanadresseClient, DanadresseError } from "@danadresse/client"; const api = new DanadresseClient({ apiKey: process.env.DANADRESSE_API_KEY, timeoutMs: 8000, }); // Pakken giver ikke headers, men minutgrænsens fejlkrop har details.retry_after (sekunder) function retryAfterOf(err) { try { const body = JSON.parse(err.body); return (body.details ?? body.error?.details)?.retry_after ?? null; } catch { return null; } } export async function withRetry(fn, retries = 2) { for (let attempt = 0; ; attempt++) { try { return await fn(); } catch (err) { if (!(err instanceof DanadresseError) || attempt >= retries) throw err; // 429 uden retry_after er quota_exceeded: prøv aldrig igen const wait = err.status === 503 ? 10 : err.status === 429 ? retryAfterOf(err) : null; if (wait === null) throw err; await new Promise((r) => setTimeout(r, Math.min(wait, 30) * 1000)); } } } const hits = await withRetry(() => api.autocomplete("Rådhuspladsen 1, 4200", { type: "adresse", perSide: 5 })); const adr = await withRetry(() => api.adresse(hits[0].data.id)); const vask = await withRetry(() => api.datavask("Strandvejen 100, 2900 Hellerup")); const naer = await withRetry(() => api.reverse(12.5683, 55.6761)); console.log(adr.adressebetegnelse, vask.kategori, naer.adressebetegnelse);
A few things to know about the package:
- A timeout surfaces as an
AbortErrorfromfetch, not aDanadresseError.withRetryabove does not retry it. Add that if you need it. - The
enrich*methods do not work with test keys (403sandbox_scope) or browser keys (403browser_key_scope). - If you need the rate-limit headers, for example to throttle a batch job, use the fetch module above.
Next steps
- Limits, quotas and 429 explains the headers and limits.
- Next.js route handler as a proxy shows the same pattern in Next.js.
- The same client in Python: Python client with requests and httpx.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)