Verify the webhook signature in Python, Node and PHP
Check X-Danadresse-Signature with HMAC-SHA256 over the raw body and a constant-time comparison, and test your code with a self-signed delivery.
Your webhook URL is public, so anyone can send a POST to it. The signature proves that the delivery comes from Danadresse and was not changed on the way. Check it on every delivery before you do anything with the content.
How the signature is made
Every delivery has the header X-Danadresse-Signature. The value is HMAC-SHA256 of the whole request body with the webhook's secret as the key, written as lowercase hex (64 characters). You got the secret when you created the webhook, see Webhooks: setup.
Three things must be right:
- Use the raw bytes. Compute the HMAC over the body exactly as it arrived over the wire. If you parse the JSON first and serialise it again, whitespace and order change and the signature no longer matches.
- Compare in constant time. Use
hmac.compare_digest,crypto.timingSafeEqualorhash_equals. A plain==stops at the first difference and can leak the signature character by character. - Reject with 401. If the header is missing or does not match, answer
401and do nothing else.
The signature does not cover a separate timestamp header. The timestamp field sits in the body and is therefore covered by the signature. If you want to reject old deliveries replayed by a third party, check timestamp and reject those older than, say, one hour. Also store (entity, sekvensnummer) and skip it if you have seen it before. See Webhooks: idempotency and failures.
Test your verification without a real delivery
There is no button to send a test delivery. It is not needed either: you can sign a sample body yourself with the same algorithm. Here is a self-test in Python that needs no network:
"""selvtest.py: signér en eksempel-body og tjek, at verify() accepterer den og afviser ændringer.""" import hashlib import hmac import json def sign(secret: str, raw_body: bytes) -> str: """Præcis som Danadresse: hex HMAC-SHA256 af de rå bytes.""" return hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() def verify(secret: str, raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = sign(secret, raw_body) return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) secret = "9c4e" * 16 # 64 hex-tegn, samme form som den rigtige hemmelighed body = json.dumps({ "entity": "adresse", "sekvensnummer": 207922555, "count": 1, "changes": ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], "timestamp": "2026-10-15T13:08:11.512345+00:00", }).encode() sig = sign(secret, body) print("signatur:", sig) assert verify(secret, body, sig), "gyldig signatur blev afvist" assert not verify(secret, body.replace(b"207922555", b"207922556"), sig), "ændret body blev accepteret" assert not verify("forkert" + secret[7:], body, sig), "forkert hemmelighed blev accepteret" assert not verify(secret, body, None), "manglende header blev accepteret" # Samme data, men JSON'en er formateret om: signaturen passer ikke længere. # Derfor skal du verificere de rå bytes, ikke en re-serialiseret dict. assert not verify(secret, json.dumps(json.loads(body), indent=2).encode(), sig) print("alle tjek bestået")
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516 alle tjek bestået
And the same in Node:
// selvtest.mjs: signér en eksempel-body og tjek verify() (Node 18+) import assert from "node:assert/strict"; import crypto from "node:crypto"; export function sign(secret, rawBody) { return crypto.createHmac("sha256", secret).update(rawBody).digest("hex"); } export function verify(secret, rawBody, signature) { if (typeof signature !== "string") return false; const expected = Buffer.from(sign(secret, rawBody), "utf8"); const given = Buffer.from(signature.trim().toLowerCase(), "utf8"); // timingSafeEqual kaster en fejl ved forskellig længde, så tjek længden først return expected.length === given.length && crypto.timingSafeEqual(expected, given); } const secret = "9c4e".repeat(16); const body = Buffer.from(JSON.stringify({ entity: "adresse", sekvensnummer: 207922555, count: 1, changes: ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], timestamp: "2026-10-15T13:08:11.512345+00:00", })); // JSON.stringify skriver uden mellemrum, så signaturen bliver en anden end i // Python-eksemplet. Det er pointen: signaturen gælder de præcise bytes. const sig = sign(secret, body); console.log("signatur:", sig); assert.ok(verify(secret, body, sig)); assert.ok(!verify(secret, Buffer.from(body.toString().replace("207922555", "207922556")), sig)); assert.ok(!verify("forkert", body, sig)); assert.ok(!verify(secret, body, undefined)); assert.ok(!verify(secret, body, "abc")); console.log("alle tjek bestået");
Python: Flask
request.get_data() gives you the raw bytes. Read them before you use request.json:
# app.py (Flask) import hashlib import hmac import json import os from flask import Flask, abort, request app = Flask(__name__) SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"] def verify(raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) @app.post("/hooks/danadresse") def danadresse_hook(): raw = request.get_data() # de rå bytes, før Flask parser JSON if not verify(raw, request.headers.get("X-Danadresse-Signature")): abort(401) payload = json.loads(raw) # Læg arbejdet i en kø og svar hurtigt (leveringen afbrydes efter 6 sekunder) enqueue(payload["entity"], payload["sekvensnummer"], payload["changes"]) return "", 204 def enqueue(entity: str, sekvensnummer: int, changes: list[str]) -> None: ... # fx Celery, RQ eller en tabel, som et baggrundsjob læser
Python: FastAPI
Read await request.body() instead of declaring a Pydantic model as a parameter. The model would only give you the parsed data:
# main.py (FastAPI) import hashlib import hmac import json import os from fastapi import BackgroundTasks, FastAPI, Header, HTTPException, Request, Response app = FastAPI() SECRET = os.environ["DANADRESSE_WEBHOOK_SECRET"] def verify(raw_body: bytes, signature: str | None) -> bool: if not signature: return False expected = hmac.new(SECRET.encode(), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode(), signature.strip().lower().encode()) @app.post("/hooks/danadresse", status_code=204) async def danadresse_hook( request: Request, background: BackgroundTasks, x_danadresse_signature: str | None = Header(default=None), ) -> Response: raw = await request.body() # læs de rå bytes, ikke en Pydantic-model if not verify(raw, x_danadresse_signature): raise HTTPException(status_code=401, detail="bad signature") payload = json.loads(raw) background.add_task(behandl, payload) # kører efter svaret er sendt return Response(status_code=204) def behandl(payload: dict) -> None: ... # hent ændringerne med /replikering/haendelser?entitet={entity} fra din cursor
Node: Express
The most common mistake in Express is that express.json() has already parsed the body. Use express.raw() on the webhook route:
// server.mjs (Express) import crypto from "node:crypto"; import express from "express"; const SECRET = process.env.DANADRESSE_WEBHOOK_SECRET; const app = express(); function verify(rawBody, signature) { if (typeof signature !== "string") return false; const expected = Buffer.from(crypto.createHmac("sha256", SECRET).update(rawBody).digest("hex"), "utf8"); const given = Buffer.from(signature.trim().toLowerCase(), "utf8"); return expected.length === given.length && crypto.timingSafeEqual(expected, given); } // express.raw giver req.body som en Buffer med de præcise bytes. // Brug den KUN på denne rute: express.json() ville parse body'en først, // og så kan signaturen ikke længere verificeres. app.post("/hooks/danadresse", express.raw({ type: "application/json", limit: "1mb" }), (req, res) => { if (!verify(req.body, req.get("X-Danadresse-Signature"))) { return res.sendStatus(401); } const payload = JSON.parse(req.body.toString("utf8")); res.sendStatus(204); // svar først ... setImmediate(() => enqueue(payload)); // ... og arbejd bagefter }); function enqueue(payload) { // fx BullMQ, en databasetabel eller en anden kø console.log("ændret:", payload.entity, payload.sekvensnummer, payload.count); } app.use(express.json()); // dine andre ruter app.listen(3000);
PHP
php://input gives you the raw bytes, and hash_equals compares in constant time:
<?php // hooks/danadresse.php (ren PHP; i Laravel: $request->getContent() og $request->header(...)) declare(strict_types=1); $secret = getenv('DANADRESSE_WEBHOOK_SECRET'); if (!is_string($secret) || $secret === '') { http_response_code(500); // konfigurationsfejl hos dig exit; } $raw = file_get_contents('php://input'); // de rå bytes $signature = $_SERVER['HTTP_X_DANADRESSE_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $raw, $secret); // hex, små bogstaver if (!hash_equals($expected, strtolower(trim($signature)))) { http_response_code(401); exit; } $payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); // Gem leveringen og svar med det samme; et cron-job eller en kø gør resten $pdo = new PDO(getenv('DATABASE_DSN')); $stmt = $pdo->prepare('INSERT INTO webhook_inbox (entity, sekvensnummer, body) VALUES (?, ?, ?)'); $stmt->execute([$payload['entity'], $payload['sekvensnummer'], $raw]); http_response_code(204);
Send a signed test delivery to your endpoint
Once your handler runs locally, you can send a signed delivery with openssl and curl. You should get 2xx for the valid one and 401 for the wrong one:
#!/usr/bin/env bash # send-test.sh: send en signeret test-levering til dit eget endpoint set -euo pipefail URL="${1:-http://localhost:3000/hooks/danadresse}" : "${DANADRESSE_WEBHOOK_SECRET:?Sæt DANADRESSE_WEBHOOK_SECRET}" BODY='{"entity": "adresse", "sekvensnummer": 207922555, "count": 1, "changes": ["0a3f50ae-b4ee-32b8-e044-0003ba298018"], "timestamp": "2026-10-15T13:08:11.512345+00:00"}' SIG="$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$DANADRESSE_WEBHOOK_SECRET" -hex | sed 's/^.*= //')" curl -sS -o /dev/null -w "gyldig signatur: HTTP %{http_code}\n" "$URL" \ -H "Content-Type: application/json" \ -H "X-Danadresse-Entity: adresse" \ -H "X-Danadresse-Signature: $SIG" \ --data-binary "$BODY" curl -sS -o /dev/null -w "forkert signatur: HTTP %{http_code}\n" "$URL" \ -H "Content-Type: application/json" \ -H "X-Danadresse-Signature: 0000" \ --data-binary "$BODY"
gyldig signatur: HTTP 204 forkert signatur: HTTP 401
Troubleshooting
| Symptom | Cause |
|---|---|
| The signature never matches | The body was parsed and serialised again, or you are using the API key instead of the webhook secret |
| Matches locally but not in production | A proxy or framework middleware changes the body (compression, charset, JSON parser) before your code |
timingSafeEqual throws | The signatures have different lengths. Check the length first, as in the examples |
| The secret is gone | It is only shown at creation. Delete the webhook and create a new one |
Related guides
Markdown source Spotted an error? Tell us API-reference (Swagger)