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.

4 min Level: Advanced ✓ Code tested

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

RuleValue
WhenWhen our sync has fetched new changes, roughly once an hour. One delivery per changed entity
Timeout6 seconds for the whole delivery
SuccessHTTP status below 400
FailureStatus 400 or higher, timeout or connection error
RetriesNone. A failed delivery is not sent again
Failure counterfailure_count goes up by 1 per failure and resets on the first success
DisabledAfter 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#receiver
# 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:

Python#worker
# 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:

Shell#cron
# 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#monitor
# 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.
  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).
  • 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.

Related guides

Markdown source Spotted an error? Tell us API-reference (Swagger)

Try it with a free key

2,000 calls/month on the free plan. No credit card.

Create account →