Concepts
Webhooks
Receive lead.created, verify the HMAC signature, and handle retries.
Instead of polling for new enquiries, receive them: when a lead appears in the clinic's CRM, the platform sends a signed POST to your address, with retries. This page covers the event, the headers, signature verification and delivery. Clinics with event webhooks can also send conversation and visit events, as ids only, to several addresses; they are signed and verified the same way.
Set it up
In the clinic's panel, open Ustawienia → Integracje → Webhook, enter your endpoint's address (Adres webhooka) and choose Wygeneruj sekret podpisu. The secret starts with pp_whsec_ and, like the API key, is shown once; generating a new one invalidates the old one.
The lead.created event
There is one event today: lead.created, sent for every new lead whatever its source (the clinic's site, the embed script, the API, a manual entry or an inbound message). The body is a frozen snapshot of the lead at the moment of the event: every delivery attempt sends the same bytes.
{
"event": "lead.created",
"lead": {
"id": "rS2bIhedVSStSQLc7DL_T",
"name": "Anna Kowalska",
"phone": "+48 600 100 200",
"email": "anna.kowalska@example.com",
"message": "Proszę o kontakt w sprawie higienizacji.",
"source": "api",
"createdAt": "2026-09-30T14:25:40.000Z"
}
}source is one of form, embed, api, manual or inbound. A field the lead does not have is null.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
X-PP-Event | The event name, e.g. lead.created. |
X-PP-Timestamp | When this attempt was sent (Unix time, seconds). |
X-PP-Signature | sha256=<hex>, the HMAC described below. |
X-PP-Event-Id | Event webhooks only: the event's id, the same in every attempt. |
The body is frozen, but X-PP-Timestamp and X-PP-Signature are computed again for every attempt.
Verify the signature
The signature is an HMAC-SHA256 with your pp_whsec_ secret over the string timestamp + "." + body, where timestamp is the X-PP-Timestamp header and body the raw bytes of the request. Verify before you parse the JSON: parsing and serialising again can change the bytes.
import { createHmac, timingSafeEqual } from "node:crypto";
/** true only for a fresh, correctly signed delivery */
export function verifyWebhook(opts: {
secret: string; // pp_whsec_… from the panel
rawBody: string; // the request body exactly as received
timestamp: string; // X-PP-Timestamp
signature: string; // X-PP-Signature, e.g. "sha256=3f7a…"
}): boolean {
const age = Math.abs(Date.now() / 1000 - Number(opts.timestamp));
if (!Number.isFinite(age) || age > 300) return false; // replay protection: 5 minutes
const expected = createHmac("sha256", opts.secret)
.update(`${opts.timestamp}.${opts.rawBody}`)
.digest();
const received = Buffer.from(opts.signature.replace(/^sha256=/, ""), "hex");
return expected.length === received.length && timingSafeEqual(expected, received);
}<?php
// WordPress REST route or plain PHP: read the raw body before anything parses it
$raw = file_get_contents( 'php://input' );
$timestamp = $_SERVER['HTTP_X_PP_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_PP_SIGNATURE'] ?? '';
$secret = getenv( 'PP_WEBHOOK_SECRET' );
$fresh = abs( time() - (int) $timestamp ) <= 300;
$expected = 'sha256=' . hash_hmac( 'sha256', $timestamp . '.' . $raw, $secret );
if ( ! $fresh || ! hash_equals( $expected, $signature ) ) {
http_response_code( 401 );
exit;
}
$event = json_decode( $raw, true );- Compare signatures in constant time (
timingSafeEqual,hash_equals), never with===. - Reject old timestamps (5 minutes above) and log rejected requests: they point at a wrong secret or a spoofing attempt.
- A clinic that has not generated a secret yet gets deliveries signed with an older interim scheme that you cannot verify in advance. Generate the secret, then verify every delivery.
Delivery and retries
- Answer with a 2xx status within 10 seconds. A slower or different answer counts as a failed attempt.
- After a failed attempt the delivery is retried after 1, 5, 30, 120 and 720 minutes. When the retries run out, the delivery is marked failed and not sent again.
- Answer fast: store the raw request, reply 2xx, and process it afterwards.
- Be idempotent: when your 2xx gets lost on the way (a timeout), the same snapshot comes again. Deduplicate by
lead.id.
A webhook complements the API: after an event you can fetch more with the read endpoints.
Event webhooks
A clinic with event webhooks switched on sends conversation and visit events to up to five addresses: a scenario in Make or n8n, or the clinic's practice software. An event carries ids and fixed values only, never a name, a phone number, an e-mail address or the text of a message, so it can travel to any system the clinic chooses. Match the ids with what your system already holds, for example the id that POST /leads or POST /booking/appointments returned.
Set up an address
In the clinic's panel, open Ustawienia → Integracje → Webhooki zdarzeń, enter an https:// address and tick the events it should get. Each address has its own signing secret (pp_whsec_…), shown once; Nowy sekret podpisu replaces it at once. Wyślij testowe sends a webhook.test event straight away and shows your answer, and the section lists the latest deliveries with their results.
The body
{
"id": "evt_V1StGXR8Z5jdHi6BmyTq4",
"type": "appointment.booked",
"clinicId": "Hk3mQe0d2B7xQp1Lz9TnA",
"occurredAt": "2026-10-12T09:15:02.000Z",
"data": {
"appointmentId": "a8Yt2LcP0qN5sVx1Rk7Ue",
"leadId": "rS2bIhedVSStSQLc7DL_T",
"status": "confirmed",
"serviceId": "Qm4pTn8sV2xC6bL0yR3eW",
"resourceId": "Zp9sL2kD7vB4nQ1xT6cHa",
"locationId": null
}
}id is the same in every attempt of one event and in the X-PP-Event-Id header: deduplicate on it. clinicId is the same in every event of one clinic. Every field listed for a type below is always present, null when it does not apply. Verify the signature exactly as in Verify the signature, with the address's own secret.
Events
| Event | When | data |
|---|---|---|
lead.created | A new enquiry, whatever its source | leadId, source, locationId |
appointment.booked | A visit is booked: online, in the panel, or as the new time of a rescheduled visit | appointmentId, leadId, status (confirmed, or pending while it waits for the clinic or the payment), serviceId, resourceId, locationId |
appointment.cancelled | The patient or the clinic cancels a visit, or a pending visit is rejected, expires or is never paid | appointmentId, leadId, cancelledBy (patient, clinic or null), reason (rejected, expired, unpaid or null), rescheduledToId |
conversation.created | A patient's first message opens a conversation | conversationId, leadId |
message.received | A patient writes | conversationId, messageId, leadId, channel |
message.sent | The clinic's message to a patient leaves | conversationId, messageId, leadId, channel |
message.status | A sent message is delivered, read or fails | messageId, channel, status (sent, delivered, read, failed) |
patient.erased | The clinic's owner erased a patient's data on their request (GDPR art. 17). Delete what your system keeps of this card and its conversation; cards merged into it get an event each | leadId, conversationId (null when the patient had no conversation) |
webhook.test | Wyślij testowe in the panel | endpointId |
channel is one of email, sms, whatsapp, instagram or chat, and serviceId and resourceId are the ids GET /booking/catalog lists. The text of a message and the patient of a conversation are read through the API: GET /conversations/{id} and GET /conversations/{id}/messages, with a key that has the scope conversations:read (see Conversations). A visit booked online arrives within seconds; a cancellation, and a visit booked any other way, within 15 minutes.
Delivery
- Answer with a 2xx status within 10 seconds; a redirect counts as a failed attempt. The same retries apply: after 1, 5, 30, 120 and 720 minutes, each signed again with a fresh timestamp.
- When three events in a row have used up their retries without a 2xx in between, the address is switched off and the clinic gets an e-mail. Nothing is sent to it until someone switches it on again in the panel; Wyślij testowe works while it is off.
- Delivery is at least once, and events of one clinic can arrive out of order: use
occurredAtand deduplicate onid.