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.

4 min Level: Advanced ✓ Code tested

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:

  1. 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.
  2. Compare in constant time. Use hmac.compare_digest, crypto.timingSafeEqual or hash_equals. A plain == stops at the first difference and can leak the signature character by character.
  3. Reject with 401. If the header is missing or does not match, answer 401 and 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:

Python#selftest-python
"""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")
Text#selftest-output
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516
alle tjek bestået

And the same in Node:

JavaScript#selftest-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:

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

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

JavaScript#express
// 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#php
<?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:

Shell#send-test
#!/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"
Text#send-test-output
gyldig signatur: HTTP 204
forkert signatur: HTTP 401

Troubleshooting

SymptomCause
The signature never matchesThe body was parsed and serialised again, or you are using the API key instead of the webhook secret
Matches locally but not in productionA proxy or framework middleware changes the body (compression, charset, JSON parser) before your code
timingSafeEqual throwsThe signatures have different lengths. Check the length first, as in the examples
The secret is goneIt 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)

Try it with a free key

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

Create account →