Webhooks
Webhooks let Reservly push real-time notifications to your app when events happen — new bookings, payments, cancellations, etc. Instead of polling our API, your server receives an HTTP POST automatically.
Setting Up Webhooks
Configure webhooks from your dashboard in Settings → Integrations → Webhooks.
- Enter your endpoint URL. It must use HTTPS.
- Select the events you want to receive.
- Click Add Webhook. The signing secret is shown once, immediately after — copy it before you navigate away.
After adding a webhook, use the paper-plane Test webhook button on its row to send a sample payload and verify your endpoint is reachable.
A webhook with no signing secret is not delivered at all. Reservly fails closed rather than sending an unsigned payload, and records the refusal in that webhook's delivery history. If events are not arriving and the history shows no signing secret, generate one from the same tab.
Event Types
Subscribe to any combination of the following events:
| Event | Description |
|---|---|
booking.created | A new booking is made — a service, a rental or an event ticket, from the public page, the widget, the API, an AI assistant or the dashboard. The payload says which mode in booking_type. |
booking.confirmed | A booking is confirmed by the business owner |
booking.cancelled | A single booking is cancelled — by the business from the dashboard, or by the customer from their own manage link. |
booking.rescheduled | A booking's date or time is changed — by the business from the dashboard, or by the customer from their manage link |
bookings.bulk_cancelled | A bulk or cascade cancellation ran (multi-select cancel, closing a date range, cancelling or deleting an event, service, rental, class or resource), or an automatic platform sweep cancelled bookings — today, expiring checkouts that were never paid. One event per operation per business — never one per booking — carrying the affected booking ids (first 100, with total_count holding the real number), the reason, a scope string naming which bulk path fired, and an initiator of "user" (someone in the dashboard did this) or "system" (an automatic sweep did, with nobody at the keyboard). No customer details are included — look bookings up by id if you need them. The per-booking booking.cancelled event still fires for single cancellations only. |
payment.received | A payment is successfully completed |
payment.refunded | A refund is processed |
customer.created | A new customer profile is created |
Scope of booking.rescheduled: it fires for service and rental bookings moved from the dashboard or by the customer. Moving a whole event to a new date or time shifts all of its attendee bookings at once; those attendees are notified by email broadcast and no per-booking webhook is emitted for them. If you need to react to an event moving, reconcile from the event rather than waiting on per-attendee deliveries.
Scope of booking.created and booking.cancelled: every service, rental and event-ticket booking a customer or the business makes is announced, and so is every single cancellation the business or the customer makes. Two things are not: names the business adds to an event's guest list from the dashboard (a batch of free entries, not a sale), and a pending booking the platform expires automatically because it was never confirmed in time. Bulk operations send bookings.bulk_cancelled instead, as described above.
Payload Format
Every webhook delivery is a POST request with a JSON body. The top-level structure includes the event type, a timestamp, your business identity, and the event-specific data.
Booking event example
{
"event": "booking.created",
"timestamp": "2026-04-15T10:30:00.000Z",
"business": {
"id": "uuid",
"slug": "luxe-salon"
},
"data": {
"booking_id": "uuid",
"booking_type": "service",
"date": "2026-04-15",
"start_time": "10:00",
"end_time": "11:00",
"status": "pending",
"customer_name": "Jane Smith",
"customer_email": "jane@example.com",
"customer_phone": "+15551234567",
"services": [
{
"name": "Haircut",
"duration_minutes": 60,
"price": 45
}
]
}
}jsonThe three modes share this one shape, so a single receiver reads them all. booking_type is "service", "rental" or "event", and services holds what was booked in each case:
- Service — one line per service, with its catalogue price.
- Rental — one line per item in the cart, priced at the line total.
duration_minutesis the hired window for an hourly item and the whole hire (the number of days times 1440) for a day-based one. A multi-day hire also carriesend_date; when it is absent the hire is a single day. - Event — one line, the event itself, with the length of the event and the ticket total that was stored on the booking.
One shape for every booking event
booking.created, booking.confirmed, booking.rescheduled and booking.cancelled all carry the same keys in the same formats, so one parser reads all four:
- Always present:
booking_id,booking_type,date,start_time,end_time,status,customer_name,customer_email,customer_phone(nullwhen the customer gave none) andservices. start_timeandend_timeare alwaysHH:MMon a 24-hour clock — never with seconds.- Present only sometimes:
end_date(a multi-day rental, on any of the four events) andcancellation_reason(booking.cancelledonly;nullwhen no reason was given). - On
booking.created,servicesholds the lines described above for every mode. On the other three events it holds the booking's service lines, so for an event or rental booking it is an empty list — usebooking_idandbooking_typeto match it to the booking you stored.
Changed in October 2026: booking.cancelled used to send start_time and end_time with seconds ("13:00:00") and had no customer_phone or services; booking.confirmed and booking.rescheduled had no booking_type. A receiver that read the seconds now receives "13:00".
customer.created carries customer_id, email, phone and business_id, and fires only the first time an email address books with you.
Payment event example
{
"event": "payment.received",
"timestamp": "2026-04-15T10:31:00.000Z",
"business": {
"id": "uuid",
"slug": "luxe-salon"
},
"data": {
"booking_id": "uuid",
"amount_cents": 4500,
"currency": "USD",
"provider": "stripe",
"status": "paid",
"customer_name": "Jane Smith",
"customer_email": "jane@example.com"
}
}jsonHeaders
Every webhook request includes the following headers — the first attempt, every retry, and the Test webhook button in Settings → Integrations alike. The test carries the event name test, the same business block, and is written to your delivery history like any other delivery.
| Header | Description |
|---|---|
X-Reservly-Signature | HMAC-SHA256 signature of the payload |
X-Reservly-Event | The event type (e.g., booking.created) |
X-Reservly-Timestamp | ISO 8601 time the event was created — the same value as the payload's timestamp, unchanged on every retry. It is not covered by the signature, so to judge how old a delivery is, read timestamp from the verified body. |
Content-Type | application/json |
User-Agent | Reservly-Webhooks/1.0 |
Signature Verification
To verify that a webhook was genuinely sent by Reservly, compute an HMAC-SHA256 hash of the raw request body using that webhook's signing secret, hex-encode the digest, and compare it to the X-Reservly-Signature header.
Each webhook has its own signing secret — 32 random bytes, hex-encoded — generated when you create the webhook in Settings → Integrations. It is displayed once, at that moment: copy it then and store it with your other server-side secrets. If you lose it, open the same tab and use Rotate signing secret on that webhook to mint a replacement; the previous secret stops verifying immediately, so update your endpoint at the same time. Your API keys are unrelated to webhook signatures — they authenticate requests you make to Reservly, not deliveries Reservly makes to you.
Sign the exact bytes you received, before any JSON parsing. Re-serialising a parsed body often reproduces the same bytes and so appears to work — but it is not guaranteed to, and any single byte of difference makes the signature fail. Verifying against the raw body removes that whole class of intermittent failure.
const crypto = require('crypto');
function verifyWebhook(rawBody, signature, secret) {
// rawBody is a Buffer of the exact bytes received — never a parsed object.
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest();
const received = Buffer.from(String(signature || ''), 'hex');
// timingSafeEqual throws when the lengths differ, so check length first —
// otherwise a short or malformed header crashes the handler instead of
// being rejected.
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(received, expected);
}
// In your Express handler — express.raw, NOT express.json, so the
// original bytes survive to the verification step:
app.post(
'/webhook',
express.raw({ type: 'application/json' }),
(req, res) => {
const isValid = verifyWebhook(
req.body, // Buffer of the raw bytes
req.headers['x-reservly-signature'],
process.env.RESERVLY_WEBHOOK_SECRET
);
if (!isValid) return res.status(401).send('Invalid signature');
const event = JSON.parse(req.body.toString('utf8'));
// Process the event...
res.status(200).send('OK');
}
);javascriptRetry Behavior
If your endpoint does not respond with a 2xx status code, Reservly retries delivery up to 3 times with exponential backoff:
- 1st retry — 1 second after the initial attempt
- 2nd retry — 5 seconds after the 1st retry
- 3rd retry — 25 seconds after the 2nd retry
Each attempt has a 15-second timeout.
Retries run in the background after the booking has been confirmed to the customer, and they do not depend on any further traffic reaching Reservly. A hostname that cannot be resolved is treated as a network failure and retried on the same schedule; only an address that is not publicly routable is refused outright. Your delivery history shows the first attempt as soon as it has been made and is updated after each retry, so a delivery that is still being retried reads retry scheduled rather than nothing at all.
Only 5xx responses and network failures trigger retries. Every other outcome is treated as permanent and is not retried — that includes 4xx and also 3xx, because redirects are never followed. In particular, an endpoint behind SSO or an authentication proxy answers 401 or 403, which means the event is attempted once and dropped: point webhooks at a genuinely public endpoint. You can view the full delivery history in Settings → Integrations.
Best Practices
- Respond with 200 quickly. Acknowledge the webhook immediately and process the event asynchronously (e.g., via a queue). Long-running handlers risk hitting the 15-second timeout.
- Verify signatures on every request. Always check the
X-Reservly-Signatureheader to confirm the payload was not tampered with. - Handle duplicate deliveries. Network issues can cause the same event to be delivered more than once. A retry carries byte-for-byte the same body as the attempt before it, so the
event,timestampand the booking or customer id together identify a delivery — use them to implement idempotency in your handler. - Monitor delivery history. Check Settings → Integrations for failed deliveries and address any endpoint issues promptly.