Getting started
- Go to Inställningar › Webhooks (Settings › Webhooks) and click Lägg till mottagare (Add receiver). You need to be an admin.
- Paste the address. It must start with https://.
- Choose the events the receiver should get and click Lägg till (Add).
- Click Skicka test (Send test) and pick an event. The receiver gets an example with made-up details, and under Leveranser (Deliveries) you see what was sent and what the receiver answered.
A facility can have up to ten receivers. You can pause, change or delete a receiver. Managers see receivers and deliveries but can't change them.
Events
| Event | Type | When |
|---|---|---|
| New booking | booking.created | A customer books online or you book for a customer. |
| Changed booking | booking.changed | Time, duration, participants, products, add-ons, protections, discount or gift card, contact details, staff or status changes. |
| Cancellation | booking.cancelled | The customer or you cancel. |
| Payment received | payment.received | A payment is recorded: card, Swish and Klarna through Stripe, payment link, invoice or manual. |
| Refund | payment.refunded | Money is paid back from Denira or from Stripe, or after a lost dispute. |
| Joined the waitlist | waitlist.joined | A customer queues for a fully booked time. |
booking.changed has a changes field that says what changed: time, duration, participants, products, addons, protections, voucher, customer, staff or status. The status changes when the booking is started, completed or its start is undone. Bookings imported from Bookspot send nothing.
What a request looks like
Each event is a POST request with JSON. data holds the booking, payment or waitlist entry as it was right after the change. For a payment, the booking is read a moment after the payment, so its payment status is up to date. Amounts are integers in öre (1/100 SEK): 90000 is 900 SEK.
POST /your-endpoint HTTP/1.1
content-type: application/json
webhook-id: msg_k57d…
webhook-timestamp: 1780302612
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{
"id": "msg_k57d…",
"type": "booking.created",
"createdAt": "2026-06-01T08:30:12.000Z",
"test": false,
"facility": {
"id": "mh7c…",
"name": "Kajakcentret"
},
"data": {
"booking": {
"id": "sample_booking",
"bookingNumber": "2026060112345",
"status": "confirmed",
"paymentStatus": "paid",
"testBooking": false,
"createdAt": "2026-06-01T08:30:00.000Z",
"cancelledAt": null,
"createdBy": "customer",
"experience": {
"id": "sample_experience",
"name": "Kajaktur",
"nameEn": "Kayak tour"
},
"duration": {
"id": "sample_duration",
"name": "2 timmar",
"nameEn": "2 hours"
},
"startDate": "2026-06-14",
"startTime": "10:00",
"endDate": "2026-06-14",
"endTime": "12:00",
"timeZone": "Europe/Stockholm",
"participantCount": 2,
"participants": [
{
"category": {
"id": "sample_adult",
"name": "Vuxen",
"nameEn": "Adult"
},
"quantity": 2,
"unitPrice": 40000
}
],
"products": [],
"addons": [
{
"id": "sample_addon",
"name": "Fika",
"nameEn": "Coffee and bun",
"quantity": 2,
"unitPrice": 5000
}
],
"staff": [],
"customer": {
"firstName": "Anna",
"lastName": "Andersson",
"email": "anna@example.com",
"phone": "+46701234567",
"comment": "Vi kommer med cykel."
},
"locale": "sv",
"totalAmount": 90000,
"vatAmount": 18000,
"discountAmount": 0,
"discountCodes": [],
"giftCardAmount": 0,
"cancellationProtection": 5000,
"rebookingProtection": null,
"currency": "SEK"
}
}
}Fields in every event
| Field | Contents |
|---|---|
| id | The event's id, the same as the webhook-id header. The same on every retry. |
| type | The event, for example booking.created. |
| createdAt | When it happened, ISO 8601 in UTC. |
| test | true for what Skicka test (Send test) sends. |
| facility | The facility's id and name. |
| data | booking, payment and booking, or waitlistEntry, depending on the event. booking.changed also has changes. |
The booking: booking
| Field | Contents |
|---|---|
| id, bookingNumber | The booking's id and booking number. |
| status | pending, confirmed, started, completed, cancelled or no_show. |
| paymentStatus | unpaid, partially_paid, paid, refunded or partially_refunded. |
| testBooking | true if the booking is marked as a test booking. |
| createdAt, cancelledAt | When it was booked and cancelled (null if not cancelled). |
| createdBy | customer if the customer booked, staff if you did. |
| experience, duration | The experience and the chosen duration, with id, name and nameEn. |
| startDate, startTime, endDate, endTime | Local time in timeZone (Europe/Stockholm). |
| participantCount, participants | Number of participants, and per price category: category, quantity and unitPrice. |
| products, addons | Products and add-ons with id, name, nameEn, quantity and unitPrice. |
| staff | Staff on the booking, with id and name. |
| customer | firstName, lastName, email, phone and comment (at most 2,000 characters). |
| locale | sv or en, the language the customer booked in. |
| totalAmount, vatAmount | Total price and VAT. |
| discountAmount, discountCodes | Discount and the discount codes. |
| giftCardAmount | Amount paid with a gift card. The gift card's code is never sent. |
| cancellationProtection, rebookingProtection | The price of cancellation and rebooking protection, null if not chosen. |
| currency | SEK. |
The payment: payment
| Field | Contents |
|---|---|
| id, amount, currency | The payment's id and amount in öre. A refund is its own row with a positive amount. |
| method | stripe, payment_link, invoice or manual. |
| paidAt | When the payment came in (payment.received). |
| refundedAt, reason, source | When the money was paid back, why, and where: denira, stripe_dashboard or dispute (payment.refunded). |
The waitlist entry: waitlistEntry
| Field | Contents |
|---|---|
| id, joinedAt | The entry's id and when the customer joined. |
| experience, duration | The experience and the duration the customer wants. |
| date, startTime, timeZone | The fully booked time. |
| groupSize | How many the customer wants to book for. |
| staff | The person the customer chose, or null. |
| customer | name, email and phone. The customer leaves an email address, a mobile number or both. |
| locale | sv or en. |
Checking the signature
Requests are signed the Standard Webhooks way (standardwebhooks.com). The secret (whsec_…) is shown and copied under the receiver in Inställningar › Webhooks (Settings › Webhooks). The signature is HMAC-SHA256 of webhook-id, webhook-timestamp and the unchanged body, joined with dots.
// Install: npm install standardwebhooks express
import express from "express";
import { Webhook } from "standardwebhooks";
const webhook = new Webhook(process.env.DENIRA_WEBHOOK_SECRET); // whsec_… from Denira
const app = express();
app.post("/denira", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = webhook.verify(req.body.toString(), req.headers);
} catch {
return res.sendStatus(400);
}
// event is verified. Answer quickly and do the work afterwards.
res.sendStatus(200);
});import crypto from "node:crypto";
function isFromDenira(secret, headers, rawBody) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false;
// Reject requests older than five minutes
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
`v1,${crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64")}`,
);
// Compare in constant time, so the response time gives nothing away
return signatures.split(" ").some((signature) => {
const given = Buffer.from(signature);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}If you change the secret in Denira, the next request is signed with the new one straight away, so change it at the receiver at the same time.
Delivery and retries
- Answer with a 2xx status within 15 seconds. Anything else counts as failed, and redirects are not followed.
- A failed request is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. After eight attempts it is marked failed, and you can send it again from the log.
- The same event can arrive more than once. Use webhook-id to skip duplicates.
- Events can arrive in a different order than they happened. createdAt and the booking's fields show the state.
- A test or a resend is tried once. A facility can make 20 in a row and then one more every three minutes.
- A facility can send 600 events of each type in a row and then ten a minute, and an event larger than 16 kB is not sent. What isn't sent shows as a warning under the receiver.
- Events and deliveries are kept for 30 days.
Recipe: Zapier
- Create a Zap in Zapier. Choose Webhooks by Zapier as the trigger and the event Catch Hook.
- Copy the address Zapier shows (https://hooks.zapier.com/hooks/catch/…).
- Add it as a receiver in Denira and choose events, for example New booking.
- Click Skicka test (Send test) in Denira, then Test trigger in Zapier. The example shows with every field.
- Add the next step, for example a row in Google Sheets or a contact in Mailchimp, and pick the fields, for example Data Booking Customer Email.
- Consider a filter before that step that only lets Test is false through, so tests stay out of the spreadsheet. Then publish the Zap.
Webhooks by Zapier is only in Zapier's paid plans. Zapier doesn't check the signature.
Recipe: Make
- Create a scenario in Make and add the module Webhooks › Custom webhook. Click Add, give it a name and save.
- Copy the address Make shows (for example https://hook.eu1.make.com/…).
- Add it as a receiver in Denira and choose events.
- Make now waits for an example. Click Skicka test (Send test) in Denira and Make learns the fields.
- Add the next module and pick the fields under data, for example data.booking.customer.email.
- Add a filter after the webhook that requires test to be false, and turn the scenario on.
Webhooks are in Make's free plan.