# Webhooks

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

Mit einem Webhook teilt Kuvert Ihrem System mit, dass etwas passiert ist, etwa dass eine Karte ausgestellt, ein Betrag abgezogen, eine Bestellung bezahlt oder erstattet, ein Tisch gebucht oder storniert, eine Veranstaltung veröffentlicht, geändert oder abgesagt wurde. Jede Zustellung ist signiert, sodass Sie nachweisen können, dass sie von uns kam.

## Erstellen Sie einen Endpunkt

Unter Einstellungen → Webhooks fügen Sie die URL hinzu, an die die Ereignisse gesendet werden sollen. Sie erhalten ein Geheimnis zurück. Es wird zur Prüfung der Signatur verwendet und muss wie ein Passwort aufbewahrt werden.

Ein System kann sich auch selbst mit einem API-Schlüssel anmelden, ohne dass jemand das Dashboard öffnet. Das ist, wie eine Integration, die Sie nicht selbst geschrieben haben, sich verbindet: Sie erhalten Ihren Schlüssel und registriert ihre eigene Adresse.

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

> **Hinweis:** Eine leere events-Liste bedeutet alle Ereignisse. Die Adresse muss https verwenden und auf einen öffentlichen Host zeigen. Interne Adressen werden abgelehnt.

## Endpunkte auflisten

`GET /v1/api/webhooks`

Die Endpunkte des Unternehmens, auch die im Dashboard angelegten, jeweils mit ihrem Geheimnis und dem Ergebnis der letzten Zustellung.

### Antwortfelder

Die Endpunkte stehen unter `endpoints`. Jeder hat diese Felder:

- `id` (`string`): Die ID des Endpunkts (`weh_…`).
- `url` (`string`): Die Adresse, an die die Ereignisse gesendet werden.
- `events` (`string[]`): Die Ereignisse, die der Endpunkt abonniert. Eine leere Liste bedeutet alle.
- `status` (`"active" | "disabled"`): Nur `active` erhält Zustellungen.
- `secret` (`string`): Das Geheimnis (`whsec_…`), mit dem die Signatur berechnet wird.
- `lastDeliveryAt` (`string | null`): Wann zuletzt eine Zustellung versucht wurde. `null`, wenn noch nie etwas gesendet wurde.
- `lastDeliveryOk` (`boolean | null`): Ob die letzte Zustellung ein 2xx erhalten hat. `null` bedeutet nie versucht und nicht fehlgeschlagen.
- `lastDeliveryDetail` (`string | null`): Warum die letzte fehlschlug: ein HTTP-Status oder ein Transportfehler. `null`, wenn sie gelang.
- `consecutiveFailures` (`integer`): Fehlgeschlagene Zustellungen in Folge. Die erste, die gelingt, setzt die Zahl zurück.
- `createdAt` (`string`): Wann der Endpunkt angelegt wurde.

### Statuscodes

- `200`: Die Liste, gegebenenfalls leer.

### Anfrage

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

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

## Endpunkt hinzufügen

`POST /v1/api/webhooks`

Registriert eine Adresse und antwortet mit dem Endpunkt und seinem Geheimnis. Er erhält sofort Zustellungen.

### Body

- `url` (`string`, erforderlich): Eine öffentliche https-Adresse, höchstens 2048 Zeichen. Interne und lokale Adressen werden abgelehnt.
- `events` (`string[]`, erforderlich): Die Ereignisse, die gesendet werden sollen. Eine leere Liste bedeutet alle, auch die, die später hinzukommen.

### Antwortfelder

Der Endpunkt steht unter `endpoint`, mit denselben Feldern wie bei Endpunkte auflisten.

### Statuscodes

- `201`: Der Endpunkt ist angelegt.
- `400 invalid_webhook_endpoint`: Der Body passt nicht: Die Adresse ist keine öffentliche https-Adresse, oder ein Ereignis existiert nicht.

### Anfrage

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

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

## Endpunkt ändern

`PATCH /v1/api/webhooks`

Ändert Adresse, Ereignisse oder Status. Die `id` des Endpunkts steht im Body, nicht im Pfad. Senden Sie nur die Felder, die Sie ändern möchten.

### Body

- `id` (`string`, erforderlich): Die ID des Endpunkts (`weh_…`).
- `url` (`string`, optional): Eine neue Adresse, mit denselben Anforderungen wie beim Anlegen.
- `events` (`string[]`, optional): Eine neue Liste von Ereignissen. Sie ersetzt die alte vollständig.
- `status` (`"active" | "disabled"`, optional): `disabled` stoppt die Zustellungen, ohne den Endpunkt zu löschen.

### Antwortfelder

Der geänderte Endpunkt steht unter `endpoint`.

### Statuscodes

- `200`: Der Endpunkt ist geändert.
- `400 invalid_request`: Der Body passt nicht, meist weil die `id` fehlt.
- `404 webhook_endpoint_not_found`: Der Endpunkt existiert in Ihrem Unternehmen nicht.

### Anfrage

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

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

## Endpunkt löschen

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

Entfernt den Endpunkt endgültig. Möchten Sie nur pausieren, setzen Sie den Status stattdessen auf `disabled`.

### Pfadparameter

- `id` (`string`, erforderlich): Die ID des Endpunkts (`weh_…`).

### Antwortfelder

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

### Statuscodes

- `200`: Der Endpunkt ist gelöscht.
- `404 webhook_endpoint_not_found`: Der Endpunkt existiert in Ihrem Unternehmen nicht.

### Anfrage

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

### Beispielantwort · 200

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

## Die Zustellung

Jedes Ereignis wird als POST mit einem JSON-Body aus vier Feldern gesendet: `id` ist die eigene ID der Zustellung (`whevt_…`), `type` ist das Ereignis, `createdAt` der Zeitpunkt und `data` das, worum es im Ereignis geht. Drei Header kommen mit: `content-type`, `kuvert-event-type` und `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}}
```

Die Signatur im Beispiel ist echt. Mit dem Geheimnis `whsec_docs-example-not-a-real-secret` ergibt das Rezept unter Die Signatur genau dieses `v1`. Lassen Sie Ihre eigene Prüfung darauf laufen, mit abgeschalteter Zeittoleranz, da der Zeitpunkt in der Vergangenheit liegt.

## Ereignisse

> **Hinweis:** Die Liste ist vollständig: 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

Ein Gutschein wird ausgestellt.

Beispielzustellung:

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

Ein Betrag wird von einer Karte abgezogen.

Beispielzustellung:

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

Eine Bestellung wurde bezahlt. Bei einer Ticketbestellung kommen die Antworten des Käufers auf die Fragen des Abends in answers mit, etwa ein Teamname, jeweils mit der Frage so, wie sie beim Beantworten lautete.

Beispielzustellung:

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

Eine Bestellung wird erstattet.

Beispielzustellung:

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

### ticket.issued

Ein Ticket wird von einer bezahlten Bestellung ausgestellt. Die Antworten der Bestellung auf die Fragen des Abends kommen auf jedem Ticket in answers mit.

Beispielzustellung:

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

Der Kartenaussteller des Käufers hat die Zahlung bestritten. Die Gutscheine und Tickets der Bestellung werden unterbrochen und können nicht verwendet werden.

Beispielzustellung:

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

Der Fall ist beendet. `won: true` bedeutet, dass die Unterbrechung aufgehoben ist und die Karten wieder funktionieren; `false` bedeutet, dass sie storniert wurden.

Beispielzustellung:

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

Ein Tisch wurde gebucht, vom Gast auf der Reservierungsseite, am Telefon oder an der Tür.

Beispielzustellung:

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

Eine Tischreservierung hat eine neue Uhrzeit, ein neues Datum, eine neue Personenzahl, neue Tische oder einen neuen Status. Die ganze Buchung wird mitgeschickt, und `status` sagt, wo sie steht.

Beispielzustellung:

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

Eine Tischreservierung wurde storniert, vom Gast oder vom Restaurant. Buchungen, die aus einem anderen System übernommen werden, senden keine Ereignisse.

Beispielzustellung:

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

Eine Veranstaltung wurde in den Verkauf gestellt. Der ganze Abend kommt mit (Name, Zeiten, Ort, Serie, Line-up und Genres), in derselben Form wie die Liste unter GET /v1/merchants/{slug}, mit `eventId` statt `id`.

Beispielzustellung:

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

Etwas Öffentliches an einer veröffentlichten Veranstaltung hat sich geändert. `changedFields` sagt, was, etwa `lineup`, oder `tiers`, wenn sich ein Preis geändert hat.

Beispielzustellung:

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

Eine veröffentlichte Veranstaltung wurde abgesagt. `cancelNote` ist die Nachricht, die Sie an die Gäste geschrieben haben.

Beispielzustellung:

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

Eine veröffentlichte Veranstaltung wurde auf Entwurf zurückgesetzt. Ein Entwurf sendet nie Ereignisse.

Beispielzustellung:

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

> **Hinweis:** totalOre bei order.paid ist der Wert der Karten. Das ist nicht unbedingt der Betrag, der dem Käufer abgebucht wurde: Hinzukommen können die Bearbeitungsgebühr von 5,00 DKK pro Gutschein und pro Ticket (eine Bestellung über drei Karten sind drei Gebühren, und Tickets unter 25,00 DKK haben keine) und die Provision, wenn Sie gewählt haben, dass der Kunde sie zahlt. Zahlen Sie beides selbst, sind die beiden Zahlen gleich. Beides erheben Sie für Kuvert und bekommen es wieder in Rechnung gestellt. Das ist netto null, aber zwei Posten in Ihrer Buchhaltung. Sie finden sie bei der Bestellung im Dashboard und auf Ihrer monatlichen Rechnung.

## Die Signatur

Jede Lieferung trägt einen `kuvert-signature`-Header mit einem Zeitstempel und einem HMAC:

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

Die Signatur ist HMAC-SHA256 in Hex über den String aus Zeitstempel, Punkt und dem rohen Body, berechnet mit dem Geheimnis Ihres Endpunkts:

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

## Signatur prüfen

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

> **Wichtig:** Verifizieren Sie den ROHEN Body, genau so, wie er eingegangen ist. Hat Ihr Server das JSON bereits geparst und den String neu zusammengesetzt, passt die Signatur nicht, weil Schlüsselreihenfolge und Leerzeichen in die Berechnung eingehen.

## Schnell antworten

- Bestätigen Sie mit 2xx, sobald Sie erhalten haben. Legen Sie die schwere Arbeit in eine Warteschlange.
- Die Zustellung erfolgt nach bestem Bemühen. Ein Endpunkt, der nicht erreichbar ist, hält nie einen Kauf beim Unternehmen auf.
- Wir warten höchstens 5 Sekunden auf die Antwort. Alles außer einem 2xx innerhalb dieser Zeit zählt als fehlgeschlagene Zustellung, auch eine Weiterleitung, der wir nicht folgen.

## Wiederholungen

Schlägt eine Zustellung fehl, senden wir sie mit wachsenden Pausen erneut (1 Minute, 5 Minuten, 30 Minuten, 2 Stunden, 6 Stunden, 12 Stunden und 24 Stunden), insgesamt 8 Versuche über knapp zwei Tage. Es ist jedes Mal derselbe Body mit derselben ID, aber mit einer neuen Signatur, damit der Zeitstempel frisch ist. Erkennen Sie an der ID ein Ereignis, das Sie bereits empfangen haben: Kommt Ihre Antwort nicht bei uns an, senden wir es erneut.

Wie die letzte Zustellung verlaufen ist, steht beim Endpunkt: Endpunkte auflisten antwortet mit lastDeliveryAt, lastDeliveryOk, lastDeliveryDetail und consecutiveFailures, und dasselbe steht unter Einstellungen → Webhooks. Wir schalten einen Endpunkt nie von selbst ab. Nur Sie können ihn auf `disabled` setzen, und ein abgeschalteter Endpunkt erhält auch keine Wiederholungen.

> **Wichtig:** Ereignisse können in einer anderen Reihenfolge ankommen, als sie geschehen sind, weil eine Wiederholung nach einem neueren Ereignis eintreffen kann. War Ihr Endpunkt länger als zwei Tage nicht erreichbar, ist verloren, was in dieser Zeit kam; gleichen Sie mit der API ab: Karten auflisten liefert das Guthaben jeder Karte, wie es jetzt steht.

## Anforderungen an die URL

Der Endpunkt muss eine öffentlich erreichbare HTTPS-Adresse sein. Interne Adressen werden bewusst abgelehnt, damit ein Endpunkt nicht benutzt werden kann, um in unser eigenes Netzwerk vorzudringen.
