# 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.

## 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.

```json 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

| 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. |

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.

```ts 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
<?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.
