Tools
Webhooks
Webhooks push real-time events to your server the moment something happens in Salonify, so you do not have to poll. Subscribe to the events you care about, receive a signed HTTPS POST for each, verify the signature, and react.
Webhooks require the Enterprise plan
How it works
- Create a subscription with an HTTPS endpoint URL and the list of events you want.
- Salonify returns a signing secret (
whsec_...). It is shown in full only at creation time. Store it securely. - When a subscribed event occurs, Salonify sends a
POSTto your URL with a signed JSON body. - Your endpoint verifies the signature, processes the event, and responds with a
2xxwithin a few seconds. - If you do not respond
2xx, Salonify retries with exponential backoff.
Managing subscriptions
Manage webhook subscriptions through these endpoints, scoped to your business.
Create a subscription
/businesses/{businessId}/webhooksBody
curl -X POST "https://api.salonify.eu/api/v1/businesses/biz_123/webhooks" \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/salonify",
"events": ["booking.created", "booking.cancelled", "payment.succeeded"],
"description": "Production receiver"
}'{
"id": "wh_1a2b3c",
"businessId": "biz_123",
"url": "https://example.com/hooks/salonify",
"events": [
"booking.created",
"booking.cancelled",
"payment.succeeded"
],
"enabled": true,
"description": "Production receiver",
"secret": "whsec_0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b",
"createdAt": "2026-06-22T08:00:00.000Z"
}Save the secret now
whsec_... secret is returned only in the create response. Subsequent reads return a masked value (for example whsec_0a1b…8a9b). If you lose it, delete the subscription and create a new one.List subscriptions
/businesses/{businessId}/webhooks[
{
"id": "wh_1a2b3c",
"url": "https://example.com/hooks/salonify",
"events": [
"booking.created",
"payment.succeeded"
],
"enabled": true,
"secret": "whsec_0a1b…8a9b",
"createdAt": "2026-06-22T08:00:00.000Z"
}
]List available event types
/businesses/{businessId}/webhooks/eventsReturns the full catalog of event names you may subscribe to.
[
"booking.created",
"booking.confirmed",
"booking.cancelled",
"booking.completed",
"booking.no_show",
"booking.rescheduled",
"payment.succeeded",
"payment.refunded",
"customer.created",
"service.created",
"service.updated",
"staff.created",
"review.created",
"giftcard.redeemed",
"loyalty.points_changed",
"invoice.created"
]Update a subscription
/businesses/{businessId}/webhooks/{id}Body
curl -X PATCH "https://api.salonify.eu/api/v1/businesses/biz_123/webhooks/wh_1a2b3c" \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{ "enabled": false }'Delete a subscription
/businesses/{businessId}/webhooks/{id}{
"ok": true
}Send a test event
/businesses/{businessId}/webhooks/{id}/testTriggers a signed test.ping delivery to the subscription URL so you can verify connectivity and signature handling.
{
"ok": true
}{
"event": "test.ping",
"payload": {
"ok": true,
"ts": "2026-06-22T08:05:00.000Z"
},
"deliveredAt": "2026-06-22T08:05:00.000Z"
}Inspect delivery history
/businesses/{businessId}/webhooks/{id}/deliveriesReturns the most recent deliveries (up to 50, newest first) including status, attempt count, HTTP status code, and the next scheduled retry. Use it to debug failing endpoints.
[
{
"id": "whd_aaa",
"subscriptionId": "wh_1a2b3c",
"eventType": "booking.created",
"status": "SUCCESS",
"attempts": 1,
"statusCode": 200,
"response": "ok",
"nextAttemptAt": null,
"deliveredAt": "2026-06-22T08:30:01.000Z",
"createdAt": "2026-06-22T08:30:00.000Z"
},
{
"id": "whd_bbb",
"subscriptionId": "wh_1a2b3c",
"eventType": "payment.succeeded",
"status": "PENDING",
"attempts": 2,
"statusCode": 500,
"response": "Internal Server Error",
"nextAttemptAt": "2026-06-22T08:36:00.000Z",
"deliveredAt": null,
"createdAt": "2026-06-22T08:30:00.000Z"
}
]Delivery format
Every delivery is an HTTP POST with a JSON body and a fixed set of headers.
Headers
Request headers Salonify sends
Body
The body is always the same envelope. The payload shape depends on the event.
{
"event": "booking.created",
"payload": {
"id": "bk_8f2a1c",
"status": "PENDING"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}Sign over the raw body
Verifying signatures
The X-Salonify-Signature header looks like t=1750000000,v1=abc123.... Recompute the HMAC-SHA256 of the raw body using your subscription secret, compare it to v1 in constant time, and reject deliveries whose timestamp is too old to limit replay attacks.
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.SALONIFY_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300; // 5 minutes
function verify(rawBody, header, secret) {
// header: "t=<unix>,v1=<hex>"
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("="))
);
const timestamp = Number(parts.t);
const signature = parts.v1;
if (!timestamp || !signature) return false;
// Reject stale deliveries (replay protection).
const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
if (age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signature, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// IMPORTANT: use the raw body, not a parsed object.
app.post(
"/hooks/salonify",
express.raw({ type: "application/json" }),
(req, res) => {
const header = req.get("X-Salonify-Signature") || "";
if (!verify(req.body, header, SECRET)) {
return res.status(400).send("invalid signature");
}
const { event, payload } = JSON.parse(req.body.toString("utf8"));
// Respond fast, then process asynchronously.
res.status(200).send("ok");
handleEvent(event, payload, req.get("X-Salonify-Delivery"));
}
);
function handleEvent(event, payload, deliveryId) {
// Idempotent handling keyed by deliveryId.
console.log("received", event, deliveryId);
}Retries & delivery status
If your endpoint does not return a 2xx status (or the request times out after 8 seconds), Salonify retries up to 5 attempts total with exponential backoff. The X-Salonify-Attempt header tells you which attempt you are receiving.
- Each delivery is tracked with a
statusofPENDING,SUCCESS, orFAILED, plus theattemptscount and laststatusCode. - While retries remain, the delivery stays
PENDINGwith anextAttemptAttimestamp. After the fifth failed attempt it becomesFAILED. - Disabling or deleting a subscription stops any pending retries for it.
- Inspect everything via the deliveries endpoint above.
Event catalog
Subscribe to any combination of these events. The payload shown is the object delivered inside the envelope's payload field.
booking.created
A new booking was created.
{
"event": "booking.created",
"payload": {
"id": "bk_8f2a1c",
"status": "PENDING",
"businessId": "biz_123",
"serviceIds": [
"svc_abc"
],
"staffId": "stf_123",
"customerId": "cus_456",
"date": "2026-07-01",
"startTime": "10:00",
"endTime": "10:45",
"total": 4500,
"currency": "EUR",
"createdAt": "2026-06-22T08:30:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}booking.confirmed
A booking moved to confirmed.
{
"event": "booking.confirmed",
"payload": {
"id": "bk_8f2a1c",
"status": "CONFIRMED",
"businessId": "biz_123",
"customerId": "cus_456",
"confirmedAt": "2026-06-22T08:31:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}booking.cancelled
A booking was cancelled by the customer or business.
{
"event": "booking.cancelled",
"payload": {
"id": "bk_8f2a1c",
"status": "CANCELLED",
"businessId": "biz_123",
"customerId": "cus_456",
"cancelledBy": "customer",
"cancelledAt": "2026-06-22T09:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}booking.completed
A booking was marked completed (the visit happened).
{
"event": "booking.completed",
"payload": {
"id": "bk_8f2a1c",
"status": "COMPLETED",
"businessId": "biz_123",
"customerId": "cus_456",
"completedAt": "2026-07-01T10:50:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}booking.no_show
The customer did not show up for a booking.
{
"event": "booking.no_show",
"payload": {
"id": "bk_8f2a1c",
"status": "NO_SHOW",
"businessId": "biz_123",
"customerId": "cus_456",
"markedAt": "2026-07-01T10:15:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}booking.rescheduled
A booking was moved to a new date or time.
{
"event": "booking.rescheduled",
"payload": {
"id": "bk_8f2a1c",
"status": "CONFIRMED",
"businessId": "biz_123",
"customerId": "cus_456",
"previous": {
"date": "2026-07-01",
"startTime": "10:00"
},
"date": "2026-07-03",
"startTime": "14:00",
"endTime": "14:45",
"rescheduledAt": "2026-06-23T11:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}payment.succeeded
A payment was captured successfully.
{
"event": "payment.succeeded",
"payload": {
"id": "pay_9b1d",
"businessId": "biz_123",
"bookingId": "bk_8f2a1c",
"amount": 4500,
"currency": "EUR",
"method": "card",
"status": "SUCCEEDED",
"createdAt": "2026-07-01T10:50:05.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}payment.refunded
A payment was fully or partially refunded.
{
"event": "payment.refunded",
"payload": {
"id": "pay_9b1d",
"businessId": "biz_123",
"bookingId": "bk_8f2a1c",
"amount": 4500,
"refundedAmount": 4500,
"currency": "EUR",
"status": "REFUNDED",
"refundedAt": "2026-07-02T09:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}customer.created
A new customer record was created.
{
"event": "customer.created",
"payload": {
"id": "cus_456",
"businessId": "biz_123",
"name": "Jordan Lee",
"email": "[email protected]",
"phone": "+352621000000",
"createdAt": "2026-06-22T08:29:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}service.created
A new service was added to the catalog.
{
"event": "service.created",
"payload": {
"id": "svc_abc",
"businessId": "biz_123",
"name": "Haircut",
"durationMinutes": 45,
"price": 4500,
"currency": "EUR",
"createdAt": "2026-06-20T12:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}service.updated
An existing service was changed.
{
"event": "service.updated",
"payload": {
"id": "svc_abc",
"businessId": "biz_123",
"name": "Haircut & Style",
"durationMinutes": 60,
"price": 5500,
"currency": "EUR",
"updatedAt": "2026-06-21T15:30:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}staff.created
A new staff member was added.
{
"event": "staff.created",
"payload": {
"id": "stf_123",
"businessId": "biz_123",
"name": "Alex Morgan",
"role": "Stylist",
"createdAt": "2026-06-18T09:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}review.created
A customer left a review.
{
"event": "review.created",
"payload": {
"id": "rev_77",
"businessId": "biz_123",
"bookingId": "bk_8f2a1c",
"customerId": "cus_456",
"rating": 5,
"comment": "Fantastic service.",
"createdAt": "2026-07-01T18:00:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}giftcard.redeemed
A gift card balance was redeemed.
{
"event": "giftcard.redeemed",
"payload": {
"id": "gc_321",
"businessId": "biz_123",
"code": "GIFT-XXXX",
"amountRedeemed": 2000,
"remainingBalance": 3000,
"currency": "EUR",
"redeemedAt": "2026-07-01T10:50:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}loyalty.points_changed
A customer's loyalty points or stamps changed.
{
"event": "loyalty.points_changed",
"payload": {
"businessId": "biz_123",
"customerId": "cus_456",
"programId": "loy_1",
"mode": "POINTS",
"delta": 45,
"balance": 320,
"reason": "booking.completed",
"changedAt": "2026-07-01T10:51:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}invoice.created
An accounting invoice was generated.
{
"event": "invoice.created",
"payload": {
"id": "inv_555",
"businessId": "biz_123",
"number": "2026-000123",
"customerId": "cus_456",
"total": 4500,
"currency": "EUR",
"status": "ISSUED",
"createdAt": "2026-07-01T10:52:00.000Z"
},
"deliveredAt": "2026-06-22T08:30:00.000Z"
}Best practices
- Respond fast. Acknowledge with
2xximmediately, then do real work in a queue or background job. Slow handlers risk the 8 second timeout and trigger retries. - Always verify the signature before trusting a payload, and reject deliveries with a stale timestamp.
- Be idempotent. Retries can deliver the same event more than once. De-duplicate on
X-Salonify-Deliveryso reprocessing is a no-op. - Use HTTPS and keep your
whsec_...secret server-side only. - Send a test event after any change to your receiver, and watch the deliveries endpoint to confirm a
SUCCESS.
Need to know which key scopes power your integration? See Authentication, or browse resources in the API Explorer.