# 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.
>
> https://danadresse.dk/en/guides/replikering-haendelser · verified 2026-11-02
Once you have loaded a full extract (see [part 1](/en/guides/replikering-foerste-udtraek)), 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](/en/guides/replikering-foerste-udtraek), 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:

```http id=request run=no
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.

> [!NOTE]
> Both of DAWA's forms work: `/replikering/haendelser?entitet=adresse` and the old `/replikering/adresse/haendelser`. The old form also accepts the plural names, such as `/replikering/adgangsadresser/haendelser`.

## Try it

Fetch the latest sequence number and three `adresse` events from a little earlier:

```python id=haendelser-peek run=live expect="seneste sekvensnummer"
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:

```json id=event run=no
{
  "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 `insert` and a change as `update`. Older events in the log can carry `update` for an object that was new, so use an upsert for both.
- **`delete` carries data.** The event holds the row's last state, typically with `status` 2 or 4. Many keep the row with that status so an old id can still be looked up.
- **`data` can 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 `adgangsadresse` use DAWA's names with æ and ø (`etrs89koordinat_øst`, `nøjagtighed`, `ændret`), while the extract uses `etrs89_x`, `noejagtighed` and `aendret`. Older `adresse` events have `dør` and `ændret`. Rename them when you store.
- **Coordinates.** `adgangsadresse` events 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](/en/guides/koordinater-srid).
- **`txid`** can be `null`: events fetched from Datafordeleren have no transaction id. Use `sekvensnummer` as the key.
- **`tidspunkt`** is UTC with milliseconds and `Z`.

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](/en/guides/replikering-foerste-udtraek) left off and uses the same SQLite file and `replikering_cursor` table:

```python id=synk run=syntax
"""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:

```text id=synk-output run=no
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:

1. **`til` is fixed at the start.** All entities are synced to the same sequence number, so the copy is consistent across tables.
2. **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.
3. **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.
4. **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.

```bash id=cron run=no
# 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](/en/guides/webhooks-opsaetning). Webhooks and this script fit together: the webhook tells you there is something new, and the script fetches it. See [Webhooks: idempotency and failures](/en/guides/webhooks-idempotens-og-fejl).

## 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](/dashboard/usage).
