# Webhooks

<https://kuvert.dk/dokumentation/api/webhooks>

En webhook lader Kuvert fortælle dit system, at noget er sket — et kort er udstedt, et beløb er trukket, en ordre er betalt eller refunderet, et bord er booket eller aflyst, et arrangement er udgivet, ændret eller aflyst. Hver leverance er signeret, så du kan bevise, at den kom fra os.

## Opret et endepunkt

Under Indstillinger → Webhooks tilføjer du den URL, hændelserne skal sendes til. Du får en hemmelighed tilbage — den bruges til at verificere signaturen og skal opbevares som en adgangskode.

Et system kan også tilmelde sig selv med en API-nøgle, uden at nogen åbner dashboardet. Det er sådan en integration, du ikke selv har skrevet, kobler sig på: den får din nøgle og registrerer sin egen adresse.

- `GET /v1/api/webhooks`
- `POST /v1/api/webhooks`
- `PATCH /v1/api/webhooks`
- `DELETE /v1/api/webhooks/{id}`

> **Bemærk:** En tom events-liste betyder alle hændelser. Adressen skal være https og pege på en offentlig vært — interne adresser afvises.

## List endepunkter

`GET /v1/api/webhooks`

Forretningens endepunkter — også dem, der er oprettet i dashboardet — hver med sin hemmelighed og udfaldet af den seneste leverance.

### Svarfelter

Endepunkterne står under `endpoints`. Hvert har disse felter:

- `id` (`string`): Endepunktets id (`weh_…`).
- `url` (`string`): Adressen, hændelserne sendes til.
- `events` (`string[]`): Hændelserne, endepunktet abonnerer på. En tom liste betyder alle.
- `status` (`"active" | "disabled"`): Kun `active` får leverancer.
- `secret` (`string`): Hemmeligheden (`whsec_…`), signaturen beregnes med.
- `lastDeliveryAt` (`string | null`): Hvornår der sidst blev forsøgt en leverance. `null`, hvis der aldrig er sendt noget.
- `lastDeliveryOk` (`boolean | null`): Om den seneste leverance fik 2xx. `null` betyder aldrig forsøgt — ikke fejlet.
- `lastDeliveryDetail` (`string | null`): Hvorfor den seneste fejlede: en HTTP-status eller en transportfejl. `null`, når den lykkedes.
- `consecutiveFailures` (`integer`): Fejlede leverancer i træk. Den første, der lykkes, nulstiller tallet.
- `createdAt` (`string`): Hvornår endepunktet blev oprettet.

### Statuskoder

- `200`: Listen, eventuelt tom.

### Forespørgsel

```bash
curl https://api.kuvert.dk/v1/api/webhooks \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Eksempel på svar · 200

```json
{
  "endpoints": [
    {
      "id": "weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9",
      "url": "https://dit-system.dk/kuvert",
      "events": [
        "giftcard.issued",
        "giftcard.redeemed"
      ],
      "status": "active",
      "secret": "whsec_docs-example-not-a-real-secret",
      "lastDeliveryAt": "2026-09-29T09:12:44.000Z",
      "lastDeliveryOk": true,
      "lastDeliveryDetail": null,
      "consecutiveFailures": 0,
      "createdAt": "2026-09-01T07:30:00.000Z"
    }
  ]
}
```

## Tilføj et endepunkt

`POST /v1/api/webhooks`

Registrerer en adresse og svarer med endepunktet og dets hemmelighed. Det får leverancer med det samme.

### Krop

- `url` (`string`, påkrævet): En offentlig https-adresse, højst 2048 tegn. Interne og lokale adresser afvises.
- `events` (`string[]`, påkrævet): Hændelserne, der skal sendes. En tom liste betyder alle — også dem, der kommer til senere.

### Svarfelter

Endepunktet står under `endpoint`, med de samme felter som ved List endepunkter.

### Statuskoder

- `201`: Endepunktet er oprettet.
- `400 invalid_webhook_endpoint`: Kroppen matcher ikke: adressen er ikke en offentlig https-adresse, eller en hændelse findes ikke.

### Forespørgsel

```bash
curl -X POST https://api.kuvert.dk/v1/api/webhooks \
  -H "Authorization: Bearer $KUVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://dit-system.dk/kuvert",
    "events": [
      "giftcard.issued",
      "giftcard.redeemed"
    ]
  }'
```

### Eksempel på svar · 201

```json
{
  "endpoint": {
    "id": "weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9",
    "url": "https://dit-system.dk/kuvert",
    "events": [
      "giftcard.issued",
      "giftcard.redeemed"
    ],
    "status": "active",
    "secret": "whsec_docs-example-not-a-real-secret",
    "lastDeliveryAt": null,
    "lastDeliveryOk": null,
    "lastDeliveryDetail": null,
    "consecutiveFailures": 0,
    "createdAt": "2026-09-29T09:00:00.000Z"
  }
}
```

## Ret et endepunkt

`PATCH /v1/api/webhooks`

Retter adresse, hændelser eller status. Endepunktets `id` står i kroppen, ikke i stien — send kun de felter, du vil ændre.

### Krop

- `id` (`string`, påkrævet): Endepunktets id (`weh_…`).
- `url` (`string`, valgfri): Ny adresse, med samme krav som ved oprettelse.
- `events` (`string[]`, valgfri): En ny liste af hændelser. Den erstatter den gamle helt.
- `status` (`"active" | "disabled"`, valgfri): `disabled` standser leverancerne uden at slette endepunktet.

### Svarfelter

Det rettede endepunkt står under `endpoint`.

### Statuskoder

- `200`: Endepunktet er rettet.
- `400 invalid_request`: Kroppen matcher ikke — typisk et manglende `id`.
- `404 webhook_endpoint_not_found`: Endepunktet findes ikke på din forretning.

### Forespørgsel

```bash
curl -X PATCH https://api.kuvert.dk/v1/api/webhooks \
  -H "Authorization: Bearer $KUVERT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9",
    "status": "disabled"
  }'
```

### Eksempel på svar · 200

```json
{
  "endpoint": {
    "id": "weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9",
    "url": "https://dit-system.dk/kuvert",
    "events": [
      "giftcard.issued",
      "giftcard.redeemed"
    ],
    "status": "disabled",
    "secret": "whsec_docs-example-not-a-real-secret",
    "lastDeliveryAt": "2026-09-29T09:12:44.000Z",
    "lastDeliveryOk": true,
    "lastDeliveryDetail": null,
    "consecutiveFailures": 0,
    "createdAt": "2026-09-01T07:30:00.000Z"
  }
}
```

## Slet et endepunkt

`DELETE /v1/api/webhooks/{id}`

Fjerner endepunktet for altid. Vil du bare holde pause, så sæt status til `disabled` i stedet.

### Stiparametre

- `id` (`string`, påkrævet): Endepunktets id (`weh_…`).

### Svarfelter

- `deleted` (`boolean`): Altid `true`.

### Statuskoder

- `200`: Endepunktet er slettet.
- `404 webhook_endpoint_not_found`: Endepunktet findes ikke på din forretning.

### Forespørgsel

```bash
curl -X DELETE https://api.kuvert.dk/v1/api/webhooks/weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9 \
  -H "Authorization: Bearer $KUVERT_API_KEY"
```

### Eksempel på svar · 200

```json
{
  "deleted": true
}
```

## Leverancen

Hver hændelse sendes som en POST med en JSON-krop i fire felter: `id` er leverancens eget id (`whevt_…`), `type` er hændelsen, `createdAt` er tidspunktet, og `data` er det, hændelsen handler om. Tre headers følger med: `content-type`, `kuvert-event-type` og `kuvert-signature`.

```http
POST /kuvert HTTP/1.1
Host: dit-system.dk
content-type: application/json
kuvert-event-type: giftcard.redeemed
kuvert-signature: t=1790673164,v1=067aae7b381a2e42b518ef5781e9747638aa4c987e4f68396bef9a06a78d71a8

{"id":"whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7","type":"giftcard.redeemed","createdAt":"2026-09-29T09:12:44.118Z","data":{"code":"KUV-7K9F-2XQD","amountOre":6400,"remainingBalanceOre":43600}}
```

Signaturen i eksemplet er ægte. Med hemmeligheden `whsec_docs-example-not-a-real-secret` giver opskriften under Signaturen præcis den `v1` — kør din egen verificering på den, med tidstolerancen slået fra, da tidspunktet ligger i fortiden.

## Hændelser

> **Bemærk:** Listen er udtømmende: giftcard.issued, giftcard.redeemed, order.paid, order.refunded, ticket.issued, order.disputed, order.dispute_closed, booking.created, booking.changed, booking.cancelled, event.published, event.updated, event.canceled, event.unpublished.

### giftcard.issued

Et gavekort er udstedt.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "giftcard.issued",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "code": "KUV-M4TQ-8WZR",
    "initialOre": 50000,
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c"
  }
}
```

### giftcard.redeemed

Et beløb er trukket fra et kort.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "giftcard.redeemed",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "code": "KUV-7K9F-2XQD",
    "amountOre": 6400,
    "remainingBalanceOre": 43600
  }
}
```

### order.paid

En ordre er betalt.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "order.paid",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
    "totalOre": 50000,
    "cardCodes": [
      "KUV-M4TQ-8WZR"
    ]
  }
}
```

### order.refunded

En ordre er refunderet.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "order.refunded",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
    "refundedOre": 50500
  }
}
```

### ticket.issued

En billet er udstedt af en betalt ordre.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "ticket.issued",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "code": "TKT-H3NW-7PKC",
    "eventId": "evt_1a9c4e7b2d5f48a3b6c9e0d2f4a7b1c8",
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c"
  }
}
```

### order.disputed

Køberens kortudsteder har bestridt betalingen. Ordrens gavekort og billetter er sat på pause og kan ikke bruges.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "order.disputed",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
    "frozenCards": 1,
    "frozenTickets": 0
  }
}
```

### order.dispute_closed

Sagen er afgjort. `won: true` betyder, at pausen er hævet og kortene virker igen; `false` at de er annulleret.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "order.dispute_closed",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "orderId": "ord_7d3f0b9c1e2a45f68b4c9d0e1f2a3b5c",
    "won": true,
    "disputedOre": 50500
  }
}
```

### booking.created

Et bord er booket — af gæsten på bookingsiden, i telefonen eller ved døren.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "booking.created",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "bookingId": "bkg_6c0e3a9f5b1d47e2a8c4f0b6d2e9a5c1",
    "date": "2026-10-02",
    "startsAt": "2026-10-02T17:30:00.000Z",
    "endsAt": "2026-10-02T19:30:00.000Z",
    "guests": 4,
    "status": "confirmed",
    "source": "storefront",
    "guestName": "Mette Hansen",
    "guestEmail": "mette@dit-domaene.dk",
    "guestPhone": "+45 12 34 56 78",
    "tableIds": [
      "btb_0d4a8e2c6f1b43a9b7e5c3d1f9a2e6b8"
    ]
  }
}
```

### booking.changed

En bordbooking har fået ny tid, dato, antal eller borde, eller en ny status. Hele bookingen følger med, og `status` siger, hvor den står.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "booking.changed",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "bookingId": "bkg_6c0e3a9f5b1d47e2a8c4f0b6d2e9a5c1",
    "date": "2026-10-02",
    "startsAt": "2026-10-02T17:30:00.000Z",
    "endsAt": "2026-10-02T19:30:00.000Z",
    "guests": 6,
    "status": "confirmed",
    "source": "storefront",
    "guestName": "Mette Hansen",
    "guestEmail": "mette@dit-domaene.dk",
    "guestPhone": "+45 12 34 56 78",
    "tableIds": [
      "btb_0d4a8e2c6f1b43a9b7e5c3d1f9a2e6b8"
    ]
  }
}
```

### booking.cancelled

En bordbooking er aflyst, af gæsten eller af restauranten. Bookinger, der flyttes ind fra et andet system, sender ingen hændelser.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "booking.cancelled",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "bookingId": "bkg_6c0e3a9f5b1d47e2a8c4f0b6d2e9a5c1",
    "date": "2026-10-02",
    "startsAt": "2026-10-02T17:30:00.000Z",
    "endsAt": "2026-10-02T19:30:00.000Z",
    "guests": 4,
    "status": "cancelled",
    "source": "storefront",
    "guestName": "Mette Hansen",
    "guestEmail": "mette@dit-domaene.dk",
    "guestPhone": "+45 12 34 56 78",
    "tableIds": [
      "btb_0d4a8e2c6f1b43a9b7e5c3d1f9a2e6b8"
    ]
  }
}
```

### event.published

Et arrangement er sat til salg. Hele aftenen følger med — navn, tider, sted, serie, line-up og genrer — i samme form som listen på GET /v1/merchants/{slug}, med `eventId` i stedet for `id`.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "event.published",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "eventId": "evt_1a9c4e7b2d5f48a3b6c9e0d2f4a7b1c8",
    "slug": "vortex-dimension-2026-10-03",
    "name": "Vortex Dimension",
    "startsAt": "2026-10-03T21:00:00.000Z",
    "endsAt": "2026-10-04T03:00:00.000Z",
    "venue": "Club Vortex",
    "bannerUrl": "https://api.kuvert.dk/v1/media/med_2b7e4c9a1f3d48e6b0a5c8d2f1e7a4b9",
    "bannerWidth": 1080,
    "bannerHeight": 1350,
    "salesCloseAt": "2026-10-03T21:00:00.000Z",
    "status": "published",
    "cancelNote": null,
    "series": null,
    "seriesDate": null,
    "subtitle": "No Boyz On Deck",
    "lineup": [
      {
        "name": "Faustix",
        "role": "DJ",
        "time": "2026-10-03T23:00:00.000Z"
      }
    ],
    "genres": [
      "House",
      "Techno"
    ],
    "doorsAt": "2026-10-03T21:00:00.000Z",
    "lastEntryAt": "2026-10-04T01:30:00.000Z",
    "dressCode": null,
    "ticketsUrl": null
  }
}
```

### event.updated

Noget offentligt på et udgivet arrangement er ændret. `changedFields` siger hvad — fx `lineup`, eller `tiers`, når en pris er ændret.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "event.updated",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "eventId": "evt_1a9c4e7b2d5f48a3b6c9e0d2f4a7b1c8",
    "slug": "vortex-dimension-2026-10-03",
    "name": "Vortex Dimension",
    "startsAt": "2026-10-03T21:00:00.000Z",
    "endsAt": "2026-10-04T03:00:00.000Z",
    "venue": "Club Vortex",
    "bannerUrl": "https://api.kuvert.dk/v1/media/med_2b7e4c9a1f3d48e6b0a5c8d2f1e7a4b9",
    "bannerWidth": 1080,
    "bannerHeight": 1350,
    "salesCloseAt": "2026-10-03T21:00:00.000Z",
    "status": "published",
    "cancelNote": null,
    "series": null,
    "seriesDate": null,
    "subtitle": "No Boyz On Deck",
    "lineup": [
      {
        "name": "Faustix",
        "role": "DJ",
        "time": "2026-10-03T23:00:00.000Z"
      }
    ],
    "genres": [
      "House",
      "Techno"
    ],
    "doorsAt": "2026-10-03T21:00:00.000Z",
    "lastEntryAt": "2026-10-04T01:30:00.000Z",
    "dressCode": null,
    "ticketsUrl": null,
    "changedFields": [
      "lineup"
    ]
  }
}
```

### event.canceled

Et udgivet arrangement er aflyst. `cancelNote` er den besked, I skrev til gæsterne.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "event.canceled",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "eventId": "evt_1a9c4e7b2d5f48a3b6c9e0d2f4a7b1c8",
    "slug": "vortex-dimension-2026-10-03",
    "name": "Vortex Dimension",
    "startsAt": "2026-10-03T21:00:00.000Z",
    "endsAt": "2026-10-04T03:00:00.000Z",
    "venue": "Club Vortex",
    "bannerUrl": "https://api.kuvert.dk/v1/media/med_2b7e4c9a1f3d48e6b0a5c8d2f1e7a4b9",
    "bannerWidth": 1080,
    "bannerHeight": 1350,
    "salesCloseAt": "2026-10-03T21:00:00.000Z",
    "status": "canceled",
    "cancelNote": "Aftenen er aflyst. Alle billetter refunderes automatisk.",
    "series": null,
    "seriesDate": null,
    "subtitle": "No Boyz On Deck",
    "lineup": [
      {
        "name": "Faustix",
        "role": "DJ",
        "time": "2026-10-03T23:00:00.000Z"
      }
    ],
    "genres": [
      "House",
      "Techno"
    ],
    "doorsAt": "2026-10-03T21:00:00.000Z",
    "lastEntryAt": "2026-10-04T01:30:00.000Z",
    "dressCode": null,
    "ticketsUrl": null
  }
}
```

### event.unpublished

Et udgivet arrangement er sat tilbage til kladde. En kladde sender aldrig hændelser.

Eksempel på leverance:

```json
{
  "id": "whevt_c81f5a2d9e3b47c6a0d4e8f1b5c9a2d7",
  "type": "event.unpublished",
  "createdAt": "2026-09-29T09:12:44.118Z",
  "data": {
    "eventId": "evt_1a9c4e7b2d5f48a3b6c9e0d2f4a7b1c8",
    "slug": "vortex-dimension-2026-10-03",
    "name": "Vortex Dimension",
    "startsAt": "2026-10-03T21:00:00.000Z",
    "endsAt": "2026-10-04T03:00:00.000Z",
    "venue": "Club Vortex",
    "bannerUrl": "https://api.kuvert.dk/v1/media/med_2b7e4c9a1f3d48e6b0a5c8d2f1e7a4b9",
    "bannerWidth": 1080,
    "bannerHeight": 1350,
    "salesCloseAt": "2026-10-03T21:00:00.000Z",
    "status": "draft",
    "cancelNote": null,
    "series": null,
    "seriesDate": null,
    "subtitle": "No Boyz On Deck",
    "lineup": [
      {
        "name": "Faustix",
        "role": "DJ",
        "time": "2026-10-03T23:00:00.000Z"
      }
    ],
    "genres": [
      "House",
      "Techno"
    ],
    "doorsAt": "2026-10-03T21:00:00.000Z",
    "lastEntryAt": "2026-10-04T01:30:00.000Z",
    "dressCode": null,
    "ticketsUrl": null
  }
}
```

> **Bemærk:** totalOre på order.paid er kortenes værdi. Det er ikke nødvendigvis det beløb, køberen blev trukket: oveni kan ligge ekspeditionsgebyret på 5,00 kr. pr. gavekort og pr. billet — en ordre på tre kort er tre gebyrer, og billetter under 25,00 kr. har intet — og provisionen, hvis du har valgt, at kunden betaler den. Vælger du selv at betale dem, er de to tal ens. Begge dele opkræver du for Kuvert og får faktureret igen — netto nul, men to poster i dit regnskab. Du finder dem på ordren i dashboardet og på din månedlige faktura.

## Signaturen

Hver leverance bærer en `kuvert-signature`-header med et tidsstempel og en HMAC:

```
kuvert-signature: t=1753440000,v1=6f3a…
```

Signaturen er HMAC-SHA256 i hex over strengen tidsstempel, punktum, den rå krop — beregnet med din endepunkts-hemmelighed:

```
HMAC_SHA256(secret, "{t}.{raw body}")
```

## Verificér signaturen

**Shell**

```bash
# t og v1 fra kuvert-signature, body.json er den rå krop, præcis som den kom ind
printf '%s.%s' "$t" "$(cat body.json)" \
  | openssl dgst -sha256 -hmac "$KUVERT_WEBHOOK_SECRET" -hex
# Sammenlign resultatet med v1
```

**JavaScript**

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

export function verify(header, rawBody, secret, toleranceSec = 300) {
  const parts = Object.fromEntries(
    header.split(',').map((kv) => kv.split('=')),
  )
  const t = Number(parts.t)
  if (!t || !parts.v1) return false

  // Afvis for gamle leverancer — det stopper genafspilning.
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false

  const expected = createHmac('sha256', secret)
    .update(`${t}.${rawBody}`)
    .digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(parts.v1, 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}
```

**Python**

```python
import hashlib
import hmac
import time


def verify(header: str, raw_body: bytes, secret: str, tolerance: int = 300) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(",") if "=" in kv)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False

    # Afvis for gamle leverancer — det stopper genafspilning.
    if abs(time.time() - t) > tolerance:
        return False

    expected = hmac.new(
        secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

**PHP**

```php
<?php
function verify(string $header, string $rawBody, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$key, $value] = array_pad(explode('=', $kv, 2), 2, '');
        $parts[$key] = $value;
    }
    $t = (int) ($parts['t'] ?? 0);
    if ($t === 0 || !isset($parts['v1'])) {
        return false;
    }

    // Afvis for gamle leverancer — det stopper genafspilning.
    if (abs(time() - $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1']);
}

// $rawBody = file_get_contents('php://input');
// $header  = $_SERVER['HTTP_KUVERT_SIGNATURE'] ?? '';
```

> **Vigtigt:** Verificér på den RÅ krop, præcis som den kom ind. Har din server allerede parset JSON og bygget strengen igen, passer signaturen ikke — nøglerækkefølge og mellemrum indgår i beregningen.

## Svar hurtigt

- Kvittér med 2xx, så snart du har taget imod. Læg det tunge arbejde i en kø.
- Levering er bedste forsøg — et endepunkt, der er nede, holder aldrig et køb tilbage hos forretningen.
- Vi venter højst 5 sekunder på svaret. Alt andet end 2xx inden da tæller som en fejlet leverance — også en omdirigering, som vi ikke følger.

## Genforsøg

Der er ingen genforsøg. Hver hændelse sendes én gang til hvert endepunkt, og en leverance, der fejler, sendes ikke igen.

Hvordan den seneste leverance gik, står på endepunktet: List endepunkter svarer med lastDeliveryAt, lastDeliveryOk, lastDeliveryDetail og consecutiveFailures, og det samme står under Indstillinger → Webhooks. Vi slår aldrig et endepunkt fra af os selv — kun du kan sætte det til `disabled`.

> **Vigtigt:** Har dit endepunkt været nede, er de hændelser, der kom imens, væk. Afstem bagefter mod API’et: List kort giver hvert korts saldo, som den står nu.

## Krav til URL

Endepunktet skal være en offentligt tilgængelig HTTPS-adresse. Interne adresser afvises — det er en bevidst spærre mod, at et endepunkt bruges til at nå ind i vores eget netværk.
