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.

5 min Level: Intermediate ✓ Code tested

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:

JavaScript#fetch-module
// 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:

Text#fetch-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:

JavaScript#express-route
// 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:

MethodCall
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:

JavaScript#package-retry
// 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 AbortError from fetch, not a DanadresseError. withRetry above does not retry it. Add that if you need it.
  • The enrich* methods do not work with test keys (403 sandbox_scope) or browser keys (403 browser_key_scope).
  • If you need the rate-limit headers, for example to throttle a batch job, use the fetch module above.

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 →