Skip to main content
On this page

A practice registers your endpoint on the client, under Webhooks, and picks which events it gets. There are four, and a test:

EventWhen
invoice.issuedAn invoice is issued
invoice.status_changedAn invoice changes status, such as paid or cancelled
claim.status_changedThe claim on an invoice changes status
payment.recordedA payment is recorded against an invoice
webhook.testSomeone at the practice picks Send a test event on the endpoint

An event only goes to a client that can read what it's about, so a client without Read on Claims never hears about claims.

What arrives

A POST with a small JSON body. It names the invoice and where to read it, with no patient data in it:

json
{
  "id": "k2m9x7c4v5b8n1m3q6w9e2r5",
  "type": "invoice.issued",
  "occurredAt": "2026-10-01T10:02:31.000Z",
  "data": {
    "object": "invoice",
    "id": "i2q6w8e0r4t7y1u3o5p9a6sd",
    "url": "https://dashboard.practor.app/api/v1/invoices/i2q6w8e0r4t7y1u3o5p9a6sd"
  }
}

Read the invoice from data.url with your key to see what changed. A claim or payment event names the invoice it belongs to; read its claim or payments from there.

Verify the signature

Practor signs every delivery the way the Standard Webhooks specification describes, so any of its libraries can check it. Three headers come with it:

  • webhook-id: the event id, the same on every retry
  • webhook-timestamp: seconds since the Unix epoch when it was sent
  • webhook-signature: v1, then the base64 HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>

The key is your endpoint's secret, shown once when the endpoint is added: drop the whsec_ prefix and base64-decode the rest. Check against the raw body exactly as it arrived, before any JSON parsing.

javascript
import crypto from "node:crypto";

export function verify(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  return headers["webhook-signature"]
    .split(" ")
    .some((candidate) => {
      const [version, signature] = candidate.split(",");
      return (
        version === "v1" &&
        signature.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
      );
    });
}

Refuse anything older than five minutes, so a captured delivery can't be replayed later.

Answer quickly

Answer with any 2xx within 10 seconds, then do the work. Anything else, or no answer, is retried with a growing delay, up to 8 attempts. Use id to ignore an event you've already handled, because a retry can arrive after you answered.

An endpoint that keeps failing for three days is switched off, and the practice is told. Once it's fixed, the practice picks Switch on on the endpoint, under Webhooks. A client can have up to five endpoints.