# 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.
>
> https://danadresse.dk/en/guides/webhooks-signatur · verified 2026-11-02
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](/en/guides/webhooks-opsaetning).

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](/en/guides/webhooks-idempotens-og-fejl).

## 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 id=selftest-python run=syntax
"""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 id=selftest-output run=no
signatur: 5fddef3badf3736b715b5c7339b82681bae5784da254fc6995d30c4949e4a516
alle tjek bestået
```

And the same in Node:

```js id=selftest-node run=syntax
// 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 id=flask run=syntax
# 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 id=fastapi run=syntax
# 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:

```js id=express run=syntax
// 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 id=php run=syntax
<?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:

```bash id=send-test run=syntax
#!/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 id=send-test-output run=no
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 |
