# 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.
>
> https://danadresse.dk/en/guides/node-klient · verified 2026-11-02
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](/en/guides/browser-noegler-domaener).

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

```js id=fetch-module run=live expect="Rådhuspladsen"
// 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 id=fetch-output run=no
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](/en/guides/fejlformat).

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

```js id=express-route run=syntax
// 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](/en/guides/adresse-vs-adgangsadresse) 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:

```js id=package-retry run=syntax
// 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

- [Limits, quotas and 429](/en/guides/graenser-og-429) explains the headers and limits.
- [Next.js route handler as a proxy](/en/guides/nextjs-proxy) shows the same pattern in Next.js.
- The same client in Python: [Python client with requests and httpx](/en/guides/python-klient).
