Skip to content
Documentation
pozyskajpacjentaDocs

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.

POST to your address
{
  "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

HeaderValue
Content-Typeapplication/json
X-PP-EventThe event name, e.g. lead.created.
X-PP-TimestampWhen this attempt was sent (Unix time, seconds).
X-PP-Signaturesha256=<hex>, the HMAC described below.
X-PP-Event-IdEvent 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.

TypeScript (Node)
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
<?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

POST to your address
{
  "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

EventWhendata
lead.createdA new enquiry, whatever its sourceleadId, source, locationId
appointment.bookedA visit is booked: online, in the panel, or as the new time of a rescheduled visitappointmentId, leadId, status (confirmed, or pending while it waits for the clinic or the payment), serviceId, resourceId, locationId
appointment.cancelledThe patient or the clinic cancels a visit, or a pending visit is rejected, expires or is never paidappointmentId, leadId, cancelledBy (patient, clinic or null), reason (rejected, expired, unpaid or null), rescheduledToId
conversation.createdA patient's first message opens a conversationconversationId, leadId
message.receivedA patient writesconversationId, messageId, leadId, channel
message.sentThe clinic's message to a patient leavesconversationId, messageId, leadId, channel
message.statusA sent message is delivered, read or failsmessageId, channel, status (sent, delivered, read, failed)
patient.erasedThe 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 eachleadId, conversationId (null when the patient had no conversation)
webhook.testWyślij testowe in the panelendpointId

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 occurredAt and deduplicate on id.