Skip to main content

Webhooks: events sent to your integration

A webhook lets an outside system, such as a CRM or your own app, learn about a change in Reservation.Studio the moment it happens, instead of asking for it over and over. When an event occurs, such as a new appointment, a changed time or a new client, Reservation.Studio sends a short message to an address you have set.

This page is for two audiences
  • Owner or manager: the sections from "Before you start" to "When an address is switched off" describe what you do in the Business app.
  • Integration developer: from "For the developer: what the server receives" onward are the headers, the body, the signature and the retries.

Before you start​

  • The subscription must include "External Connectivity". If the subscription stops including it, no events are sent.
  • The employee who sets up addresses has the right permissions: "View webhook addresses", "Create webhook addresses", "Edit, test and resend webhook addresses" and "Delete webhook addresses".
  • You need an address that the integration's developer has given you. It starts with https:// and is reachable from the internet.

How to add an address​

  1. Open Integrations.
  2. In the "Webhooks" card, select "Settings".
  3. Select "New webhook address".
  4. Fill in the fields (see below) and select at least one event.
  5. Select "Create".
  6. Copy the secret from the "Webhook secret" window and hand it to the integration's developer. The integration uses it to check that events come from Reservation.Studio.

After you save, the address appears in the list and starts receiving the events you chose.

The secret is shown only once

After you close the window you cannot view the secret again. Anyone who has it can send fake events to your server, so do not share it with anyone. If you lose it, generate a new one (see "What you do from the row menu").

What each field means​

  • "Name" is your own description of the address, for example the name of the integration.
  • "Address (URL)" is the integration's server that receives the events. Only https:// addresses reachable from the internet are accepted. An address that points to an internal network is refused.
  • "Email for notifications" is the email that gets a notice when the address is switched off because the server keeps failing. It is usually the integration developer's. If the field is empty, the notice goes to the employee who created the address.
  • "Active" switches the address on or off. A switched-off address neither receives nor keeps events. Switching it back on does not send what was missed.
  • "Events" are the events you want to receive. The "Select all" button ticks every event in a group and "Unselect all" clears them. At least one event is required.

Which events are sent​

GroupEventWhen it is sent
Appointments"Appointment created" (appointment.created)A new appointment or class, from any channel.
Appointments"Appointment changed" (appointment.updated)New time, location, services or employees.
Appointments"Appointment deleted" (appointment.deleted)The appointment was removed from the calendar.
Appointments"Appointment restored" (appointment.restored)A deleted appointment is back in the calendar.
Participants"Client joined" (appointment-client.created)A client booked into an existing appointment or class.
Participants"Client status changed" (appointment-client.status-changed)Confirmed, cancelled, no-show or completed.
Participants"Client removed" (appointment-client.deleted)A client was taken off an appointment.
Clients"Client created" (client.created)A new client profile.
Clients"Client changed" (client.updated)Names of the changed fields are sent, never their values.

Events tell you what happened and to which record; they do not carry all the data. The integration reads the full record from the API with an API key that has the permissions you gave it.

What is not sent
  • A bulk import of appointments and clients sends no events.
  • Merging two clients sends no events. If your integration syncs clients, check it against the client list from time to time.
  • An appointment that already has its client when it is created sends only "Appointment created", without a separate "Client joined".
  • A deleted appointment sends no events for its participants.

What you see in the list​

The list shows, for each address, its "Name" and address, the number of "Events", the "Status" and the "Last successful delivery" (or "Never").

The status is one of:

  • "Active": the address receives its events.
  • "Turned off": you switched it off with the "Active" switch.
  • "Failing since": the server does not accept the events and we are still trying. You see since when.
  • "Switched off: failing for 3 days": the address kept failing for 3 days and was switched off automatically.
  • "Switched off: the server answered 410 Gone": the server answered that the address no longer exists, and it was switched off at once.

What you do from the row menu​

The row menu has four actions:

  • "Edit" opens the address form. To switch a stopped address back on, turn "Active" on and save.
  • "Send test event" sends a test event to the saved address and shows in a message what the server answered, for example "The server answered 200 in 142 ms." or why it did not arrive. A test does not count toward the automatic switch-off.
  • "Generate new secret" creates a new secret. The current one stops working at once, so the integration's server needs the new one before it can accept events. The new secret is shown once, as when you create an address.
  • "Delete" removes the address. A deleted address cannot be restored: to get it back, create a new one.

Sending history​

The "Sending history" button at the top of the screen opens the report of deliveries, narrowed to Webhooks. Each delivery is a row, and selecting a row opens "Sending details": "HTTP response", "Attempts", "Next attempt" and "Problem". Test events are marked "Test".

When an event did not reach the server, the details show a "Send again" button. It is available to employees with the "Edit, test and resend webhook addresses" permission. After sending you see the message "The event is on its way again."

When an address is switched off​

  • At once, when the server answers 410 Gone.
  • After 3 days, if every delivery to it fails. The status becomes "Switched off: failing for 3 days" and the "Email for notifications" gets a message.

While an address is switched off, events for it are not kept. When you switch it back on, what was missed is not sent. The integration catches up with requests to the API, for example by reading the appointments for a period (see below).

For the developer: what the server receives​

This part is for the integration developer. The signature follows the Standard Webhooks specification, so most languages have ready-made libraries.

For every event we send a POST with a JSON body and these headers:

POST https://integration-address
Content-Type: application/json
User-Agent: ReservationStudio-Webhooks/1
webhook-id: evt_01JB2K8Z3M4N5P6Q7R8S9T0V1W
webhook-timestamp: 1791882842
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
HeaderContent
webhook-idThe event's identifier. It is the same on every retry, so use it to drop duplicates.
webhook-timestampUnix time in seconds of this attempt.
webhook-signatureThe signature: v1, and a base64 value.

The body has the same envelope for every event:

{
"id": "evt_01JB2K8Z3M4N5P6Q7R8S9T0V1W",
"type": "appointment.updated",
"timestamp": "2026-10-13T09:14:02+00:00",
"business_id": "812",
"actor": { "type": "employee", "id": "4410" },
"data": {
"type": "appointments",
"id": "55120",
"location_id": "1904",
"start_at": "2026-10-15 14:00:00",
"end_at": "2026-10-15 15:00:00",
"changed": ["time"]
}
}
FieldWhat it means
idThe same as webhook-id.
typeThe event type, see the table above.
timestampWhen the change happened. webhook-timestamp is when the delivery was attempted.
business_idThe business where the event happened.
actorWho made the change. actor.type is employee, client, api-key or system. actor.id is the identifier of the employee or client in the business, or of the API key. It is null for system, and for an employee or client without a profile in the business, for example support staff.
dataShort data about the event, see below.

Using actor, an integration that writes through an API key can recognize its own changes and avoid a loop. The order of the fields in the body is not guaranteed.

The data of each event​

The body is short: it carries the type of the record, an identifier and a few facts. start_at and end_at are wall-clock time in the business's time zone. All identifiers are strings. Read the full record with a GET:

  • appointment: GET /v2/appointments/{data.id};
  • client: GET /v2/clients/{data.id};
  • a participant (appointment-clients) has no GET of its own; read it through its appointment: GET /v2/appointments/{data.appointment_id}?include=clients.

Send these requests with an API key that has the permissions for the resource.

appointment.created and appointment.restored

{
"type": "appointments",
"id": "55120",
"location_id": "1904",
"start_at": "2026-10-15 14:00:00",
"end_at": "2026-10-15 15:00:00"
}

start_at and end_at are null when the appointment has no time.

appointment.updated

{
"type": "appointments",
"id": "55120",
"location_id": "1904",
"start_at": "2026-10-15 14:00:00",
"end_at": "2026-10-15 15:00:00",
"changed": ["time"]
}

changed is a list of time, location and services. A change of employees is reported as services.

appointment.deleted

{
"type": "appointments",
"id": "55120",
"location_id": "1904"
}

appointment-client.created

{
"type": "appointment-clients",
"id": "90417",
"appointment_id": "55120",
"client_id": "7730",
"status_id": "3"
}

status_id is the identifier of a status from GET /v2/appointment-statuses.

appointment-client.status-changed

{
"type": "appointment-clients",
"id": "90417",
"appointment_id": "55120",
"client_id": "7730",
"status_id": "5",
"previous_status_id": "3"
}

appointment-client.deleted

{
"type": "appointment-clients",
"id": "90417",
"appointment_id": "55120",
"client_id": "7730"
}

client.created

{
"type": "clients",
"id": "7730"
}

client.updated

{
"type": "clients",
"id": "7730",
"changed": ["city", "phoneCountry"]
}

changed holds the names of the fields that changed, as they appear in GET /v2/clients/{id}, never their values. Group membership is not reported.

webhook.test

{
"endpoint_id": "26"
}

This is the test event from "Send test event".

Verifying the signature​

The signature is an HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{body}. The key is the base64-decoded bytes of the secret after the whsec_ prefix. Verify the raw body before you parse it as JSON.

Node.js with the standardwebhooks library:

import { Webhook } from 'standardwebhooks';

const payload = new Webhook(process.env.WEBHOOK_SECRET).verify(rawBody, request.headers);

PHP without a library:

$key = base64_decode(substr($secret, strlen('whsec_')));
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "$id.$timestamp.$rawBody", $key, true));
$valid = abs(time() - (int) $timestamp) <= 300
&& in_array(true, array_map(fn ($signature) => hash_equals($expected, $signature), explode(' ', $signatureHeader)), true);

Refuse requests whose webhook-timestamp is more than 5 minutes from the current time.

Response, retries and guarantees​

  • Any 2xx response within 15 seconds is a success. Redirects (3xx) are not followed and count as a failure.
  • Answer quickly and process the event asynchronously.
  • A failed delivery is tried 9 times in total over about 75 hours: at once, then after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h and 30 h, with up to 10% random jitter.
  • The guarantee is at least once, in no guaranteed order. Two deliveries of the same event are possible, and a later event can arrive before an earlier one. Drop duplicates by id and read the current state with a GET.
  • For events from the time an address was switched off, catch up with the list requests, for example GET /v2/appointments for a period.

Address requirements​

  • https only, up to 2048 characters, with no user name or password in the address.
  • The domain is written in ASCII. A Cyrillic domain is entered in its xn-- form: instead of хуки.бг write xn--h1ad0ah.xn--90ae.
  • The host must resolve only to public addresses. We check it when you save and on every attempt. A host that does not resolve at the moment is an ordinary failure and is retried. A host that points to an internal network is refused without a retry and counts toward the 3-day switch-off.
  • Parameters in the address, such as ?token=…, are useful as your own protection on the receiver if you do not verify the signature. They are not shown in the sending history.
💡No business profile yet?

To use these features in Business, you need a business profile. Create one and continue with setup.