# Webhooks

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

En webhook lar Kuvert fortelle systemet ditt at noe har skjedd, for eksempel at et kort er utstedt, et beløp er trukket, en ordre er betalt eller refundert, et bord er booket eller avbestilt, eller at et arrangement er publisert, endret eller avlyst. Hver levering er signert, så du kan bevise at den kom fra oss.

## Opprett et endepunkt

Under Innstillinger → Webhooks legger du til URL-en hendelsene skal sendes til. Du får en hemmelighet tilbake. Den brukes til å verifisere signaturen og skal oppbevares som et passord.

Et system kan også abonnere på seg selv med en API-nøkkel, uten at noen åpner dashbordet. Det er slik en integrasjon du ikke selv har skrevet kobler seg på: den får din nøkkel og registrerer sin egen adresse.

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

> **Merk:** En tom events-liste betyr alle hendelser. Adressen må være https og peke på en offentlig vert. Interne adresser avvises.

## List endepunkter

`GET /v1/api/webhooks`

Bedriftens endepunkter, også de som er opprettet i dashbordet, hvert med sin hemmelighet og utfallet av den siste leveringen.

### Svarfelter

Endepunktene ligger under `endpoints`. Hvert har disse feltene:

- `id` (`string`): Endepunktets id (`weh_…`).
- `url` (`string`): Adressen hendelsene sendes til.
- `events` (`string[]`): Hendelsene endepunktet abonnerer på. En tom liste betyr alle.
- `status` (`"active" | "disabled"`): Bare `active` får leveranser.
- `secret` (`string`): Hemmeligheten (`whsec_…`) signaturen regnes ut med.
- `lastDeliveryAt` (`string | null`): Når det sist ble forsøkt en leveranse. `null` hvis ingenting er sendt.
- `lastDeliveryOk` (`boolean | null`): Om den siste leveringen fikk 2xx. `null` betyr aldri forsøkt og ikke feilet.
- `lastDeliveryDetail` (`string | null`): Hvorfor den siste feilet: en HTTP-status eller en transportfeil. `null` når den lyktes.
- `consecutiveFailures` (`integer`): Feilede leveranser på rad. Den første som lykkes, nullstiller tallet.
- `createdAt` (`string`): Når endepunktet ble opprettet.

### Statuskoder

- `200`: Listen, eventuelt tom.

### Forespørsel

```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"
    }
  ]
}
```

## Legg til et endepunkt

`POST /v1/api/webhooks`

Registrerer en adresse og svarer med endepunktet og hemmeligheten. Det får leveranser med en gang.

### Kropp

- `url` (`string`, påkrevd): En offentlig https-adresse, høyst 2048 tegn. Interne og lokale adresser avvises.
- `events` (`string[]`, påkrevd): Hendelsene som skal sendes. En tom liste betyr alle, også de som kommer til senere.

### Svarfelter

Endepunktet ligger under `endpoint`, med de samme feltene som ved List endepunkter.

### Statuskoder

- `201`: Endepunktet er opprettet.
- `400 invalid_webhook_endpoint`: Kroppen stemmer ikke: adressen er ikke en offentlig https-adresse, eller en hendelse finnes ikke.

### Forespørsel

```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"
  }
}
```

## Endre et endepunkt

`PATCH /v1/api/webhooks`

Endrer adresse, hendelser eller status. Endepunktets `id` står i kroppen, ikke i stien. Send bare feltene du vil endre.

### Kropp

- `id` (`string`, påkrevd): Endepunktets id (`weh_…`).
- `url` (`string`, valgfri): Ny adresse, med de samme kravene som ved opprettelse.
- `events` (`string[]`, valgfri): En ny liste over hendelser. Den erstatter den gamle helt.
- `status` (`"active" | "disabled"`, valgfri): `disabled` stanser leveransene uten å slette endepunktet.

### Svarfelter

Det endrede endepunktet ligger under `endpoint`.

### Statuskoder

- `200`: Endepunktet er endret.
- `400 invalid_request`: Kroppen stemmer ikke, som regel fordi `id` mangler.
- `404 webhook_endpoint_not_found`: Endepunktet finnes ikke hos bedriften din.

### Forespørsel

```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"
  }
}
```

## Slett et endepunkt

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

Fjerner endepunktet for godt. Vil du bare ta en pause, setter du status til `disabled` i stedet.

### Stiparametere

- `id` (`string`, påkrevd): Endepunktets id (`weh_…`).

### Svarfelter

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

### Statuskoder

- `200`: Endepunktet er slettet.
- `404 webhook_endpoint_not_found`: Endepunktet finnes ikke hos bedriften din.

### Forespørsel

```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
}
```

## Leveransen

Hver hendelse sendes som en POST med en JSON-kropp i fire felter: `id` er leveransens egen id (`whevt_…`), `type` er hendelsen, `createdAt` er tidspunktet, og `data` er det hendelsen handler om. Tre headere 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 eksempelet er ekte. Med hemmeligheten `whsec_docs-example-not-a-real-secret` gir oppskriften under Signaturen nøyaktig den `v1`. Kjør din egen verifisering på den, med tidstoleransen slått av, siden tidspunktet ligger i fortiden.

## Hendelser

> **Merk:** Listen er uttø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 utstedt.

Eksempel på leveranse:

```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øp er trukket fra et kort.

Eksempel på leveranse:

```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. På en billettordre følger kjøperens svar på kveldens spørsmål med i answers, for eksempel et lagnavn, hvert med spørsmålet slik det lød da det ble besvart.

Eksempel på leveranse:

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

### order.refunded

En ordre er refundert.

Eksempel på leveranse:

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

### ticket.issued

En billett er utstedt av en betalt ordre. Ordrens svar på kveldens spørsmål følger med i answers på hver billett.

Eksempel på leveranse:

```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",
    "answers": [
      {
        "questionId": "eqs_5c1e9a7d3b2f48a6",
        "label": "Holdnavn",
        "answer": "Ølnørderne"
      }
    ],
    "seller": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "host": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "cohosts": []
  }
}
```

### order.disputed

Kjøperens kortutssteder har bestridt betalingen. Ordren sine gavekort og billetter er satt på pause og kan ikke brukes.

Eksempel på leveranse:

```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

Saken er avgjort. `won: true` betyr at pauseringen er hevet og kortene virker igjen; `false` at de er annullert.

Eksempel på leveranse:

```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 av gjesten på bookingsiden, på telefon eller i døra.

Eksempel på leveranse:

```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 bordreservasjon har fått nytt tidspunkt, ny dato, nytt antall, nye bord eller en ny status. Hele bookingen følger med, og `status` sier hvor den står.

Eksempel på leveranse:

```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 bordreservasjon er avbestilt, av gjesten eller av restauranten. Bookinger som flyttes inn fra et annet system, sender ingen hendelser.

Eksempel på leveranse:

```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 satt i salg. Hele kvelden følger med (navn, tider, sted, serie, line-up og sjangre) i samme form som listen på GET /v1/merchants/{slug}, med `eventId` i stedet for `id`.

Eksempel på leveranse:

```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,
    "host": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "cohosts": []
  }
}
```

### event.updated

Noe offentlig på et publisert arrangement er endret. `changedFields` sier hva, for eksempel `lineup`, eller `tiers` når en pris er endret.

Eksempel på leveranse:

```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,
    "host": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "cohosts": [],
    "changedFields": [
      "lineup"
    ]
  }
}
```

### event.canceled

Et publisert arrangement er avlyst. `cancelNote` er beskjeden dere skrev til gjestene.

Eksempel på leveranse:

```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,
    "host": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "cohosts": []
  }
}
```

### event.unpublished

Et publisert arrangement er satt tilbake til kladd. En kladd sender aldri hendelser.

Eksempel på leveranse:

```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,
    "host": {
      "slug": "club-vortex",
      "name": "Club Vortex"
    },
    "cohosts": []
  }
}
```

> **Merk:** totalOre på order.paid er kortenes verdi. Det er ikke nødvendigvis beløpet kjøperen ble trukket: i tillegg kan det komme ekspedisjonsgebyret på 5,00 DKK pr. gavekort og pr. billett (en ordre på tre kort er tre gebyrer, og billetter under 25,00 DKK har ingen) og provisjonen, hvis du har valgt at kunden betaler den. Velger du å betale dem selv, er de to tallene like. Begge deler krever du inn for Kuvert og får fakturert tilbake. Det er netto null, men to poster i regnskapet ditt. Du finner dem på ordren i dashbordet og på den månedlige fakturaen din.

## Signaturen

Hver levering 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å kroppen, beregnet med endepunktets hemmelighet:

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

## Verifiser 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'] ?? '';
```

> **Viktig:** Verifiser på den RÅ kroppen, nøyaktig slik den kom inn. Har serveren din allerede parset JSON og bygget strengen på nytt, stemmer ikke signaturen, fordi nøkkelrekkefølge og mellomrom inngår i beregningen.

## Svar raskt

- Kvitter med 2xx så snart du har mottatt. Legg det tunge arbeidet i en kø.
- Levering skjer etter beste evne. Et endepunkt som er nede, holder aldri tilbake et kjøp hos bedriften.
- Vi venter høyst 5 sekunder på svaret. Alt annet enn 2xx innen da teller som en mislykket levering, også en omdirigering, som vi ikke følger.

## Nye forsøk

Feiler en levering, sender vi den på nytt med økende pauser (1 minutt, 5 minutter, 30 minutter, 2 timer, 6 timer, 12 timer og 24 timer), totalt 8 forsøk over knapt to døgn. Det er den samme kroppen med den samme id-en hver gang, men med en ny signatur, så tidsstempelet er ferskt. Bruk id-en til å kjenne igjen en hendelse dere allerede har tatt imot: kommer ikke svaret fram, sender vi den på nytt.

Hvordan den siste leveringen gikk, står på endepunktet: List endepunkter svarer med lastDeliveryAt, lastDeliveryOk, lastDeliveryDetail og consecutiveFailures, og det samme står under Innstillinger → Webhooks. Vi slår aldri av et endepunkt på egen hånd. Bare du kan sette det til `disabled`, og et endepunkt som er slått av, får heller ingen nye forsøk.

> **Viktig:** Hendelsene kan komme i en annen rekkefølge enn de skjedde, fordi et nytt forsøk kan lande etter en nyere hendelse. Har endepunktet ditt vært nede i mer enn to døgn, er det som kom i mellomtiden, borte; avstem mot API-et: List kort gir hvert korts saldo slik den står nå.

## Krav til URL

Endepunktet må være en offentlig tilgjengelig HTTPS-adresse. Interne adresser avvises med vilje, så et endepunkt ikke kan brukes til å nå inn i vårt eget nettverk.
