# Webhooks

Registrér et HTTPS-endpoint, og Grundfast sender en signeret JSON-besked, når der sker noget: en nøgle oprettes eller tilbagekaldes, planen ændres, forbruget passerer en grænse, en overvåget ejendom ændrer sig, adresser eller bygninger i dit område ændrer sig, eller et job bliver færdigt. Gik en levering tabt, kan du hente den eller sende den igen.

> **Kræver Pro** Webhooks kræver Pro-planen eller højere. Oprettelse, ændring og test-ping svarer `402` på en lavere plan, og der leveres ingen hændelser. Oversigt, leveringslog og sletning virker stadig, så du kan rydde op efter en nedgradering. Se [/priser](https://grundfast.dk/priser).

## Registrér et endpoint

Webhooks oprettes, rettes, testes og slettes med din dashboard-session — i dashboardet eller på `/v1/me/webhooks` — ikke med en API-nøgle. Opret et endpoint med mindst én hændelse:

```http
POST /v1/me/webhooks
Content-Type: application/json

{
  "url": "https://example.com/hooks/grundfast",
  "events": ["key.created", "usage.threshold"],
  "description": "prod-overvågning"
}

HTTP/1.1 201 Created

{
  "webhook": {
    "id": "wh_...",
    "url": "https://example.com/hooks/grundfast",
    "events": ["key.created", "usage.threshold"],
    "enabled": true,
    "description": "prod-overvågning",
    "secretPrefix": "whsec_a1b2c",
    "secret": "whsec_...",
    "createdAt": "2026-07-03T10:00:00.000Z"
  }
}
```

> Signeringsnøglen (`secret`) returneres **kun** i svaret på oprettelsen. Gem den sikkert; den kan ikke hentes igen. URL’en skal være en offentlig `https`-adresse — `http`, `localhost` og private eller interne adresser afvises. `description` er valgfri, højst 200 tegn.

| Metode | Sti | Hvad |
| --- | --- | --- |
| GET | /v1/me/webhooks | List dine endpoints (uden signeringsnøgle). |
| POST | /v1/me/webhooks | Opret et endpoint. Kræver Pro. |
| PATCH | /v1/me/webhooks/:id | Ret `url`, `events`, `description`, `enabled`, `adresseFilter` eller `cvrFilter`. Kræver Pro. |
| DELETE | /v1/me/webhooks/:id | Slet et endpoint. |
| GET | /v1/me/webhooks/:id/deliveries | De seneste leveringsforsøg. |
| POST | /v1/me/webhooks/:id/test | Send en signeret test-ping — eller et eksempel på én bestemt hændelse — og se resultatet med det samme. Kræver Pro. |
| GET | /v1/me/webhooks/events | Alle hændelser til dine endpoints med leveringsstatus — [hent det, du gik glip af](#hent-missede). |
| POST | /v1/me/webhooks/:id/deliveries/:deliveryId/redeliver | [Send én levering igen](#send-igen). Kræver Pro. |
| POST | /v1/me/webhooks/:id/redeliver-failed | [Send alle fejlede hændelser igen](#send-igen), højst 500 pr. kald. Kræver Pro. |

Oversigten, leveringsloggen, hændelsesfeedet og de to genleveringer kan også kaldes med en API-nøgle, der har scopet `webhooks` — så din modtager selv kan rydde op efter et nedbrud.

## Hændelser

| Hændelse | Hvornår | `data` |
| --- | --- | --- |
| key.created | En API-nøgle blev oprettet. | { key_id, name, permission, prefix, kind } |
| key.revoked | En API-nøgle blev tilbagekaldt (også ved "Tilbagekald alle nøgler", én hændelse pr. nøgle). | { key_id } |
| subscription.updated | Kontoens plan ændrede sig. | { tier } |
| usage.threshold | Månedens forbrug passerede 80 % eller 100 % af kvoten. Hver grænse sendes højst én gang pr. måned, og passeres begge inden for samme time, kommer 80 % først. | { tier, threshold_pct, used, allowance, month } |
| ejendom.aendret | En ejendom på din [overvågningsliste](https://grundfast.dk/docs/ejendomsovervaagning) har ændret sig i ét eller flere registre siden sidste tjek. | { bfe_nummer, note, aendrede_registre, aendringer, kontrolleret } |
| adresse.oprettet | Nye adgangsadresser i dit område, fra den daglige DAR-opdatering. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| adresse.aendret | Adresser i dit område har fået ny betegnelse, vej, husnummer, postnummer, kommune eller placering. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| adresse.nedlagt | Adresser i dit område er nedlagt eller henlagt i DAR. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| cvr.oprettet | Nye virksomheder i dit område, fra den ugentlige CVR-opdatering. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| cvr.aendret | Virksomheder på din liste eller i dit område har fået nyt navn, ny status, virksomhedsform, adresse, kommune eller hovedbranche. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| cvr.ophoert | Virksomheder på din liste eller i dit område er ophørt (status ophørt eller tvangsopløst, eller en ophørsdato er registreret). | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| bygning.tilkommet | Bygninger i dit område er nu registreret som opført — fundet, når en ny udgave af bygningsindekset sammenlignes med den forrige. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| bygning.aendret | Bygninger i dit område har fået ændret en registreret oplysning (anvendelse, år, arealer, etager, varme, tag, ydervæg, asbest, olietank, adresse, BFE eller placering). | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| bygning.udgaaet | Bygninger i dit område er ikke længere registreret som opført — revet ned, eller registreringen er rettet. | { antal, sekvens_fra, sekvens_til, flere, aendringer } |
| job.completed | Et [job](https://grundfast.dk/docs/jobs) er færdigt: alle rækker er behandlet, og mindst én lykkedes. Et job, du selv stopper, sender den ikke. | { job } |
| job.failed | Et job er færdigt, men ingen række kunne besvares (`job.status` er `failed`). Abonnerer et endpoint kun på `job.completed`, får det stadig fejlede job dér, som det altid har. | { job } |

`ejendom.aendret`, `adresse.*`, `bygning.*` og `cvr.*` hører til [ejendomsovervågning](https://grundfast.dk/docs/ejendomsovervaagning) og kræver, ligesom webhooks, Pro-planen. Vil du hellere have adresse- og bygningsændringer skubbet ud over én åben forbindelse end som webhooks, så brug [live-strømmen](https://grundfast.dk/docs/api/aendringer-stream).

## Test et endpoint

`POST /v1/me/webhooks/:id/test` uden body sender en `ping` med `data` `{ message, webhook_id }`. Med `{ "event": "adresse.aendret" }` (eller en anden hændelse) sender den i stedet et eksempel på netop den hændelse, så din modtager prøves på den form, den rigtigt får. Body’en har `"test": true`, og svaret siger, hvor dataene kom fra:

- `log` — den nyeste rigtige ændring af den slags i loggen, inden for endpointets område, hvis der er én dér, ellers den nyeste i landet.
- `konto` — din egen konto: dit seneste job, din seneste nøgle eller din plan.
- `eksempel` — en fast eksempelværdi, når der ikke findes noget rigtigt endnu.

```http
POST /v1/me/webhooks/wh_.../test
Content-Type: application/json

{ "event": "job.failed" }

HTTP/1.1 200 OK

{
  "ok": true,
  "status": 200,
  "error": null,
  "event": "job.failed",
  "kilde": "konto",
  "payload": { "id": "whd_...", "event": "job.failed", "created": "…", "test": true, "data": { "job": { … } } }
}
```

En test logges ikke som en levering og kan ikke sendes igen.

## Besked og headere

Body’en er altid `{ id, event, created, data }`, hvor `created` er ISO 8601 i UTC. Hændelsesnavn og leverings-id sendes også som headere, så du kan route og fjerne dubletter uden at parse body’en:

```http
POST https://example.com/hooks/grundfast
Content-Type: application/json
User-Agent: Grundfast-Webhooks/1.0
X-Grundfast-Event: key.created
X-Grundfast-Delivery: whd_...
X-Grundfast-Signature: sha256=<hex>

{
  "id": "whd_...",
  "event": "key.created",
  "created": "2026-07-03T10:00:00.000Z",
  "data": { "key_id": 42, "name": "prod", "permission": "read_only", "prefix": "gf_live_...", "kind": "secret" }
}
```

## Adresse- og bygningshændelser i bundter

Et endpoint, der abonnerer på en `adresse.*`- eller `bygning.*`-hændelse, skal have et **område** i `adresseFilter`: kommunekoder, postnumre, konkrete adgangsadresse-id’er og/eller BFE-numre. En ændring leveres, når den ligger i **én af** de valgte kommuner eller postnumre, gælder et af id’erne, eller hører til et af BFE-numrene. Uden et område afvises oprettelsen med `400` — ingen modtager hele landets ændringer.

Et BFE-nummer fanger bygningsændringer på ejendommen og adresseændringer på de adresser, vores adresseindeks placerer på den. Hændelsestyperne vælger du med `events`: abonnér fx kun på `bygning.udgaaet`, hvis det er nedrivninger, du vil vide besked om.

```http
POST /v1/me/webhooks
Content-Type: application/json

{
  "url": "https://example.com/hooks/adresser",
  "events": ["adresse.oprettet", "adresse.aendret", "adresse.nedlagt"],
  "adresseFilter": {
    "kommunekoder": ["0101", "0147"],
    "postnumre": ["2770"],
    "adgangsadresseIds": [],
    "bfeNumre": [5651067]
  }
}
```

- Højst 10 kommunekoder, 50 postnumre, 1.000 adgangsadresse-id’er og 1.000 BFE-numre pr. endpoint. En tre-cifret kommunekode udfyldes med 0 foran.
- Adresseændringerne kommer fra den daglige DAR-opdatering. Én levering bærer op til **100 ændringer** af samme slags; flere ændringer giver flere leveringer.
- Bygningsændringerne findes, når en ny udgave af det nationale bygningsindeks indlæses og sammenlignes med den forrige — de kommer altså samlet, når indekset er bygget igen. Det sker automatisk, så snart Datafordeler har udgivet et nyt samlet BBR-udtræk; udtrækkene udkommer med nogle dages forsinkelse, så ændringerne er typisk få dage gamle. Én levering bærer op til **50 bygninger** af samme slags, hver i samme form som i [ændringsfeedet](https://grundfast.dk/docs/api/aendringer) plus sit `sekvens`-nummer.
- Hver ændring har et globalt, stigende `sekvens`-nummer. Brug det til at genkende dubletter, og til at hente efterslæb fra [ændringsfeedet](https://grundfast.dk/docs/api/overvaagning-adresse-haendelser) med `?efter=`.
- `flere: true` betyder, at flere ændringer af samme slags følger i de næste leveringer.
- Et nyt abonnement starter fra nu: du får ikke de ændringer, der allerede ligger i loggen.

```json
{
  "id": "whd_...",
  "event": "adresse.aendret",
  "created": "2026-10-01T05:12:40.112Z",
  "data": {
    "antal": 1,
    "sekvens_fra": 48211,
    "sekvens_til": 48211,
    "flere": false,
    "aendringer": [
      {
        "sekvens": 48211,
        "haendelse": "adresse.aendret",
        "adgangsadresse_id": "0a3f507a-b2e6-32b8-e044-0003ba298018",
        "betegnelse": "Rådhuspladsen 1, 1550 København V",
        "kommunekode": "0101",
        "postnr": "1550",
        "status": "gaeldende",
        "aendrede_felter": ["koordinat"],
        "foer": {
          "betegnelse": "Rådhuspladsen 1, 1550 København V",
          "vejnavn": "Rådhuspladsen",
          "husnr": "1",
          "postnr": "1550",
          "postnrnavn": "København V",
          "kommunekode": "0101",
          "koordinat": { "lon": 12.56953, "lat": 55.67568 }
        },
        "efter": {
          "betegnelse": "Rådhuspladsen 1, 1550 København V",
          "vejnavn": "Rådhuspladsen",
          "husnr": "1",
          "postnr": "1550",
          "postnrnavn": "København V",
          "kommunekode": "0101",
          "koordinat": { "lon": 12.56961, "lat": 55.67571 }
        },
        "dar_udtraek": "DAR_20261001040010",
        "registreret": "2026-10-01T05:10:02.551Z"
      }
    ]
  }
}
```

For `adresse.oprettet` er `foer` `null`, og for `adresse.nedlagt` er `efter` `null`. `aendrede_felter` er kun udfyldt for `adresse.aendret`.

## Virksomhedshændelser (CVR)

Et endpoint, der abonnerer på en `cvr.*`-hændelse, skal have et `cvrFilter`: en liste **CVR-numre** (fx den portefølje af firmaer, du vil vide det om, når de ophører) og/eller **kommunekoder**. En ændring leveres, når virksomheden er på listen, eller dens adresse ligger — eller lå, før en flytning — i én af kommunerne. Uden filter afvises oprettelsen med `400`.

```http
POST /v1/me/webhooks
Content-Type: application/json

{
  "url": "https://example.com/hooks/cvr",
  "events": ["cvr.ophoert", "cvr.aendret"],
  "cvrFilter": {
    "cvrNumre": [12345678, 87654321],
    "kommunekoder": []
  }
}
```

- Højst 10.000 CVR-numre og 10 kommunekoder pr. endpoint.
- Ændringerne findes, når hele CVR hver uge sammenlignes med Grundfasts kopi — en ændring kommer altså op til ca. en uge efter, CVR registrerede den. Kun felter vi udstiller tæller: navn, status, ophørsdato, virksomhedsform, adresse, kommune og hovedbranche.
- `cvr.ophoert` sendes i stedet for `cvr.aendret`, når virksomheden går fra aktiv til ophørt; `aendrede_felter` nævner alle felter, der flyttede sig samtidig. Abonnér på begge, hvis du vil have alt.
- Samme bundter som adressehændelser: op til 100 ændringer af samme slags pr. levering, et globalt `sekvens`-nummer, og efterslæb fra [ændringsfeedet](https://grundfast.dk/docs/api/overvaagning-cvr-haendelser) med `?efter=`. Et nyt abonnement starter fra nu.

```json
{
  "id": "whd_...",
  "event": "cvr.ophoert",
  "created": "2026-10-04T03:15:02.004Z",
  "data": {
    "antal": 1,
    "sekvens_fra": 9120,
    "sekvens_til": 9120,
    "flere": false,
    "aendringer": [
      {
        "sekvens": 9120,
        "haendelse": "cvr.ophoert",
        "cvr_nummer": 12345678,
        "navn": "Eksempel ApS",
        "kommunekode": "0101",
        "status": "ophørt",
        "aendrede_felter": ["status", "ophoersdato"],
        "foer": { "navn": "Eksempel ApS", "status": "aktiv", "ophoersdato": null, "...": "..." },
        "efter": { "navn": "Eksempel ApS", "status": "ophørt", "ophoersdato": "2026-09-30", "...": "..." },
        "cvr_udtraek": "CVR_20261003",
        "registreret": "2026-10-04T03:12:40.118Z"
      }
    ]
  }
}
```

`foer` og `efter` har felterne `navn`, `status`, `ophoersdato`, `virksomhedsform`, `adresse`, `kommunekode`, `kommune` og `branche` (`{ kode, tekst }`). For `cvr.oprettet` er `foer` `null`.

## Ændringer på overvågede ejendomme

`ejendom.aendret` sendes én gang pr. ejendom og tjek, når mindst ét af registrene BBR, vurdering, plan og jord har ændret sig. `aendringer` har et kort før/efter-sammendrag pr. ændret register:

```json
{
  "id": "whd_...",
  "event": "ejendom.aendret",
  "created": "2026-10-01T09:41:17.006Z",
  "data": {
    "bfe_nummer": 5651067,
    "note": "Portefølje Nord",
    "aendrede_registre": ["vurdering"],
    "aendringer": [
      {
        "register": "vurdering",
        "foer": {
          "antal_vurderingsejendomme": 1,
          "aar": 2022,
          "ejendomsvaerdi_kr": 3150000,
          "grundvaerdi_kr": 912000,
          "vurderet_areal_m2": 812
        },
        "efter": {
          "antal_vurderingsejendomme": 1,
          "aar": 2024,
          "ejendomsvaerdi_kr": 3400000,
          "grundvaerdi_kr": 968000,
          "vurderet_areal_m2": 812
        }
      }
    ],
    "kontrolleret": "2026-10-01T09:41:16.880Z"
  }
}
```

## Færdige job

`job.completed` bærer hele jobbet i samme form som [GET /v1/jobs/:id](https://grundfast.dk/docs/api/jobs-get), så du kan hente filerne med det samme uden at spørge først. Hændelsen sendes højst én gang pr. job.

```json
{
  "id": "whd_...",
  "event": "job.completed",
  "created": "2026-10-05T11:02:44.913Z",
  "data": {
    "job": {
      "id": "job_4n8q2v7k1m9x3c5z0w6r2t8b",
      "kind": "datavask",
      "status": "completed",
      "totalRows": 2,
      "processedRows": 2,
      "succeeded": 2,
      "failed": 0,
      "billedUnits": 2,
      "progress": 1,
      "resultsAvailable": true
    }
  }
}
```

## Verificér signaturen

`X-Grundfast-Signature` er `sha256=` efterfulgt af den hex-kodede HMAC-SHA256 af den **rå** request-body med din `whsec_`-nøgle. Beregn den over de præcise bytes, før du parser JSON, og sammenlign i konstant tid:

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

import express from 'express';

export function verifyGrundfastWebhook(
  rawBody: string,
  header: string | undefined,
  secret: string,
): boolean {
  if (!header) return false;
  const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
  const a = Buffer.from(header);
  const b = Buffer.from(expected);
  // Længden først: timingSafeEqual kaster ved forskellig længde.
  return a.length === b.length && timingSafeEqual(a, b);
}

const secret = process.env.GRUNDFAST_WEBHOOK_SECRET ?? '';
const app = express();

// Læs den RÅ body — ikke et objekt, der er parset og serialiseret igen.
app.post('/hooks/grundfast', express.raw({ type: 'application/json' }), (req, res) => {
  const rawBody = req.body.toString('utf8');
  if (!verifyGrundfastWebhook(rawBody, req.header('X-Grundfast-Signature'), secret)) {
    res.status(401).send('bad signature');
    return;
  }
  const event = JSON.parse(rawBody);
  // Håndtér event.event og event.data. Svar hurtigt med 2xx; lav det tunge arbejde bagefter.
  res.sendStatus(200);
});
```

Bruger du [SDK’et](https://grundfast.dk/docs/sdk) (fra 0.24.0), gør `verifyWebhookSignature` det samme med Web Crypto — i Node, Bun, Deno og edge-miljøer:

```ts
import { verifyWebhookSignature } from '@grundfast/sdk';

export async function POST(request: Request): Promise<Response> {
  const raw = await request.text();
  const ok = await verifyWebhookSignature(
    raw,
    request.headers.get('X-Grundfast-Signature'),
    process.env.GRUNDFAST_WEBHOOK_SECRET ?? '',
  );
  if (!ok) return new Response('bad signature', { status: 401 });
  const event = JSON.parse(raw);
  // Håndtér event.event og event.data.
  return new Response(null, { status: 200 });
}
```

## Levering og nye forsøg

En levering er gennemført, når dit endpoint svarer `2xx` inden for **10 sekunder**. Alt andet — en anden status, timeout eller netværksfejl — prøves igen med stigende pause, op til **5 forsøg** i alt:

```text
forsøg 1 → fejl → vent 1 min
forsøg 2 → fejl → vent 5 min
forsøg 3 → fejl → vent 30 min
forsøg 4 → fejl → vent 2 timer
forsøg 5 → fejl → status "failed" (opgives)
```

- Redirects følges **ikke**; et `30x`-svar tæller som en fejlet levering.
- Hver levering står i loggen som `pending`, `success` eller `failed` med antal forsøg, seneste HTTP-status og eventuel fejl. Hent den på `GET /v1/me/webhooks/:id/deliveries`.
- Den samme hændelse kan i sjældne tilfælde nå frem mere end én gang. Brug `X-Grundfast-Delivery` til at genkende dubletter, og gør din håndtering idempotent.

## Send igen

Var din modtager nede, så send det, den gik glip af, igen — fra dashboardet eller med API’et. En genlevering er **den samme hændelse**: samme body, samme `id` og samme `X-Grundfast-Delivery`, plus headeren `X-Grundfast-Redelivery: true`. Genkender du dubletter på id’et, behandles den kun én gang.

```http
POST /v1/me/webhooks/wh_.../deliveries/whd_.../redeliver

HTTP/1.1 202 Accepted
{ "delivery": { "id": "whd_...", "status": "pending", "redeliveryOf": "whd_...", … } }

POST /v1/me/webhooks/wh_.../redeliver-failed
Content-Type: application/json

{ "siden": "2026-10-10" }

HTTP/1.1 202 Accepted
{ "queued": 37, "hasMore": false }
```

- Genleveringen sættes i kø og sendes inden for et minut, med de samme nye forsøg som en almindelig levering.
- `redeliver-failed` tager hver hændelse, hvis levering endte som `failed`, og som ikke allerede er sendt igen med held eller står i kø — ældste først, højst 500 pr. kald (`antal`). Er `hasMore` `true`, så kald igen. To kald efter hinanden sender intet to gange.
- `409`, hvis hændelsen allerede står i kø, eller hvis endpointet er sat på pause — genaktivér det først.
- Højst 120 enkelte genleveringer og 10 kald til `redeliver-failed` i timen pr. konto; derover svarer API’et `429` med `Retry-After`.

## Hent det, du gik glip af

`GET /v1/me/webhooks/events` lister alle hændelser, Grundfast har dannet til dine endpoints, ældste først — med `data` præcis som den blev (eller bliver) sendt, og hvordan leveringen gik. Det er den sikre vej tilbage efter et nedbrud: læs fra dit sidste kendte tidspunkt, og behandl det, du ikke har set.

```http
GET /v1/me/webhooks/events?status=failed&since=2026-10-10

HTTP/1.1 200 OK

{
  "events": [
    {
      "id": "whd_...",
      "event": "adresse.aendret",
      "created": "2026-10-10T05:12:40.112Z",
      "data": { "antal": 3, "sekvens_fra": 48211, "sekvens_til": 48230, "flere": false, "aendringer": [ … ] },
      "webhookId": "wh_...",
      "delivery": {
        "id": "whd_...",
        "status": "failed",
        "attempts": 5,
        "responseStatus": 503,
        "error": "endpoint returned 503",
        "lastAttemptAt": "2026-10-10T08:14:02.551Z",
        "deliveredAt": null,
        "redeliveries": 0
      }
    }
  ],
  "nextCursor": "ZTo0ODI",
  "hasMore": false,
  "retentionDays": 30,
  "coveredFrom": "2026-09-12T09:00:00.000Z"
}
```

- Filtrér med `status` (`pending`, `success`, `failed`), `event`, `webhook` (et endpoint-id) og `since`. `status` er den seneste tilstand — en hændelse, der fejlede og siden blev sendt igen med held, står som `success` med `redeliveries: 1`.
- Sid med `cursor` = `nextCursor`, op til 100 pr. side (`limit`, standard 25). Du springer aldrig over og ser aldrig en hændelse to gange.
- Hændelser gemmes i `retentionDays` dage, samme tid som leveringsloggen. Et `since` før `coveredFrom` læser fra `coveredFrom`.
- Kan kaldes med din session eller en API-nøgle med scopet `webhooks`.
