# Webhooks: idempotency, failures and catching up

> Answer fast, deduplicate on entity and sequence number, recover missed changes with events, and monitor failure_count before the webhook is disabled.
>
> https://danadresse.dk/en/guides/webhooks-idempotens-og-fejl · verified 2026-11-02
Webhooks are convenient, but a delivery can get lost, and it is not sent again. This guide shows how to build a receiver that copes with that: it answers fast, processes the same delivery once, and fetches everything it may have missed from the events API.

## How we deliver

| Rule | Value |
|---|---|
| When | When our sync has fetched new changes, roughly once an hour. One delivery per changed entity |
| Timeout | 6 seconds for the whole delivery |
| Success | HTTP status below 400 |
| Failure | Status 400 or higher, timeout or connection error |
| Retries | None. A failed delivery is not sent again |
| Failure counter | `failure_count` goes up by 1 per failure and resets on the first success |
| Disabled | After 15 consecutive failures `is_active` is set to `false`, and no more deliveries are sent |

Two things follow from the table:

- **The webhook is a signal, not the data.** You cannot count on getting every delivery. Use it to know there is something new, and fetch the changes with `/replikering/haendelser?entitet=…` from your own cursor. That way you also get whatever a lost delivery should have told you.
- **A redirect counts as success.** A 301 or 302 is below 400, so it resets `failure_count`, but the content never arrives. Point the webhook straight at the final URL.

No email is sent when a webhook is disabled. That is why you need to monitor it yourself, see further down.

## 1. Answer fast, work afterwards

Six seconds is enough to verify the signature and store the delivery, but not necessarily to fetch events and update your database. Split it in two: a receiver that stores and answers `204`, and a worker that does the work.

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

The primary key `(entity, sekvensnummer)` makes the receiver idempotent. The same delivery twice, for example because a proxy repeated it, ends up as one row.

## 2. The worker fetches from your cursor

The worker uses `synk.py` from [Replication, part 2](/en/guides/replikering-haendelser). For each entity in the inbox it fetches every event from your cursor up to the delivery's `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}")
```

Why is `changes` not enough?

- `changes` holds at most 1,000 ids. `count` tells you whether there were more.
- For `vejstykke`, `changes` only holds the municipality code, not the street code, so you cannot tell which street changed.
- `changes` does not say what changed or whether the row was retired. The events do.
- The events from your cursor also cover the deliveries you never got.

Because `synk_entitet` never moves the cursor backwards and applies events as upserts, it is harmless to run the worker several times or to process deliveries out of order.

## 3. Keep a safety net

Even with webhooks you should run the normal sync from cron, for example once an hour or once a day. It catches changes during periods when your server was down, and changes for a webhook that has been disabled:

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

The worker only makes API calls when there is something in the inbox. The sync makes one call to `senestesekvensnummer` plus at least one call per entity.

## 4. Monitor the webhook

`GET /api/v1/webhooks` shows `is_active`, `failure_count` and `last_delivery_at` for each webhook. Check them daily:

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

Set `STILLE_TIMER` (quiet hours) to suit the entities you follow. Addresses change every day, but a postcode can go months without changes, so a webhook on `postnummer` alone will often be quiet for a long time.

## 5. When the webhook has been disabled

There is no endpoint to re-enable a webhook. Do this:

1. Find and fix the fault in your endpoint. Check your logs for 4xx and 5xx and for responses that took more than 6 seconds.
2. Delete the old webhook with `DELETE /api/v1/webhooks/{id}`.
3. Create a new one with `POST /api/v1/webhooks` and store the new secret. See [Webhooks: setup](/en/guides/webhooks-opsaetning).
4. Run `synk.py` once to fetch everything that happened while the webhook was disabled.

## Checklist

- The signature is verified on every delivery ([Verify the webhook signature](/en/guides/webhooks-signatur)).
- The receiver answers 2xx in under a second and stores the delivery.
- Deliveries are deduplicated on `(entity, sekvensnummer)`.
- The worker fetches events from your cursor, not just the ids in `changes`.
- The sync also runs from cron as a safety net.
- `is_active` and `failure_count` are monitored daily.
- The webhook URL answers directly without a redirect.
