# Webhooks: get notified when addresses change

> Log in for a JWT, create, list and delete webhooks through /api/v1/webhooks, and store the secret that is shown only once.
>
> https://danadresse.dk/en/guides/webhooks-opsaetning · verified 2026-11-02
A webhook sends a signed `POST` to your server when the entities you follow have changed. That saves you from asking for new events all the time. The webhook tells you what changed. You fetch the data itself with the [events API](/en/guides/replikering-haendelser) if you need more than the ids.

Webhooks are managed through the API only. There is no page for them in the dashboard yet, so every step below is an HTTP call.

## 1. Prepare your endpoint

Before you create the webhook, you need a URL that:

- uses `https://`. Other URLs are rejected with `400 url_must_be_https`.
- is reachable from the internet. Deliveries come from our server.
- answers with a 2xx status within 6 seconds. Do the actual work in a queue afterwards.
- answers directly without a redirect. A 3xx status counts as delivered, but the content never reaches the page you redirect to.
- checks the signature. See [Verify the webhook signature](/en/guides/webhooks-signatur).

## 2. Log in and get a JWT

The webhook endpoints use your account login, not your API key. Log in with email and password:

```bash id=login run=no
curl -s https://api.danadresse.dk/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "dig@firma.dk", "password": "din-adgangskode"}'
```

```json id=login-response run=no
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
  "token_type": "Bearer",
  "user": {"id": "…", "email": "dig@firma.dk", "plan": "free", "…": "…"}
}
```

The token is valid for 24 hours. If you created the account with Google, it has no password. Set one through [Forgot password](/dashboard/forgot) first. A wrong login gives `401 invalid_credentials`, and too many attempts give `429 too_many_attempts`.

## 3. Create the webhook

Send the URL and the entities you want to follow. Valid entities are `postnummer`, `kommune`, `navngivenvej`, `vejstykke`, `adgangsadresse` and `adresse`. `kommune` can be selected, but there are currently no ongoing changes for municipalities:

```bash id=create run=no
curl -s https://api.danadresse.dk/api/v1/webhooks \
  -H "Authorization: Bearer $DANADRESSE_JWT" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://app.firma.dk/hooks/danadresse", "entities": ["adresse", "adgangsadresse"]}'
```

```json id=create-response run=no
{
  "id": "6f1c2a9e-1d2b-4c51-9a57-2f4f0d3e8b11",
  "url": "https://app.firma.dk/hooks/danadresse",
  "entities": ["adresse", "adgangsadresse"],
  "is_active": true,
  "failure_count": 0,
  "created_at": "2026-10-15T12:20:04",
  "last_delivery_at": null,
  "secret": "9c4e…64 hex-tegn…",
  "warning": "Save the secret now — it is shown only once."
}
```

`secret` is only shown in this response. Store it in your secrets manager or in an environment variable on the server that receives the webhook. If you lose it, delete the webhook and create a new one.

An empty list or an unknown entity gives `400 invalid_entities`, and `details.invalid` shows which names were wrong.

## 4. List and delete

```bash id=list-delete run=no
# Vis dine webhooks (uden hemmeligheden)
curl -s https://api.danadresse.dk/api/v1/webhooks \
  -H "Authorization: Bearer $DANADRESSE_JWT"

# Slet en
curl -s -X DELETE https://api.danadresse.dk/api/v1/webhooks/6f1c2a9e-1d2b-4c51-9a57-2f4f0d3e8b11 \
  -H "Authorization: Bearer $DANADRESSE_JWT"
```

The list shows `is_active`, `failure_count` and `last_delivery_at` for each webhook. Keep an eye on them, see [Webhooks: idempotency and failures](/en/guides/webhooks-idempotens-og-fejl).

There is no endpoint to change a webhook. To change the URL or entities, or to re-enable a webhook that was switched off after too many failures, delete it and create a new one. The new one gets a new secret.

## 5. Do it from a script

The same in Python, so you can run it from your deploy pipeline. Email and password are read from environment variables:

```python id=admin-script run=syntax
"""webhook_admin.py: opret, vis og slet webhooks via /api/v1/webhooks.

    DANADRESSE_EMAIL=… DANADRESSE_PASSWORD=… python webhook_admin.py list
    … python webhook_admin.py create https://app.example.dk/hooks/danadresse adresse adgangsadresse
    … python webhook_admin.py delete <id>
"""
import json
import os
import sys

import requests

BASE_URL = os.environ.get("DANADRESSE_BASE_URL", "https://api.danadresse.dk")


def login() -> str:
    r = requests.post(f"{BASE_URL}/api/v1/auth/login", timeout=10, json={
        "email": os.environ["DANADRESSE_EMAIL"],
        "password": os.environ["DANADRESSE_PASSWORD"],
    })
    if not r.ok:
        raise SystemExit(f"login fejlede: {r.status_code} {r.json().get('error', {}).get('code')}")
    return r.json()["access_token"]  # JWT, gyldig i 24 timer


def main(argv: list[str]) -> None:
    if not argv or argv[0] not in ("list", "create", "delete"):
        raise SystemExit(__doc__)
    auth = {"Authorization": f"Bearer {login()}"}

    if argv[0] == "list":
        r = requests.get(f"{BASE_URL}/api/v1/webhooks", headers=auth, timeout=10)
    elif argv[0] == "create":
        url, entities = argv[1], argv[2:]
        r = requests.post(f"{BASE_URL}/api/v1/webhooks", headers=auth, timeout=10,
                          json={"url": url, "entities": entities})
    else:
        r = requests.delete(f"{BASE_URL}/api/v1/webhooks/{argv[1]}", headers=auth, timeout=10)

    body = r.json()
    if not r.ok:
        err = body.get("error") or {}
        raise SystemExit(f"{r.status_code} {err.get('code')}: {err.get('message')}")
    if argv[0] == "create":
        # Hemmeligheden vises kun nu. Gem den, fx i din secrets manager.
        print("GEM DENNE HEMMELIGHED:", body["secret"], file=sys.stderr)
        body.pop("secret")
    print(json.dumps(body, indent=2, ensure_ascii=False))


if __name__ == "__main__":
    main(sys.argv[1:])
```

## 6. What you receive

When an entity you follow has changed, you get one `POST` per entity:

```http id=delivery run=no
POST /hooks/danadresse HTTP/1.1
Host: app.firma.dk
Content-Type: application/json
User-Agent: danadresse-webhooks/1.0
X-Danadresse-Entity: adresse
X-Danadresse-Signature: 5d1f0c…(hex HMAC-SHA256 af hele body)

{"entity": "adresse", "sekvensnummer": 207922555, "count": 37, "changes": ["0a3f50ae-b4ee-32b8-e044-0003ba298018", "…"], "timestamp": "2026-10-15T13:08:11.512345+00:00"}
```

| Field | Content |
|---|---|
| `entity` | The entity that changed |
| `sekvensnummer` | The highest sequence number in this round. Use it as a cursor |
| `count` | Number of changes in this round |
| `changes` | The ids of the changed rows, at most 1,000. If `count` is larger, fetch the rest with events. For `vejstykke` it only holds the municipality code, so use events to find the street |
| `timestamp` | When the delivery was sent (UTC) |

Deliveries are sent when our sync has fetched new changes. That happens roughly once an hour. There is no button to send a test delivery. Instead, test your endpoint by signing a sample body yourself, as shown in [Verify the webhook signature](/en/guides/webhooks-signatur).

## 7. What to do with a delivery

The events behind a delivery are already in the event log when it is sent. So the usual reaction is: verify the signature, store the delivery, answer `204`, and then fetch the events for that entity with `/replikering/haendelser?entitet=…` from your own cursor + 1 up to the delivery's `sekvensnummer`. The full flow is: [full extract](/en/guides/replikering-foerste-udtraek) once, then [events](/en/guides/replikering-haendelser) from your cursor, started by the webhook and by a cron job as a safety net. The receiver and worker are in [Webhooks: idempotency and failures](/en/guides/webhooks-idempotens-og-fejl).
