Hand-built DAWA autocomplete with fetch or jQuery

Move a self-written address field from DAWA's /autocomplete to Danadresse – URL, key, JSONP or JSON, error handling and the caretpos loop.

5 min Level: Intermediate ✓ Code tested

Many teams built their own address field on top of DAWA's /autocomplete with fetch, XMLHttpRequest or jQuery. That kind of code works again with a few changes, because the response has the same shape: a list of suggestions with type, tekst, forslagstekst, caretpos and data.

This guide goes through the five things to change and shows a complete example.

1. Change the URL and add a browser key

Typical DAWA code looked like this:

JavaScript#before-jquery
$("#adresse").on("input", function () {
  $.ajax({
    url: "https://dawa.aws.dk/autocomplete",
    dataType: "jsonp",
    data: { q: this.value, caretpos: this.selectionStart, type: "adresse" },
    success: showSuggestions
  });
});

The domain becomes https://api.danadresse.dk, and the call needs a key. The code runs in the browser, so the key is visible to everyone. Use a key that is locked to your domains under Tilladte domæner (allowed domains) in Keys – see Browser keys and allowed domains.

Send the key as api_key in the URL. An X-Api-Key header works too, but a custom header makes the browser send an extra CORS request (OPTIONS) before the call, and it does so for every new URL – that is, for every keystroke. With api_key in the URL you avoid that extra round trip.

2. Prefer JSON over JSONP

JSONP works as in DAWA: with callback=fn the response is wrapped as fn(…) (application/javascript), so jQuery's dataType: "jsonp" keeps working once the key is added. But with JSONP the browser does not run the script when the call fails, so your code never sees the status or the error code. CORS is open to every domain, so switch to dataType: "json" or fetch when you touch the code anyway.

3. Handle errors

DAWA almost always answered 200. With us a call can fail for reasons that concern the key and usage. Error responses on the DAWA paths have DAWA's shape {"type", "title", "details", "code"}. Branch on code:

StatuscodeWhat to do
401missing_api_key, invalid_api_keyShow no suggestions and log the error. It is a configuration error, so do not retry
403origin_not_allowedThe page's domain is missing from the key's list. Do not retry
403key_blockedThe key was blocked as leaked. Lock it to your domains or create a new one
429rate_limit_per_visitor, rate_limit_per_minutePause for a few seconds
429quota_exceededThe free plan's monthly quota is used up. Do not retry before next month

The per-minute 429 responses carry Retry-After with the number of seconds to wait (1 to 60), and the browser lets your script read it. Pause for that long. quota_exceeded has no Retry-After; stop asking until the user reloads the page. An address field that stops suggesting for a moment is better than one that keeps hitting the limit.

4. Understand the caretpos loop

DAWA's autocomplete works in three steps: street name, adgangsadresse (house number) and adresse (floor and door). When the user picks a suggestion that is not the final level, you:

  1. set the field's text to the suggestion's tekst,
  2. put the cursor at caretpos,
  3. ask again with the new text and caretpos.

This continues until the user picks a suggestion of the type you want to end with. For an adgangsadresse suggestion, tekst is e.g. "Vesterbrogade 145, , 1620 København V" with the cursor right after "145, ", so the user can type floor and door into the empty slot. Use forslagstekst to show the suggestion in the list and tekst for the field.

One difference from DAWA: if you send type=adresse, we return only address-level suggestions, even while the user is still typing the street name. DAWA showed street names first. Without type you get the three steps as in DAWA. Write the loop so it works either way: when the user picks a suggestion of the wanted type, you are done. The filters postnr, kommunekode and adgangsadresseid narrow the suggestions as in DAWA, and typo tolerance is a flag as in DAWA: on with fuzzy (or fuzzy=), off without it or with fuzzy=false.

You can try the loop from Node. It starts with "Rådhuspladsen 1", picks the first suggestion and drills down until it reaches an address:

JavaScript#drill-loop
const API = "https://api.danadresse.dk/autocomplete";
const headers = { "X-Api-Key": process.env.DANADRESSE_API_KEY };

let text = "Rådhuspladsen 1";
let caretpos = text.length;

for (let step = 1; step <= 3; step++) {
  const url = new URL(API);
  url.search = new URLSearchParams({ q: text, caretpos: String(caretpos), per_side: "5" });
  const res = await fetch(url, { headers });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const suggestions = await res.json();
  if (suggestions.length === 0) break;

  const picked = suggestions[0];
  console.log(`step ${step}: ${picked.type} -> "${picked.tekst}" (caretpos ${picked.caretpos})`);
  if (picked.type === "adresse") {
    console.log(`done, address id ${picked.data.id}`);
    break;
  }
  text = picked.tekst;
  caretpos = picked.caretpos;
}

5. A complete field with fetch

The example waits 200 ms after the last keystroke (debounce), cancels a call that has gone stale and handles the errors from step 3. It uses only the browser's own APIs:

JavaScript#fetch-field
const API = "https://api.danadresse.dk/autocomplete";
const API_KEY = "dawa_live_your_browser_key"; // domain-locked browser key
const TARGET = "adresse"; // or "adgangsadresse" if you do not want floor and door

const input = document.querySelector("#adresse");
const list = document.querySelector("#suggestions");
let controller = null;
let timer = null;
let pauseUntil = 0;

async function fetchSuggestions(text, caretpos) {
  if (Date.now() < pauseUntil || text.trim().length < 2) return [];
  controller?.abort();
  controller = new AbortController();
  const url = new URL(API);
  url.search = new URLSearchParams({
    q: text,
    caretpos: String(caretpos),
    per_side: "10",
    api_key: API_KEY,
  });
  let res;
  try {
    res = await fetch(url, { signal: controller.signal });
  } catch (err) {
    if (err.name === "AbortError") return null; // a newer keystroke took over
    throw err;
  }
  if (res.status === 429) {
    const error = await res.json();
    // quota_exceeded: the month is used up, stop asking. Otherwise wait Retry-After seconds.
    const wait = error.code === "quota_exceeded" ? 3600 : Number(res.headers.get("Retry-After")) || 5;
    pauseUntil = Date.now() + wait * 1000;
    return [];
  }
  if (res.status === 401 || res.status === 403) {
    const error = await res.json();
    console.error("Danadresse:", error.code, error.title);
    return [];
  }
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
  return res.json();
}

function showSuggestions(suggestions) {
  list.replaceChildren(...suggestions.map((s) => {
    const li = document.createElement("li");
    li.textContent = s.forslagstekst;
    li.addEventListener("mousedown", (e) => {
      e.preventDefault();
      pick(s);
    });
    return li;
  }));
}

async function refresh(text, caretpos) {
  const suggestions = await fetchSuggestions(text, caretpos);
  if (suggestions) showSuggestions(suggestions);
}

function pick(s) {
  input.value = s.tekst;
  input.setSelectionRange(s.caretpos, s.caretpos);
  if (s.type === TARGET) {
    list.replaceChildren();
    input.dataset.addressId = s.data.id; // store the id, not just the text
    return;
  }
  refresh(s.tekst, s.caretpos); // street name or adgangsadresse: drill down
}

input.addEventListener("input", () => {
  delete input.dataset.addressId;
  clearTimeout(timer);
  timer = setTimeout(() => refresh(input.value, input.selectionStart), 200);
});

With jQuery the change is smaller. Change the URL and dataType, and add the key and an error handler:

JavaScript#after-jquery
$("#adresse").on("input", function () {
  $.ajax({
    url: "https://api.danadresse.dk/autocomplete",
    dataType: "json",
    data: { q: this.value, caretpos: this.selectionStart, api_key: API_KEY },
    success: showSuggestions,
    error: function (xhr) {
      var error = xhr.responseJSON || {};
      console.error("Danadresse:", xhr.status, error.code);
    }
  });
});

Remember debounce here too. Without it every keystroke uses a call from your quota.

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 →