Replication, part 2: keep your copy current with events
Fetch changes with sekvensnummerfra and maxantal, apply insert, update and delete as idempotent upserts, and store the cursor safely.
Once you have loaded a full extract (see part 1), you keep the copy current by fetching events: one row per change, numbered with an increasing sequence number. You store the last sequence number you processed and next time ask for everything that has arrived since.
Sequence numbers
The sequence number is global and always increases. It is shared by all entities, so for a single entity there are gaps in the series. That is normal. The numbering continues from DAWA's. We serve the events ourselves; nothing is forwarded to DAWA, which closed on 1 October 2026. Our event log only goes back to the summer of 2026, and since the switch to Datafordeleren in October 2026 we number the events ourselves. So a new copy always starts with a full extract, and you follow events from the sequence number you saved before the extract, + 1. Do not continue from a cursor you saved from DAWA.
/replikering/senestesekvensnummer gives the highest number available right now (it needs no key, and /replikering/senesteSekvensnummer also works). Events are fetched with:
GET /replikering/haendelser?entitet=adresse&sekvensnummerfra=207922000&sekvensnummertil=207940624&maxantal=1000 X-Api-Key: $DANADRESSE_API_KEY
| Parameter | Meaning |
|---|---|
sekvensnummerfra | First sequence number, inclusive. Send your cursor + 1 |
sekvensnummertil | Last sequence number, inclusive. Optional. Send the value from senestesekvensnummer |
maxantal | Optional. At most this many events in the response, 1 to 50000. Without maxantal you get the whole range from sekvensnummerfra to sekvensnummertil |
The response is a JSON array sorted by sequence number, oldest first. Without maxantal it holds the whole range, which can be large after a long pause. With maxantal you page: if the response has fewer rows than maxantal, you have everything. Otherwise ask again from the last sequence number + 1.
Try it
Fetch the latest sequence number and three adresse events from a little earlier:
import os import requests BASE = "https://api.danadresse.dk" H = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]} seneste = requests.get(f"{BASE}/replikering/senestesekvensnummer", headers=H, timeout=10).json() print("seneste sekvensnummer:", seneste["sekvensnummer"]) r = requests.get( f"{BASE}/replikering/haendelser", params={"entitet": "adresse", "sekvensnummerfra": seneste["sekvensnummer"] - 20000, "maxantal": 3}, headers=H, timeout=30, ) r.raise_for_status() for h in r.json(): print(h["sekvensnummer"], h["operation"], h["tidspunkt"], h["data"]["id"])
An event looks like this:
{
"txid": null,
"sekvensnummer": 207922077,
"tidspunkt": "2026-10-15T08:27:59.776Z",
"operation": "delete",
"data": {
"id": "ff5063fe-432a-4b9a-ae18-7fcb0fbed0f4",
"status": 2,
"adgangsadresseid": "547c02a1-b1ce-4fc6-a40e-612c77d75490",
"etage": null,
"doer": null,
"oprettet": "2026-10-15T10:27:59.776",
"aendret": "2026-10-15T10:27:59.776"
}
}Operations and data
operation | What you do |
|---|---|
insert | Upsert the row |
update | Upsert the row. Merge the fields into what you have |
delete | Mark the row as retired (status 2) or withdrawn (status 4), or delete it |
A few things worth knowing:
- Insert and update are handled the same. A new object arrives as
insertand a change asupdate. Older events in the log can carryupdatefor an object that was new, so use an upsert for both. deletecarries data. The event holds the row's last state, typically withstatus2 or 4. Many keep the row with that status so an old id can still be looked up.datacan be partial. An event does not always carry every field from the extract. Merge the fields into the existing row instead of replacing it.- Field names. Events for
adgangsadresseuse DAWA's names with æ and ø (etrs89koordinat_øst,nøjagtighed,ændret), while the extract usesetrs89_x,noejagtighedandaendret. Olderadresseevents havedørandændret. Rename them when you store. - Coordinates.
adgangsadresseevents only carry ETRS89/UTM32 coordinates (srid 25832). If you need longitude and latitude, look the address up with/adgangsadresser/{id}or convert yourself. See Coordinates and srid. txidcan benull: events fetched from Datafordeleren have no transaction id. Usesekvensnummeras the key.tidspunktis UTC with milliseconds andZ.
Ongoing events exist for postnummer, navngivenvej, vejstykke, adgangsadresse and adresse. Other entities, such as kommune, region and sogn, return an empty list. They rarely change, so fetch them again with a new extract once a month.
The sync script
The script continues where the first extract left off and uses the same SQLite file and replikering_cursor table:
"""synk.py: hent hændelser siden sidste kørsel og opdater SQLite-kopien fra førsteudtrækket. Kør fx hvert 15. minut fra cron. Sikker at køre igen efter et nedbrud: hændelser anvendes som upserts, og cursoren gemmes i samme transaktion som ændringerne. """ import json import os import sqlite3 import time import requests BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk") HEADERS = {"X-Api-Key": os.environ["DANADRESSE_API_KEY"]} # Uden maxantal kommer hele intervallet fra..til i ét svar. Med maxantal deler du # det op i sider, så én side aldrig bliver for stor (højst 50000). MAXANTAL = 1000 # Samme rækkefølge og nøgler som i førsteudtrækket ENTITETER = { "postnummer": ["nr"], "kommune": ["kode"], "navngivenvej": ["id"], "vejstykke": ["kommunekode", "vejkode"], "adgangsadresse": ["id"], "adresse": ["id"], } # Hændelser bruger enkelte andre feltnavne end udtrækket (DAWA-navne med æ/ø). # Omdøb dem, så de lander i de samme nøgler i din tabel. OMDOEB = { "dør": "doer", "ændret": "aendret", "ikrafttrædelsesdato": "ikrafttraedelsesdato", "nøjagtighed": "noejagtighed", "etrs89koordinat_øst": "etrs89_x", "etrs89koordinat_nord": "etrs89_y", } def get(path: str, **params) -> object: for forsoeg in range(3): r = requests.get(BASE_URL + path, params=params, headers=HEADERS, timeout=(5, 60)) ra = r.headers.get("Retry-After", "") if r.status_code in (429, 503) and ra.isdigit() and forsoeg < 2: time.sleep(min(int(ra), 30)) continue r.raise_for_status() return r.json() raise RuntimeError("unreachable") def cursor(db: sqlite3.Connection, entitet: str) -> int: row = db.execute("SELECT sekvensnummer FROM replikering_cursor WHERE entitet = ?", (entitet,)).fetchone() if row is None: raise SystemExit(f"{entitet}: ingen cursor. Kør førsteudtrækket først.") return row[0] def gem_cursor(db: sqlite3.Connection, entitet: str, sekvensnummer: int) -> None: db.execute("UPDATE replikering_cursor SET sekvensnummer = ? WHERE entitet = ?", (sekvensnummer, entitet)) def anvend(db: sqlite3.Connection, entitet: str, noegle: list[str], h: dict) -> None: data = {OMDOEB.get(k, k): v for k, v in h["data"].items()} if h["operation"] == "delete": # Behold rækken som nedlagt (2) eller henlagt (4), så gamle id'er kan slås op data["status"] = data.get("status") if data.get("status") in (2, 4) else 4 pk = "-".join(str(data[k]) for k in noegle) # insert og update er begge en upsert. data kan være delvis, så flet den ind # i den eksisterende række (json_patch) i stedet for at overskrive den. db.execute( f"INSERT INTO {entitet} (pk, status, data) VALUES (?, ?, ?) " "ON CONFLICT(pk) DO UPDATE SET status = coalesce(excluded.status, status), " "data = json_patch(data, excluded.data)", (pk, data.get("status"), json.dumps(data, ensure_ascii=False)), ) def synk_entitet(db: sqlite3.Connection, entitet: str, noegle: list[str], til: int) -> int: fra = cursor(db, entitet) + 1 if fra > til: return 0 # allerede hentet; flyt aldrig cursoren baglæns antal = 0 while fra <= til: haendelser = get("/replikering/haendelser", entitet=entitet, sekvensnummerfra=fra, sekvensnummertil=til, maxantal=MAXANTAL) for h in haendelser: anvend(db, entitet, noegle, h) if haendelser: gem_cursor(db, entitet, haendelser[-1]["sekvensnummer"]) db.commit() # ændringer og cursor i én transaktion antal += len(haendelser) if len(haendelser) < MAXANTAL: break # færre end maxantal: intervallet er hentet fra = haendelser[-1]["sekvensnummer"] + 1 gem_cursor(db, entitet, til) # alt til og med `til` er hentet db.commit() return antal def main() -> None: db = sqlite3.connect("adresser.db") til = get("/replikering/senestesekvensnummer")["sekvensnummer"] for entitet, noegle in ENTITETER.items(): print(f"{entitet}: {synk_entitet(db, entitet, noegle, til)} hændelser (til {til})") if __name__ == "__main__": main()
Output from a run:
postnummer: 2 hændelser (til 207922555) kommune: 0 hændelser (til 207922555) navngivenvej: 1419 hændelser (til 207922555) vejstykke: 1064 hændelser (til 207922555) adgangsadresse: 2282 hændelser (til 207922555) adresse: 428 hændelser (til 207922555)
What makes the script safe to run again:
tilis fixed at the start. All entities are synced to the same sequence number, so the copy is consistent across tables.- The cursor is stored in the same transaction as the changes. If the process dies in the middle of a page, both the changes and the cursor roll back, and the next run starts at the same place.
- Upserts are idempotent. Receive the same event twice and the result is the same. That is why it does no harm that events from before the extract finished are applied again.
- Retry on 429 and 503. If the API answers with
Retry-After, the script waits and retries.
How often should you ask?
Our event log is updated roughly once an hour. Running the script every 15 minutes is plenty. The first call, senestesekvensnummer, is cheap, and if the number has not changed since last time there is nothing to fetch.
# crontab: synkronisér hvert 15. minut, aldrig to kørsler på én gang */15 * * * * cd /srv/adresser && flock -n synk.lock python synk.py >> synk.log 2>&1
flock -n makes sure a slow run does not overlap the next one.
Would you rather be told when there are new events instead of asking? See Webhooks. Webhooks and this script fit together: the webhook tells you there is something new, and the script fetches it. See Webhooks: idempotency and failures.
Errors
| Status | code | Cause |
|---|---|---|
| 400 | invalid_param | sekvensnummerfra, sekvensnummertil or maxantal is not an integer |
| 404 | unknown_entity | The entity does not exist. details.supported lists the valid ones |
| 401 | missing_api_key | The key is missing |
| 429 | rate_limit_per_minute | Wait Retry-After seconds |
Errors have DAWA's shape {"type", "title", "details", "code"}; branch on code. Replication responses currently have no X-RateLimit-* headers. Follow your usage at /dashboard/usage.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)