# Webhooks

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

A webhook lets Kuvert tell your system that something has happened, for example that a card has been issued, an amount deducted, an order paid or refunded, a table booked or cancelled, or an event published, changed or cancelled. Every delivery is signed, so you can prove it came from us.

## Create an endpoint

Under Settings → Webhooks you add the URL the events should be sent to. You get a secret back. It is used to verify the signature and should be stored like a password.

A system can also subscribe to itself with an API key, without anyone opening the dashboard. That is how an integration you have not written yourself hooks in: it gets your key and registers its own address.

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

> **Note:** An empty events list means all events. The address must be https and point to a public host. Internal addresses are rejected.

## List endpoints

`GET /v1/api/webhooks`

The business's endpoints, including those created in the dashboard, each with its secret and the outcome of the latest delivery.

### Response fields

The endpoints are under `endpoints`. Each has these fields:

- `id` (`string`): The endpoint's id (`weh_…`).
- `url` (`string`): The address events are sent to.
- `events` (`string[]`): The events the endpoint subscribes to. An empty list means all.
- `status` (`"active" | "disabled"`): Only `active` receives deliveries.
- `secret` (`string`): The secret (`whsec_…`) the signature is computed with.
- `lastDeliveryAt` (`string | null`): When a delivery was last attempted. `null` if nothing has ever been sent.
- `lastDeliveryOk` (`boolean | null`): Whether the latest delivery got a 2xx. `null` means never attempted, not failed.
- `lastDeliveryDetail` (`string | null`): Why the latest one failed: an HTTP status or a transport error. `null` when it succeeded.
- `consecutiveFailures` (`integer`): Failed deliveries in a row. The first one that succeeds resets the number.
- `createdAt` (`string`): When the endpoint was created.

### Status codes

- `200`: The list, possibly empty.

### Request

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

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

## Add an endpoint

`POST /v1/api/webhooks`

Registers an address and answers with the endpoint and its secret. It receives deliveries straight away.

### Body

- `url` (`string`, required): A public https address, at most 2048 characters. Internal and local addresses are refused.
- `events` (`string[]`, required): The events to send. An empty list means all of them, including those added later.

### Response fields

The endpoint is under `endpoint`, with the same fields as for List endpoints.

### Status codes

- `201`: The endpoint is created.
- `400 invalid_webhook_endpoint`: The body does not match: the address is not a public https address, or an event does not exist.

### Request

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

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

## Change an endpoint

`PATCH /v1/api/webhooks`

Changes the address, the events or the status. The endpoint's `id` goes in the body, not in the path. Send only the fields you want to change.

### Body

- `id` (`string`, required): The endpoint's id (`weh_…`).
- `url` (`string`, optional): A new address, with the same requirements as when creating.
- `events` (`string[]`, optional): A new list of events. It replaces the old one entirely.
- `status` (`"active" | "disabled"`, optional): `disabled` stops the deliveries without deleting the endpoint.

### Response fields

The changed endpoint is under `endpoint`.

### Status codes

- `200`: The endpoint is changed.
- `400 invalid_request`: The body does not match, typically because `id` is missing.
- `404 webhook_endpoint_not_found`: The endpoint does not exist on your business.

### Request

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

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

## Delete an endpoint

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

Removes the endpoint for good. If you only want a pause, set the status to `disabled` instead.

### Path parameters

- `id` (`string`, required): The endpoint's id (`weh_…`).

### Response fields

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

### Status codes

- `200`: The endpoint is deleted.
- `404 webhook_endpoint_not_found`: The endpoint does not exist on your business.

### Request

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

### Example response · 200

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

## The delivery

Each event is sent as a POST with a JSON body of four fields: `id` is the delivery's own id (`whevt_…`), `type` is the event, `createdAt` is the time, and `data` is what the event is about. Three headers come with it: `content-type`, `kuvert-event-type` and `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}}
```

The signature in the example is real. With the secret `whsec_docs-example-not-a-real-secret`, the recipe under The signature gives exactly that `v1`. Run your own verification on it with the time tolerance switched off, as the timestamp is in the past.

## Events

> **Note:** The list is exhaustive: 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

A gift card has been issued.

Example delivery:

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

An amount has been withdrawn from a card.

Example delivery:

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

An order has been paid. On a ticket order, the buyer's answers to the evening's questions are included in answers, for example a team name, each with the question as it was worded when it was answered.

Example delivery:

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

An order has been refunded.

Example delivery:

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

### ticket.issued

A ticket has been issued from a paid order. The order’s answers to the night’s questions come along in answers on every ticket.

Example delivery:

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

The buyer's card issuer has disputed the payment. The order's gift cards and tickets have been paused and cannot be used.

Example delivery:

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

The case has been decided. `won: true` means the pause has been lifted and the cards work again; `false` means they have been cancelled.

Example delivery:

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

A table has been booked by the guest on the booking page, by phone or at the door.

Example delivery:

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

A table booking has a new time, date, party size or tables, or a new status. The whole booking comes with it, and `status` says where it stands.

Example delivery:

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

A table booking has been cancelled, by the guest or by the restaurant. Bookings moved in from another system send no events.

Example delivery:

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

An event has been put on sale. The whole evening is included (name, times, venue, series, line-up and genres) in the same shape as the list on GET /v1/merchants/{slug}, with `eventId` instead of `id`.

Example delivery:

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

Something public on a published event has changed. `changedFields` says what, for example `lineup`, or `tiers` when a price has changed.

Example delivery:

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

A published event has been cancelled. `cancelNote` is the message you wrote to the guests.

Example delivery:

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

A published event has been set back to a draft. A draft never sends events.

Example delivery:

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

> **Note:** totalOre on order.paid is the value of the cards. It is not necessarily the amount the buyer was charged: on top of it there may be the handling fee of DKK 5.00 per gift card and per ticket (an order of three cards is three fees, and tickets under DKK 25.00 carry none) and the commission, if you have chosen that the customer pays it. If you choose to pay them yourself, the two figures are the same. You collect both on Kuvert's behalf and are invoiced for them again. It is net zero, but two entries in your accounts. You find them on the order in the dashboard and on your monthly invoice.

## The signature

Each delivery carries a `kuvert-signature` header with a timestamp and an HMAC:

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

The signature is HMAC-SHA256 in hex over the string timestamp, full stop, raw body, calculated with your endpoint secret:

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

## Verify the signature

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

> **Important:** Verify against the RAW body, exactly as it came in. If your server has already parsed the JSON and rebuilt the string, the signature will not match, because key order and whitespace are part of the calculation.

## Reply quickly

- Acknowledge with 2xx as soon as you have received it. Put the heavy work in a queue.
- Delivery is best effort. An endpoint that is down never holds back a purchase at the business.
- We wait at most 5 seconds for the response. Anything other than a 2xx within that time counts as a failed delivery, including a redirect, which we do not follow.

## Retries

If a delivery fails, we send it again with growing pauses (1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours), 8 attempts in all over almost two days. It is the same body with the same id every time, but with a new signature, so the timestamp is fresh. Use the id to recognise an event you have already received: if your response does not reach us, we send it again.

How the latest delivery went is shown on the endpoint: List endpoints answers with lastDeliveryAt, lastDeliveryOk, lastDeliveryDetail and consecutiveFailures, and the same is shown under Settings → Webhooks. We never switch an endpoint off on our own. Only you can set it to `disabled`, and an endpoint that is switched off gets no retries either.

> **Important:** Events can arrive in a different order from the one they happened in, because a retry can land after a newer event. If your endpoint has been down for more than two days, whatever came in the meantime is gone; reconcile against the API: List cards gives every card's balance as it stands now.

## URL requirements

The endpoint must be a publicly accessible HTTPS address. Internal addresses are rejected on purpose, so that an endpoint cannot be used to reach into our own network.
