Webhooks
Webhook endpoints are managed by an organization admin in the PlusMedical app (Settings → API).
Endpoints must be https:// URLs resolving to public addresses, and each gets a
signing secret (whsec_…, shown once, rotatable).
Events
| Event | Fires when |
|---|---|
appointment.created | An appointment is booked on ANY channel (reception, widget, portal, API) |
appointment.updated | Time/doctor/status changes (other than the two below) |
appointment.cancelled | An appointment is cancelled |
appointment.no_show | An appointment is marked no-show |
patient.created | A patient is registered with (or first linked to) the organization |
patient.updated | A patient's contact data changes |
invoice.issued | An invoice is issued (gets its number) |
invoice.paid | An issued invoice becomes fully paid |
invoice.stornoed | A credit note is issued against an invoice |
lab_order.validated | A lab order is fully validated — reference only: order id, patient ref and analysis CODES, never values |
ping | The "send test event" button |
Envelope
data uses the same public DTOs as the REST API — a field that is
not in the API is not in a webhook either.
{
"id": "0197a3c2-7f1e-7a31-bb1c-2f6a9d2e4c10",
"type": "appointment.created",
"created_at": "2026-06-14T09:30:00+00:00",
"data": {
"id": "0197a3c2-6d2b-7c44-9e1f-8b3d5a7e9f21",
"status": "confirmed",
"starts_at": "2026-06-15T08:00:00+00:00",
"ends_at": "2026-06-15T08:30:00+00:00",
"source": "api",
"patient": { "id": "…" },
"doctor": { "id": "…" },
"location": { "id": "…" },
"service": { "id": "…" }
}
} Idempotency
id is the event id and your idempotency key: automatic retries
and manual redeliveries reuse it, and when one event fans out to several of your endpoints they
all share it. Process each event id at most once.
Signature verification
Every delivery is signed Stripe-style. The X-PlusMedical-Signature header carries
t={unix},v1={hmac} where the HMAC is
HMAC-SHA256(secret, "{t}.{raw_body}"). Verify the timestamp
within a 5-minute tolerance (replay protection) and compare digests in
constant time. Always sign-check the raw body, before any JSON parsing.
PHP
<?php
// $secret = the endpoint secret (whsec_...)
// $payload = the RAW request body (do not re-encode it)
// $header = $_SERVER['HTTP_X_PLUSMEDICAL_SIGNATURE']
function verifyPlusMedicalSignature(string $secret, string $payload, string $header): bool
{
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) {
return false;
}
[, $timestamp, $signature] = $m;
if (abs(time() - (int) $timestamp) > 300) { // 5-minute tolerance
return false; // replay guard
}
$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
return hash_equals($expected, $signature);
} Node.js
const crypto = require('node:crypto')
// secret = the endpoint secret (whsec_...)
// payload = the RAW request body as a string (use express.raw / rawBody)
// header = req.headers['x-plusmedical-signature']
function verifyPlusMedicalSignature(secret, payload, header) {
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header || '')
if (!match) return false
const [, timestamp, signature] = match
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + '.' + payload)
.digest('hex')
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
} Python
import hashlib, hmac, re, time
# secret = the endpoint secret (whsec_...)
# payload = the RAW request body as bytes
# header = request.headers["X-PlusMedical-Signature"]
def verify_plusmedical_signature(secret: str, payload: bytes, header: str) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not match:
return False
timestamp, signature = match.groups()
if abs(time.time() - int(timestamp)) > 300: # 5-minute tolerance
return False
signed = f"{timestamp}.".encode() + payload
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature) Delivery & retries
- HTTP POST with a 10-second timeout; any
2xxcounts as delivered. - Failures retry with backoff: 1m, 5m, 30m, 2h, 6h, 24h — then the delivery is marked dead. Dead deliveries can be redelivered manually from the app (same event id).
- After 20 consecutive failures the endpoint is auto-disabled and the organization admins are emailed. Re-enabling it from the app resets the counter.
-
Other headers:
X-PlusMedical-Event(event type) andX-PlusMedical-Delivery(delivery id, changes per retry chain).
Respond fast: acknowledge with 200 immediately and process asynchronously.
Slow handlers hit the 10s timeout and count as failures.