# Webhooks

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

En webhook låter Kuvert tala om för ditt system att något har hänt, t.ex. att ett kort har utfärdats, ett belopp har dragits, en order har betalats eller återbetalats, ett bord har bokats eller avbokats, eller ett arrangemang har publicerats, ändrats eller ställts in. Varje leverans är signerad, så att du kan bevisa att den kom från oss.

## Skapa en slutpunkt

Under Inställningar → Webhooks lägger du till den URL som händelserna ska skickas till. Du får en hemlighet tillbaka. Den används för att verifiera signaturen och ska förvaras som ett lösenord.

Ett system kan också anmäla sig själv med en API-nyckel, utan att någon öppnar kontrollpanelen. Det är en sådan integration som du inte själv skrev som ansluter sig: den får din nyckel och registrerar sin egen adress.

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

> **Anmärkning:** En tom events-lista betyder alla händelser. Adressen måste vara https och peka på en offentlig värd. Interna adresser avvisas.

## Lista slutpunkter

`GET /v1/api/webhooks`

Företagets slutpunkter, även de som har skapats i kontrollpanelen, var och en med sin hemlighet och utfallet av den senaste leveransen.

### Svarsfält

Slutpunkterna ligger under `endpoints`. Var och en har de här fälten:

- `id` (`string`): Slutpunktens id (`weh_…`).
- `url` (`string`): Adressen som händelserna skickas till.
- `events` (`string[]`): Händelserna som slutpunkten prenumererar på. En tom lista betyder alla.
- `status` (`"active" | "disabled"`): Bara `active` får leveranser.
- `secret` (`string`): Hemligheten (`whsec_…`) som signaturen beräknas med.
- `lastDeliveryAt` (`string | null`): När en leverans senast försöktes. `null` om inget någonsin skickats.
- `lastDeliveryOk` (`boolean | null`): Om den senaste leveransen fick 2xx. `null` betyder aldrig försökt och inte misslyckad.
- `lastDeliveryDetail` (`string | null`): Varför den senaste misslyckades: en HTTP-status eller ett transportfel. `null` när den lyckades.
- `consecutiveFailures` (`integer`): Misslyckade leveranser i rad. Den första som lyckas nollställer talet.
- `createdAt` (`string`): När slutpunkten skapades.

### Statuskoder

- `200`: Listan, eventuellt tom.

### Anrop

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

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

## Lägg till en slutpunkt

`POST /v1/api/webhooks`

Registrerar en adress och svarar med slutpunkten och dess hemlighet. Den får leveranser direkt.

### Body

- `url` (`string`, obligatorisk): En publik https-adress, högst 2048 tecken. Interna och lokala adresser avvisas.
- `events` (`string[]`, obligatorisk): Händelserna som ska skickas. En tom lista betyder alla, även de som tillkommer senare.

### Svarsfält

Slutpunkten ligger under `endpoint`, med samma fält som vid Lista slutpunkter.

### Statuskoder

- `201`: Slutpunkten är skapad.
- `400 invalid_webhook_endpoint`: Bodyn stämmer inte: adressen är inte en publik https-adress, eller en händelse finns inte.

### Anrop

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

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

## Ändra en slutpunkt

`PATCH /v1/api/webhooks`

Ändrar adress, händelser eller status. Slutpunktens `id` står i bodyn, inte i sökvägen. Skicka bara de fält du vill ändra.

### Body

- `id` (`string`, obligatorisk): Slutpunktens id (`weh_…`).
- `url` (`string`, valfri): En ny adress, med samma krav som när den skapas.
- `events` (`string[]`, valfri): En ny lista med händelser. Den ersätter den gamla helt.
- `status` (`"active" | "disabled"`, valfri): `disabled` stoppar leveranserna utan att radera slutpunkten.

### Svarsfält

Den ändrade slutpunkten ligger under `endpoint`.

### Statuskoder

- `200`: Slutpunkten är ändrad.
- `400 invalid_request`: Bodyn stämmer inte, oftast för att `id` saknas.
- `404 webhook_endpoint_not_found`: Slutpunkten finns inte hos ditt företag.

### Anrop

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

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

## Radera en slutpunkt

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

Tar bort slutpunkten för gott. Vill du bara pausa, sätt status till `disabled` i stället.

### Sökvägsparametrar

- `id` (`string`, obligatorisk): Slutpunktens id (`weh_…`).

### Svarsfält

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

### Statuskoder

- `200`: Slutpunkten är raderad.
- `404 webhook_endpoint_not_found`: Slutpunkten finns inte hos ditt företag.

### Anrop

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

### Exempel på svar · 200

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

## Leveransen

Varje händelse skickas som en POST med en JSON-body i fyra fält: `id` är leveransens eget id (`whevt_…`), `type` är händelsen, `createdAt` är tidpunkten, och `data` är det händelsen handlar om. Tre headers följer med: `content-type`, `kuvert-event-type` och `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 exemplet är äkta. Med hemligheten `whsec_docs-example-not-a-real-secret` ger receptet under Signaturen exakt den `v1`. Kör din egen verifiering på den, med tidstoleransen avstängd, eftersom tidpunkten ligger bakåt i tiden.

## Händelser

> **Anmärkning:** Listan är uttömmande: 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

Ett presentkort är utgivet.

Exempel på leverans:

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

Ett belopp dras från ett kort.

Exempel på leverans:

```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 order är betald. På en biljettorder följer köparens svar på kvällens frågor med i answers, t.ex. ett lagnamn, vart och ett med frågan så som den löd när den besvarades.

Exempel på leverans:

```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 order är återbetald.

Exempel på leverans:

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

### ticket.issued

En biljett är utgiven från en betald order. Orderns svar på kvällens frågor följer med i answers på varje biljett.

Exempel på leverans:

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

Köparens kortutgivare har bestridit betalningen. Orderns presentkort och biljetter är pausade och kan inte användas.

Exempel på leverans:

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

Ärendet är löst. `won: true` betyder att pausen är hävd och korten fungerar igen; `false` att de är annullerade.

Exempel på leverans:

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

Ett bord har bokats av gästen på bokningssidan, per telefon eller vid dörren.

Exempel på leverans:

```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 bordsbokning har fått ny tid, nytt datum, nytt antal, nya bord eller en ny status. Hela bokningen följer med, och `status` säger var den står.

Exempel på leverans:

```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 bordsbokning är avbokad, av gästen eller av restaurangen. Bokningar som flyttas in från ett annat system skickar inga händelser.

Exempel på leverans:

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

Ett arrangemang har lagts ut till försäljning. Hela kvällen följer med (namn, tider, plats, serie, line-up och genrer) i samma form som listan på GET /v1/merchants/{slug}, med `eventId` i stället för `id`.

Exempel på leverans:

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

Något offentligt på ett publicerat arrangemang har ändrats. `changedFields` anger vad, t.ex. `lineup`, eller `tiers` när ett pris har ändrats.

Exempel på leverans:

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

Ett publicerat evenemang har ställts in. `cancelNote` är meddelandet ni skrev till gästerna.

Exempel på leverans:

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

Ett publicerat evenemang har satts tillbaka till utkast. Ett utkast skickar aldrig händelser.

Exempel på leverans:

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

> **Anmärkning:** totalOre på order.paid är kortens värde. Det är inte nödvändigtvis det belopp som köparen debiterades: ovanpå kan expeditionsavgiften på 5,00 Dkr per presentkort och per biljett komma (en order på tre kort ger tre avgifter, och biljetter under 25,00 Dkr har ingen) och provisionen, om du har valt att kunden betalar den. Väljer du själv att betala dem är de två beloppen lika. Båda tar du ut för Kuverts räkning och får dem sedan fakturerade. Det blir netto noll, men två poster i din bokföring. Du hittar dem på ordern i kontrollpanelen och på din månatliga faktura.

## Signaturen

Varje leverans bär en `kuvert-signature`-header med en tidsstämpel och en HMAC:

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

Signaturen är HMAC-SHA256 i hex över strängen tidsstämpel, punkt, den råa bodyn, beräknad med din slutpunkts hemlighet:

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

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

> **Viktigt:** Verifiera mot den RÅA bodyn, exakt som den kom in. Har din server redan tolkat JSON och byggt upp strängen igen stämmer signaturen inte, eftersom nyckelordning och mellanslag ingår i beräkningen.

## Svara snabbt

- Bekräfta med 2xx så snart du har mottagit. Lägg det tunga arbetet i en kö.
- Leverans sker efter bästa förmåga. En slutpunkt som ligger nere håller aldrig tillbaka ett köp hos företaget.
- Vi väntar högst 5 sekunder på svaret. Allt annat än 2xx inom den tiden räknas som en misslyckad leverans, även en omdirigering, som vi inte följer.

## Nya försök

Misslyckas en leverans skickar vi den igen med allt längre pauser (1 minut, 5 minuter, 30 minuter, 2 timmar, 6 timmar, 12 timmar och 24 timmar), totalt 8 försök under knappt två dygn. Det är samma body med samma id varje gång, men med en ny signatur, så att tidsstämpeln är färsk. Använd id:t för att känna igen en händelse som ni redan har tagit emot: når ert svar inte fram skickar vi den igen.

Hur den senaste leveransen gick står på slutpunkten: Lista slutpunkter svarar med lastDeliveryAt, lastDeliveryOk, lastDeliveryDetail och consecutiveFailures, och detsamma står under Inställningar → Webhooks. Vi stänger aldrig av en slutpunkt på eget initiativ. Bara du kan sätta den till `disabled`, och en avstängd slutpunkt får inte heller några nya försök.

> **Viktigt:** Händelserna kan komma i en annan ordning än de inträffade, eftersom ett nytt försök kan landa efter en nyare händelse. Har din slutpunkt legat nere i mer än två dygn är det som kom under tiden borta; stäm av mot API:et: Lista kort ger varje korts saldo som det står nu.

## Krav på URL

Slutpunkten måste vara en offentligt tillgänglig HTTPS-adress. Interna adresser avvisas med avsikt, så att en slutpunkt inte kan användas för att nå in i vårt eget nätverk.
