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
/ v1/ api/ webhooksThe 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
activereceives deliveries. secretstring- The secret (
whsec_…) the signature is computed with. lastDeliveryAtstring | null- When a delivery was last attempted.
nullif nothing has ever been sent. lastDeliveryOkboolean | null- Whether the latest delivery got a 2xx.
nullmeans never attempted, not failed. lastDeliveryDetailstring | null- Why the latest one failed: an HTTP status or a transport error.
nullwhen 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.
Often used together with
Add an endpoint
/ v1/ api/ webhooksRegisters 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.
400
invalid_webhook_endpointThe 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.
Often used together with
Change an endpoint
/ v1/ api/ webhooksChanges 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"Optionaldisabledstops the deliveries without deleting the endpoint.
Response fields
The changed endpoint is under endpoint.
Status codes
200
The endpoint is changed.
400
invalid_requestThe body does not match, typically because
idis missing.404
webhook_endpoint_not_foundThe 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
/ 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.
404
webhook_endpoint_not_foundThe 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
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.
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.
giftcard.issued
A gift card has been issued.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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.
{
"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": []
}
}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}")# 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- 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.
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.
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?