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.

ENTERPRISE plan

Webhooks require the Enterprise plan

Webhook subscriptions are available on the Enterprise plan. The management endpoints below are authenticated with your dashboard session (owner) and gated by your plan. The delivery payloads themselves are signed with a per-subscription secret, not your API key.

How it works

  1. Create a subscription with an HTTPS endpoint URL and the list of events you want.
  2. Salonify returns a signing secret (whsec_...). It is shown in full only at creation time. Store it securely.
  3. When a subscribed event occurs, Salonify sends a POST to your URL with a signed JSON body.
  4. Your endpoint verifies the signature, processes the event, and responds with a 2xx within a few seconds.
  5. 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

POST/businesses/{businessId}/webhooks
ENTERPRISE plan

Body

NameTypeRequiredDescription
urlstring (https)RequiredThe endpoint that will receive event POSTs. Must be a valid http(s) URL.
eventsstring[]RequiredNon-empty, unique list of event types to subscribe to. See the event catalog below.
descriptionstringOptionalOptional human label for the subscription.
Request
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

The full 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

GET/businesses/{businessId}/webhooks
ENTERPRISE plan
[
  {
    "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

GET/businesses/{businessId}/webhooks/events
ENTERPRISE plan

Returns 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

PATCH/businesses/{businessId}/webhooks/{id}
ENTERPRISE plan

Body

NameTypeRequiredDescription
urlstring (https)OptionalNew receiver URL.
eventsstring[]OptionalReplacement list of subscribed events.
enabledbooleanOptionalPause (false) or resume (true) deliveries.
descriptionstringOptionalUpdate the label.
Request
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

DELETE/businesses/{businessId}/webhooks/{id}
ENTERPRISE plan
{
  "ok": true
}

Send a test event

POST/businesses/{businessId}/webhooks/{id}/test
ENTERPRISE plan

Triggers a signed test.ping delivery to the subscription URL so you can verify connectivity and signature handling.

{
  "ok": true
}
Body delivered to your endpoint
{
  "event": "test.ping",
  "payload": {
    "ok": true,
    "ts": "2026-06-22T08:05:00.000Z"
  },
  "deliveredAt": "2026-06-22T08:05:00.000Z"
}

Inspect delivery history

GET/businesses/{businessId}/webhooks/{id}/deliveries
ENTERPRISE plan

Returns 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

NameTypeRequiredDescription
X-Salonify-EventstringRequiredThe event type, for example booking.created.
X-Salonify-DeliverystringRequiredUnique delivery id. Stable across retries of the same delivery.
X-Salonify-SignaturestringRequiredHMAC-SHA256 signature, format t=<unix>,v1=<hex>. Verify before trusting the body.
X-Salonify-AttemptintegerRequiredAttempt number, starting at 1 and increasing on each retry.
Content-TypestringRequiredAlways application/json.
User-AgentstringRequiredSalonify-Webhook/1.0

Body

The body is always the same envelope. The payload shape depends on the event.

Envelope
{
  "event": "booking.created",
  "payload": {
    "id": "bk_8f2a1c",
    "status": "PENDING"
  },
  "deliveredAt": "2026-06-22T08:30:00.000Z"
}

Sign over the raw body

The signature is computed over the exact raw request body bytes. Capture the raw body before any JSON parsing middleware reformats it, otherwise verification will fail.

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.

AttemptDelay after the event
1Immediately
2After 1 minute
3After 5 minutes
4After 30 minutes
5After 2 hours
  • Each delivery is tracked with a status of PENDING, SUCCESS, or FAILED, plus the attempts count and last statusCode.
  • While retries remain, the delivery stays PENDING with a nextAttemptAt timestamp. After the fifth failed attempt it becomes FAILED.
  • 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.

booking.created payload
{
  "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.

booking.confirmed payload
{
  "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.

booking.cancelled payload
{
  "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).

booking.completed payload
{
  "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.

booking.no_show payload
{
  "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.

booking.rescheduled payload
{
  "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.

payment.succeeded payload
{
  "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.

payment.refunded payload
{
  "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.

customer.created payload
{
  "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.

service.created payload
{
  "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.

service.updated payload
{
  "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.

staff.created payload
{
  "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.

review.created payload
{
  "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.

giftcard.redeemed payload
{
  "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.

loyalty.points_changed payload
{
  "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.

invoice.created payload
{
  "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 2xx immediately, 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-Delivery so 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.