# Webhooks: idempotens, fejl og indhentning

> Svar hurtigt, dedupliker på entity og sekvensnummer, hent det tabte med hændelser, og overvåg failure_count, før webhooken slås fra.
>
> https://danadresse.dk/guides/webhooks-idempotens-og-fejl · verified 2026-11-02
Webhooks er praktiske, men en levering kan gå tabt, og den bliver ikke sendt igen. Denne guide viser, hvordan du bygger en modtager, der tåler det: den svarer hurtigt, behandler den samme levering én gang, og henter alt, hvad den måske har misset, fra hændelses-API'et.

## Sådan leverer vi

| Regel | Værdi |
|---|---|
| Hvornår | Når vores synkronisering har hentet nye ændringer, omtrent én gang i timen. Én levering pr. ændret entitet |
| Timeout | 6 sekunder for hele leveringen |
| Succes | HTTP-status under 400 |
| Fejl | Status 400 eller højere, timeout eller forbindelsesfejl |
| Nye forsøg | Ingen. En fejlet levering sendes ikke igen |
| Fejltæller | `failure_count` stiger med 1 pr. fejl og nulstilles ved første succes |
| Slås fra | Efter 15 fejl i træk sættes `is_active` til `false`, og der sendes ikke flere leveringer |

To ting følger af tabellen:

- **Webhooken er et signal, ikke selve data.** Du kan ikke regne med at få hver levering. Brug den til at vide, at der er noget nyt, og hent ændringerne med `/replikering/haendelser?entitet=…` fra din egen cursor. Så får du også det med, som en tabt levering skulle have fortalt.
- **En redirect tæller som succes.** En 301 eller 302 er under 400, så den nulstiller `failure_count`, men indholdet når aldrig frem. Peg webhooken direkte på den endelige URL.

Der sendes ingen mail, når en webhook slås fra. Det er derfor, du skal overvåge den selv, se længere nede.

## 1. Svar hurtigt, arbejd bagefter

Seks sekunder er nok til at verificere signaturen og gemme leveringen, men ikke nødvendigvis til at hente hændelser og opdatere din database. Del det i to: en modtager, der gemmer og svarer `204`, og en worker, der gør arbejdet.

```python id=receiver run=syntax
# modtager.py (FastAPI): verificér, dedupliker, gem i en indbakke, svar 204
import hashlib
import hmac
import json
import os
import sqlite3

from fastapi import FastAPI, Header, HTTPException, Request, Response

app = FastAPI()
SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"]

db = sqlite3.connect("adresser.db", check_same_thread=False)
db.execute("""CREATE TABLE IF NOT EXISTS webhook_inbox (
    entity        TEXT    NOT NULL,
    sekvensnummer INTEGER NOT NULL,
    body          TEXT    NOT NULL,
    modtaget      TEXT    NOT NULL DEFAULT (datetime('now')),
    behandlet     INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (entity, sekvensnummer)
)""")
db.commit()


@app.post("/hooks/danadresse", status_code=204)
async def hook(request: Request, x_danadresse_signature: str | None = Header(default=None)) -> Response:
    raw = await request.body()
    expected = hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
    if not x_danadresse_signature or not hmac.compare_digest(
            expected.encode(), x_danadresse_signature.strip().lower().encode()):
        raise HTTPException(status_code=401)

    p = json.loads(raw)
    # Primærnøglen (entity, sekvensnummer) gør det idempotent: en gentaget
    # levering rammer INSERT OR IGNORE og ændrer intet.
    db.execute("INSERT OR IGNORE INTO webhook_inbox (entity, sekvensnummer, body) VALUES (?, ?, ?)",
               (p["entity"], p["sekvensnummer"], raw.decode()))
    db.commit()
    return Response(status_code=204)  # hurtigt svar: arbejdet sker i worker.py
```

Primærnøglen `(entity, sekvensnummer)` gør modtageren idempotent. Den samme levering to gange, fx fordi en proxy gentog den, ender som én række.

## 2. Workeren henter fra din cursor

Workeren bruger `synk.py` fra [Replikering, del 2](/guides/replikering-haendelser). For hver entitet i indbakken henter den alle hændelser fra din cursor op til leveringens `sekvensnummer`:

```python id=worker run=syntax
# worker.py: behandl indbakken ved at hente hændelser fra din egen cursor.
# Genbruger synk.py fra guiden "Replikering, del 2". Kør fx hvert minut fra cron.
import sqlite3

from synk import ENTITETER, synk_entitet

db = sqlite3.connect("adresser.db")

ventende = db.execute(
    "SELECT entity, max(sekvensnummer) FROM webhook_inbox WHERE behandlet = 0 GROUP BY entity"
).fetchall()

for entity, hoejeste in ventende:
    if entity not in ENTITETER:
        continue
    # Hent ALT fra din cursor op til leveringens sekvensnummer, ikke kun de id'er,
    # der stod i `changes`. Så fanger du også det, en tabt levering skulle have sagt.
    antal = synk_entitet(db, entity, ENTITETER[entity], hoejeste)
    db.execute("UPDATE webhook_inbox SET behandlet = 1 WHERE entity = ? AND sekvensnummer <= ?",
               (entity, hoejeste))
    db.commit()
    print(f"{entity}: {antal} hændelser til og med {hoejeste}")
```

Hvorfor er `changes` ikke nok?

- `changes` har højst 1.000 id'er. `count` fortæller, om der var flere.
- For `vejstykke` indeholder `changes` kun kommunekoden, ikke vejkoden, så du kan ikke se, hvilken vej der er ændret.
- `changes` siger ikke, hvad der er ændret, eller om rækken er nedlagt. Det står i hændelserne.
- Hændelserne fra din cursor dækker også de leveringer, du aldrig fik.

Fordi `synk_entitet` aldrig flytter cursoren baglæns og anvender hændelser som upserts, er det ufarligt at køre workeren flere gange eller at behandle leveringer i forkert rækkefølge.

## 3. Hav et sikkerhedsnet

Selv med webhooks bør du køre den almindelige synkronisering fra cron, fx én gang i timen eller én gang i døgnet. Den fanger ændringer i perioder, hvor din server var nede, og ændringer for en webhook, der er slået fra:

```bash id=cron run=no
# hvert minut: behandl indbakken (ingen API-kald, når den er tom)
* * * * *  cd /srv/adresser && flock -n worker.lock python worker.py >> worker.log 2>&1
# hver time: fuld indhentning som sikkerhedsnet
7 * * * *  cd /srv/adresser && flock -n synk.lock python synk.py >> synk.log 2>&1
```

Workeren bruger kun API-kald, når der ligger noget i indbakken. Synkroniseringen bruger ét kald til `senestesekvensnummer` plus mindst ét kald pr. entitet.

## 4. Overvåg webhooken

`GET /api/v1/webhooks` viser `is_active`, `failure_count` og `last_delivery_at` for hver webhook. Tjek dem dagligt:

```python id=monitor run=syntax
# overvaag_webhooks.py: kør dagligt; exit 1 (og en linje pr. problem), hvis noget er galt
import datetime as dt
import os
import sys

import requests

BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk")
STILLE_TIMER = 48  # alarmér, hvis en aktiv webhook ikke har fået noget i så lang tid

token = requests.post(f"{BASE_URL}/api/v1/auth/login", timeout=10, json={
    "email": os.environ["DANADRESSE_EMAIL"],
    "password": os.environ["DANADRESSE_PASSWORD"],
}).json()["access_token"]

r = requests.get(f"{BASE_URL}/api/v1/webhooks", headers={"Authorization": f"Bearer {token}"}, timeout=10)
r.raise_for_status()

problemer = []
nu = dt.datetime.now(dt.timezone.utc)
for w in r.json()["webhooks"]:
    navn = f"{w['url']} ({','.join(w['entities'])})"
    if not w["is_active"]:
        problemer.append(f"SLÅET FRA efter {w['failure_count']} fejl i træk: {navn}. Slet og opret igen.")
    elif w["failure_count"] > 0:
        problemer.append(f"{w['failure_count']} fejl i træk: {navn}")
    if w["is_active"] and w["last_delivery_at"]:
        sidst = dt.datetime.fromisoformat(w["last_delivery_at"])
        if sidst.tzinfo is None:
            sidst = sidst.replace(tzinfo=dt.timezone.utc)
        if nu - sidst > dt.timedelta(hours=STILLE_TIMER):
            problemer.append(f"ingen levering siden {sidst:%Y-%m-%d %H:%M} UTC: {navn}")

for p in problemer:
    print(p)
sys.exit(1 if problemer else 0)
```

Sæt `STILLE_TIMER` efter de entiteter, du følger. Adresser ændrer sig hver dag, men et postnummer kan gå måneder uden ændringer, så en webhook kun på `postnummer` vil ofte være stille længe.

## 5. Når webhooken er slået fra

Der er ikke et endpoint til at genaktivere en webhook. Gør sådan:

1. Find og ret fejlen i dit endpoint. Tjek dine logs for 4xx og 5xx og for svar, der tog mere end 6 sekunder.
2. Slet den gamle webhook med `DELETE /api/v1/webhooks/{id}`.
3. Opret en ny med `POST /api/v1/webhooks` og gem den nye hemmelighed. Se [Webhooks: opsætning](/guides/webhooks-opsaetning).
4. Kør `synk.py` én gang for at hente alt, hvad der skete, mens webhooken var slået fra.

## Tjekliste

- Signaturen verificeres på hver levering ([Verificér webhook-signaturen](/guides/webhooks-signatur)).
- Modtageren svarer 2xx på under et sekund og gemmer leveringen.
- Leveringer deduplikeres på `(entity, sekvensnummer)`.
- Workeren henter hændelser fra din cursor, ikke kun id'erne i `changes`.
- Synkroniseringen kører også fra cron som sikkerhedsnet.
- `is_active` og `failure_count` overvåges dagligt.
- Webhook-URL'en svarer direkte uden redirect.
