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.
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.
# 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. For each entity in the inbox it fetches every event from your cursor up to the delivery's sekvensnummer:
# 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?
changesholds at most 1,000 ids.counttells you whether there were more.- For
vejstykke,changesonly holds the municipality code, not the street code, so you cannot tell which street changed. changesdoes 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:
# 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:
# 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:
- Find and fix the fault in your endpoint. Check your logs for 4xx and 5xx and for responses that took more than 6 seconds.
- Delete the old webhook with
DELETE /api/v1/webhooks/{id}. - Create a new one with
POST /api/v1/webhooksand store the new secret. See Webhooks: setup. - Run
synk.pyonce to fetch everything that happened while the webhook was disabled.
Checklist
- The signature is verified on every delivery (Verify the webhook signature).
- 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_activeandfailure_countare monitored daily.- The webhook URL answers directly without a redirect.
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)