> ## Documentation Index
> Fetch the complete documentation index at: https://build.practicehub.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhook receiver

> A production-shaped Node receiver — verify the signature, acknowledge in milliseconds, process idempotently, fetch the record behind a thin event

<Warning>
  **Coming soon.** Webhook delivery is not switched on yet. This page describes the contract that will ship — event types, payload shape, signing and delivery behaviour — so you can build your receiver ahead of time. Until then, poll with `updated` filters and `deleted_entities` (see [Querying](/guides/querying)). The changelog will announce availability.
</Warning>

**Goal:** an HTTPS endpoint that PracticeHub can deliver events to reliably: it rejects anything not signed with your endpoint secret, answers `2xx` immediately so retries and disabling never kick in, does the real work in the background exactly once per event, and fetches the current record with your own key because the event only carries the id.

**Key:** read-only (the receiver only reads). **Needs:** an endpoint created under **Developers → Webhooks** (2.0 accounts) with its signing secret, and the `standardwebhooks` package.

## The shape of a delivery

```http theme={null}
POST /practicehub/webhooks HTTP/1.1
Content-Type: application/json
webhook-id: msg_2h9Wl3…
webhook-timestamp: 1755345600
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj4tXA9Fw…

{
  "id": "0f7a5c4e-8f0d-4b2c-9c9d-1a2b3c4d5e6f",
  "type": "appointments.cancelled",
  "occurred_at": "2026-08-15T12:00:00+00:00",
  "data": { "entity": "appointments", "id": 4821 }
}
```

Three headers carry the [Standard Webhooks](https://www.standardwebhooks.com/) signature; the body is the event: a unique `id`, a `type` (`{resource}.created|updated|deleted` plus the semantic appointment events), when it happened, and **only** the entity name and id. Nothing personal or clinical is in the payload by design.

## The receiver

```ts theme={null}
// server.ts — Node 18+, express, standardwebhooks
import express from 'express';
import { Webhook } from 'standardwebhooks';
import { api } from './practicehub';           // the client from the booking example
import { queue, seen } from './infra';          // your job queue + a 24 h key/value store (Redis, DB…)

const wh = new Webhook(process.env.PRACTICEHUB_WEBHOOK_SECRET!);   // "whsec_…" from Developers → Webhooks
const app = express();

// 1. Raw body — verification is over the exact bytes PracticeHub signed.
app.post('/practicehub/webhooks', express.raw({ type: 'application/json', limit: '64kb' }), async (req, res) => {
  let event: { id: string; type: string; occurred_at: string; data: { entity: string; id: number } };

  // 2. Verify — wrong secret, tampered body or a timestamp outside the tolerance window → 401, no retry from us is wanted.
  try {
    event = wh.verify(req.body, {
      'webhook-id': req.header('webhook-id')!,
      'webhook-timestamp': req.header('webhook-timestamp')!,
      'webhook-signature': req.header('webhook-signature')!,
    }) as typeof event;
  } catch {
    return res.status(401).send('invalid signature');
  }

  // 3. De-duplicate — a delivery can be retried after a timeout; the event id is stable across retries.
  if (await seen.setIfAbsent(`ph-event:${event.id}`, '1', { ttlSeconds: 86_400 }) === false) {
    return res.status(200).send('duplicate');
  }

  // 4. Acknowledge now, work later. Anything slow (our API call, your database) goes to a queue.
  await queue.enqueue('practicehub-event', event);
  res.status(202).send('queued');
});

app.listen(8080);
```

And the worker:

```ts theme={null}
// worker.ts
import { api, ApiError } from './practicehub';

export async function handlePracticeHubEvent(event: { id: string; type: string; data: { entity: string; id: number } }) {
  const [resource, action] = event.type.split('.') as [string, string];

  // 5. Deleted / voided records are gone from the API — do not fetch, just react.
  if (action === 'deleted') {
    return onDeleted(resource, event.data.id);
  }

  // 6. Everything else: fetch the *current* record. Events are not ordered; the record is.
  let record: Record<string, unknown>;
  try {
    ({ data: record } = await api<{ data: Record<string, unknown> }>('GET', `/${resource}/${event.data.id}`));
  } catch (e) {
    if (e instanceof ApiError && e.status === 404) return onDeleted(resource, event.data.id);   // deleted between event and fetch
    throw e;                                                                                  // 429/5xx already retried by api(); rethrow → job retry
  }

  switch (event.type) {
    case 'appointments.cancelled':
    case 'appointments.missed':
      return crm.markVisitOutcome(record);
    case 'appointments.rescheduled':
      return calendar.upsert(record);
    case 'patients.created':
    case 'patients.updated':
      return crm.upsertContact(record);
    case 'invoices.updated':
      return ledger.upsertInvoice(record);
    default:
      return; // subscribe only to what you handle; unknown types are safe to ignore
  }
}
```

## Why each step is there

| Step                             | Without it                                                                                                                                                                    |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Raw body                         | JSON middleware re-serialises the body; the signature no longer matches and every delivery fails verification                                                                 |
| Verify                           | Anyone who learns your URL can inject events; the timestamp check also stops replay of an old capture                                                                         |
| De-duplicate on `id`             | A slow response gets retried and you process the same event twice — the CRM gets two notes, the ledger two receipts                                                           |
| Acknowledge fast, queue the work | PracticeHub waits a few seconds then retries; repeated timeouts eventually **disable the endpoint** and you stop receiving anything until someone re-enables it in the portal |
| Fetch the current record         | Two events for the same record can arrive in either order; the payload has no data anyway. Fetching means the last write always wins correctly                                |
| `deleted` → don't fetch          | The record no longer comes back from the API (`404`); a soft-delete or void is what the event means                                                                           |

## Testing it

1. **Portal → Send test event** delivers a real signed message to your endpoint.
2. **Portal → Replay** any past delivery — the same `id`, so this also proves your de-duplication.
3. Locally, run the receiver behind a tunnel (ngrok, cloudflared) and add that URL as a second endpoint on a test account; delete it afterwards.
4. Every attempt, with your response code and body, is visible in the portal — start there when "webhooks aren't arriving".

## Gotchas

| Symptom                           | Cause                                                                         | Fix                                                                                                  |
| --------------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Every delivery fails verification | Body parsed before verifying, or the wrong secret (each endpoint has its own) | `express.raw` (or the framework equivalent) on this route only; copy the secret from *this* endpoint |
| Verification fails only sometimes | Server clock skew beyond the tolerance window                                 | Sync the clock (NTP)                                                                                 |
| Endpoint disabled in the portal   | Consistent timeouts/5xx from your handler                                     | Ack before working; fix the worker; re-enable and replay from the portal                             |
| Same event handled twice          | No de-dup, or de-dup keyed on `webhook-id`/time instead of the event `id`     | Key on `event.id`; keep 24 h                                                                         |
| Old data overwritten by newer     | Applied the event's *type* as state without fetching                          | Fetch and apply the record; the event only tells you *something* changed                             |
| No events at all                  | Account is not on PracticeHub 2.0, or endpoint subscribed to no event types   | Check Developers → Webhooks; webhooks are a 2.0 feature                                              |
