Create an endpoint
Under Settings → Webhooks you add the URL 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.
| Method | Path | Does |
|---|---|---|
| GET | /v1/api/webhooks | Lists your endpoints |
| POST | /v1/api/webhooks | Creates one and returns the secret |
| PATCH | /v1/api/webhooks | Edits address, events or status |
| DELETE | /v1/api/webhooks/{id} | Removes it again |
curl -X POST https://api.kuvert.dk/v1/api/webhooks \
-H "Authorization: Bearer pk_live_..." \
-H "Content-Type: application/json" \
-d '{"url":"https://dit-system.dk/kuvert","events":["giftcard.redeemed"]}'Events
| Event | Sent when |
|---|---|
| giftcard.issued | A gift card has been issued. |
| giftcard.redeemed | An amount has been withdrawn from a card. |
| order.paid | An order has been paid. |
| order.refunded | An order has been refunded. |
| ticket.issued | A ticket has been issued from a paid order. |
| 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. |
| 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. |
| booking.created | A table has been booked — by the guest on the booking page, on the phone or at the door. |
| 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. |
| booking.cancelled | A table booking has been cancelled, by the guest or by the restaurant. Bookings moved in from another system send no events. |
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, period, the raw body — calculated with your endpoint secret:
HMAC_SHA256(secret, "{t}.{raw body}")Verify in Node
import { createHmac, timingSafeEqual } from 'node:crypto'
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)
}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 from the business.
- Expect to receive the same event twice, and make your handling idempotent.
URL requirements
The endpoint must be a publicly accessible HTTPS address. Internal addresses are rejected — it is a deliberate barrier to prevent an endpoint from being used to reach into our own network.