# Replikering, del 2: hold kopien opdateret med hændelser

> Hent ændringer med sekvensnummerfra og maxantal, anvend insert, update og delete som idempotente upserts, og gem cursoren sikkert.
>
> https://danadresse.dk/guides/replikering-haendelser · verified 2026-11-02

Når du har hentet et fuldt udtræk (se [del 1](/guides/replikering-foerste-udtraek)), holder du kopien opdateret ved at hente hændelser: én række pr. ændring, nummereret med et stigende sekvensnummer. Du gemmer det sidste sekvensnummer, du har behandlet, og spørger næste gang efter alt, hvad der er kommet siden.

## Sekvensnumre

Sekvensnummeret er globalt og stiger altid. Det er fælles for alle entiteter, så for én entitet er der huller i rækken. Det er normalt. Nummereringen fortsætter fra DAWA's. Vi leverer hændelserne selv; intet sendes videre til DAWA, som lukkede 1. oktober 2026. Vores hændelseslog går kun tilbage til sommeren 2026, og siden skiftet til Datafordeleren i oktober 2026 nummererer vi hændelserne selv. En ny kopi starter derfor altid med et [fuldt udtræk](/guides/replikering-foerste-udtraek), og du følger hændelser fra det sekvensnummer, du gemte før udtrækket, + 1. Fortsæt ikke fra en cursor, du har gemt fra DAWA.

`/replikering/senestesekvensnummer` giver det højeste nummer, der er tilgængeligt lige nu (det kræver ingen nøgle, og `/replikering/senesteSekvensnummer` virker også). Hændelser hentes med:

```http id=request run=no
GET /replikering/haendelser?entitet=adresse&sekvensnummerfra=207922000&sekvensnummertil=207940624&maxantal=1000
X-Api-Key: $DANADRESSE_API_KEY
```

| Parameter          | Betydning                                                                                                                                      |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `sekvensnummerfra` | Første sekvensnummer, inklusive. Send din cursor + 1                                                                                           |
| `sekvensnummertil` | Sidste sekvensnummer, inklusive. Valgfri. Send værdien fra `senestesekvensnummer`                                                              |
| `maxantal`         | Valgfri. Højst så mange hændelser i svaret, 1 til 50000. Uden `maxantal` får du hele intervallet fra `sekvensnummerfra` til `sekvensnummertil` |

Svaret er et JSON-array sorteret efter sekvensnummer, ældste først. Uden `maxantal` indeholder det hele intervallet, og det kan blive stort efter en lang pause. Med `maxantal` henter du i sider: har svaret færre rækker end `maxantal`, har du fået det hele. Ellers spørger du igen fra sidste sekvensnummer + 1.

> [!NOTE]
> Begge former fra DAWA virker: `/replikering/haendelser?entitet=adresse` og den gamle `/replikering/adresse/haendelser`. Den gamle form tager også flertalsnavnene, fx `/replikering/adgangsadresser/haendelser`.

## Prøv det

Hent det seneste sekvensnummer og tre `adresse`-hændelser fra lidt før:

```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"])
```

En hændelse ser sådan ud:

```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"
  }
}
```

## Operationer og data

| `operation` | Hvad du gør                                                                   |
| ----------- | ----------------------------------------------------------------------------- |
| `insert`    | Upsert rækken                                                                 |
| `update`    | Upsert rækken. Flet felterne ind i det, du har                                |
| `delete`    | Marker rækken som nedlagt (status 2) eller henlagt (status 4), eller slet den |

Et par ting, der er værd at vide:

- **Insert og update behandles ens.** Et nyt objekt kommer som `insert` og en ændring som `update`. Ældre hændelser i loggen kan have `update` for et objekt, der var nyt, så brug en upsert til begge.
- **`delete` har data med.** Hændelsen indeholder rækkens sidste tilstand, typisk med `status` 2 eller 4. Mange vælger at beholde rækken med den status, så et gammelt id stadig kan slås op.
- **`data` kan være delvis.** En hændelse har ikke altid alle felter fra udtrækket. Flet felterne ind i den eksisterende række i stedet for at erstatte den.
- **Feltnavne.** Hændelser for `adgangsadresse` bruger DAWA's navne med æ og ø (`etrs89koordinat_øst`, `nøjagtighed`, `ændret`), mens udtrækket bruger `etrs89_x`, `noejagtighed` og `aendret`. Ældre `adresse`-hændelser har `dør` og `ændret`. Omdøb dem, når du gemmer.
- **Koordinater.** `adgangsadresse`-hændelser har kun ETRS89/UTM32-koordinater (srid 25832). Har du brug for længde- og breddegrad, så slå adressen op med `/adgangsadresser/{id}`, eller omregn selv. Se [Koordinater og srid](/guides/koordinater-srid).
- **`txid`** kan være `null`: hændelser hentet fra Datafordeleren har intet transaktions-id. Brug `sekvensnummer` som nøgle.
- **`tidspunkt`** er UTC med millisekunder og `Z`.

Løbende hændelser findes for `postnummer`, `navngivenvej`, `vejstykke`, `adgangsadresse` og `adresse`. Andre entiteter, fx `kommune`, `region` og `sogn`, giver en tom liste. De ændrer sig sjældent, så hent dem igen med et nyt udtræk en gang om måneden.

## Synkroniseringsscriptet

Scriptet fortsætter, hvor [førsteudtrækket](/guides/replikering-foerste-udtraek) slap, og bruger samme SQLite-fil og `replikering_cursor`-tabel:

```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 fra en kørsel:

```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)
```

Det, der gør scriptet sikkert at køre igen:

1. **`til` låses ved start.** Alle entiteter synkroniseres til det samme sekvensnummer, så kopien er konsistent på tværs af tabeller.
2. **Cursoren gemmes i samme transaktion som ændringerne.** Går processen ned midt i en side, rulles både ændringer og cursor tilbage, og næste kørsel starter samme sted.
3. **Upserts er idempotente.** Får du den samme hændelse to gange, er resultatet det samme. Derfor gør det ikke noget, at hændelser fra før udtrækket blev færdigt, bliver anvendt igen.
4. **Retry på 429 og 503.** Svarer API'et med `Retry-After`, venter scriptet og prøver igen.

## Hvor ofte skal du spørge?

Vores hændelseslog opdateres omtrent én gang i timen. At køre scriptet hvert 15. minut er rigeligt. Det første kald, `senestesekvensnummer`, er billigt, og er tallet uændret siden sidst, er der ikke noget at hente.

```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` sørger for, at en langsom kørsel ikke overlapper med den næste.

Vil du have besked, når der er nye hændelser, i stedet for at spørge? Se [Webhooks](/guides/webhooks-opsaetning). Webhooks og dette script passer sammen: webhooken fortæller, at der er noget nyt, og scriptet henter det. Se [Webhooks: idempotens og fejl](/guides/webhooks-idempotens-og-fejl).

## Fejl

| Status | `code`                  | Årsag                                                                     |
| ------ | ----------------------- | ------------------------------------------------------------------------- |
| 400    | `invalid_param`         | `sekvensnummerfra`, `sekvensnummertil` eller `maxantal` er ikke et heltal |
| 404    | `unknown_entity`        | Entiteten findes ikke. `details.supported` lister de gyldige              |
| 401    | `missing_api_key`       | Nøglen mangler                                                            |
| 429    | `rate_limit_per_minute` | Vent `Retry-After` sekunder                                               |

Fejl har DAWA's form `{"type", "title", "details", "code"}`; styr efter `code`. Replikeringssvarene har i dag ikke `X-RateLimit-*`-headers. Følg dit forbrug på [/dashboard/usage](/dashboard/usage).
