Skip to content

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.

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.

curl https://api.kuvert.dk/v1/api/webhooks \
  -H "Authorization: Bearer $KUVERT_API_KEY"
200 OKJSON
{
  "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"
    }
  ]
}

Response fields

The endpoints are under endpoints. Each has these fields:

idstring
The endpoint's id (weh_…).
urlstring
The address events are sent to.
eventsstring[]
The events the endpoint subscribes to. An empty list means all.
status"active" | "disabled"
Only active receives deliveries.
secretstring
The secret (whsec_…) the signature is computed with.
lastDeliveryAtstring | null
When a delivery was last attempted. null if nothing has ever been sent.
lastDeliveryOkboolean | null
Whether the latest delivery got a 2xx. null means never attempted, not failed.
lastDeliveryDetailstring | null
Why the latest one failed: an HTTP status or a transport error. null when it succeeded.
consecutiveFailuresinteger
Failed deliveries in a row. The first one that succeeds resets the number.
createdAtstring
When the endpoint was created.

Status codes

  • 200

    The list, possibly empty.

An error always has the shape { "error": "…" }. Any call can also answer 401, 403, 429 and 500. See Errors.

Add an endpoint

POST/v1/api/webhooks

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

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"
    ]
  }'
201 CreatedJSON
{
  "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"
  }
}

Body

urlstringRequired
A public https address, at most 2048 characters. Internal and local addresses are refused.
eventsstring[]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.

  • 400invalid_webhook_endpoint

    The body does not match: the address is not a public https address, or an event does not exist.

An error always has the shape { "error": "…" }. Any call can also answer 401, 403, 429 and 500. See Errors.

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.

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"
  }'
200 OKJSON
{
  "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"
  }
}

Body

idstringRequired
The endpoint's id (weh_…).
urlstringOptional
A new address, with the same requirements as when creating.
eventsstring[]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.

  • 400invalid_request

    The body does not match, typically because id is missing.

  • 404webhook_endpoint_not_found

    The endpoint does not exist on your business.

An error always has the shape { "error": "…" }. Any call can also answer 401, 403, 429 and 500. See Errors.

Often used together with

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.

curl -X DELETE https://api.kuvert.dk/v1/api/webhooks/weh_5e8a1c3f7b9d42e6a0c4f8b2d6e1a3c9 \
  -H "Authorization: Bearer $KUVERT_API_KEY"
200 OKJSON
{
  "deleted": true
}

Path parameters

idstringRequired
The endpoint's id (weh_…).

Response fields

deletedboolean
Always true.

Status codes

  • 200

    The endpoint is deleted.

  • 404webhook_endpoint_not_found

    The endpoint does not exist on your business.

An error always has the shape { "error": "…" }. Any call can also answer 401, 403, 429 and 500. See Errors.

Often used together with

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.

A whole deliveryHTTP
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

giftcard.issued

A gift card has been issued.

Example deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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 deliveryJSON
{
  "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": []
  }
}

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

# 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

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.

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.

Was this page helpful?