Webhooks
Webhooks
The outbound event catalogue with example payloads, signature verification in Node.js and Python, retries and replays.
Clive notifies partner systems over HTTPS when something happens in a clinic: a member checks in or doesn't show, a voucher is redeemed, results are released, a consent changes, an enquiry or case moves on. Each notification is a signed JSON POST to an endpoint you register. Deliveries are retried with back-off for about 24 hours and can be replayed.
- Staff manage endpoints in Settings › Webhooks (
manage_settings). - Partners manage their own endpoints through the public API with the
webhooks:managescope (see Managing endpoints).
Event catalogue
Envelope api_version: 2026-10-01. Within a version, fields are only ever added; a breaking change ships as a new version under the deprecation policy and is announced in the changelog.
| Event | When | data fields |
|---|---|---|
appointment.confirmed | Appointment confirmed | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external |
appointment.checked_in | Member checked in for an appointment | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external |
appointment.no_show | Appointment marked as no-show | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external |
appointment.cancelled | Appointment cancelled | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external |
appointment.rescheduled | Appointment moved to a new time | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external, previous_start_time |
appointment.completed | Appointment completed | appointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external |
member.updated | Member profile updated | member, changed_fields, updated_at |
member.merged | Duplicate member records merged | member, merged_member_ids, merged_at |
consent.changed | Consent granted or withdrawn | consent_id, member, purpose, status, notice_version, changed_at, channel, source |
voucher.redeemed | Voucher redeemed | voucher_id, external_id, member, appointment_id, venue_id, redeemed_at |
result.released | Results released to the member | result_id, member, appointment_id, released_at |
lead.status_changed | Enquiry status changed | lead_id, external_id, status, previous_status, member, changed_at |
case.status_changed | Case status changed | case_id, status, previous_status, member, changed_at |
Subscribe an endpoint to specific events or to * (every event, including ones added later). webhook.test is sent only by Send test event and is never part of a subscription.
Payloads are deliberately small: ids, statuses and timestamps. A member is identified only by member.id (the Clive member id) and member.membership_no (your membership number, when linked). Names, identity numbers, phone numbers, email addresses and clinical values are never sent: fetch them through the API, where scopes and consent apply.
The envelope
Every delivery is a POST with a JSON body like this. id is the event id: it stays the same on every retry and replay, so deduplicate on it.
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.checked_in",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "checked_in",
"previous_status": "confirmed",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}Request headers:
Content-Type: application/jsonUser-Agent: Clive-Webhooks/1X-Clive-Event: the event typeX-Clive-Delivery: the id of this delivery of the event to your endpointX-Clive-Signature:t=<unix seconds>,v1=<hex HMAC-SHA256>(see Verifying signatures)
Example payloads
One complete request body per event. Values are synthetic. Each event has a stable link, for example #event-voucher-redeemed.
appointment.confirmedAppointment confirmed
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.confirmed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "confirmed",
"previous_status": "scheduled",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}appointment.checked_inMember checked in for an appointment
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.checked_in",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "checked_in",
"previous_status": "confirmed",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}appointment.no_showAppointment marked as no-show
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.no_show",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "no_show",
"previous_status": "confirmed",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}appointment.cancelledAppointment cancelled
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.cancelled",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "cancelled",
"previous_status": "confirmed",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}appointment.rescheduledAppointment moved to a new time
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.rescheduled",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "confirmed",
"previous_status": "confirmed",
"start_time": "2026-11-03T02:30:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
},
"previous_start_time": "2026-11-02T01:00:00.000Z"
}
}appointment.completedAppointment completed
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "appointment.completed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"status": "completed",
"previous_status": "in_progress",
"start_time": "2026-11-02T01:00:00.000Z",
"duration_minutes": 30,
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"external": {
"provider": "partner_v1",
"id": "BK-998877"
}
}
}member.updatedMember profile updated
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "member.updated",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"changed_fields": [
"phone",
"address"
],
"updated_at": "2026-10-24T02:15:09.123Z"
}
}member.mergedDuplicate member records merged
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "member.merged",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"merged_member_ids": [
"9d3e1f20-5b6a-4c7d-8e9f-0a1b2c3d4e5f"
],
"merged_at": "2026-10-24T02:15:09.123Z"
}
}consent.changedConsent granted or withdrawn
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "consent.changed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"consent_id": "7e2a4c6b-1d3f-4a5b-9c8d-0e1f2a3b4c5d",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"purpose": "share_results_partner_v1",
"status": "granted",
"notice_version": "partner-2026-03",
"changed_at": "2026-10-24T02:15:09.123Z",
"channel": "api",
"source": "partner_v1"
}
}voucher.redeemedVoucher redeemed
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "voucher.redeemed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"voucher_id": "3f6b8d0a-2c4e-4f1a-9b3d-5e7f9a1c3e5b",
"external_id": "V-55120",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"venue_id": "5a1d9e2b-3c4f-4a6b-8d7e-9f0a1b2c3d4e",
"redeemed_at": "2026-11-02T01:02:41.517Z"
}
}result.releasedResults released to the member
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "result.released",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"result_id": "6c8e0a2b-4d6f-4b1c-8e3a-5f7b9d1e3a5c",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"appointment_id": "0b8e3c1a-6f2d-4e7b-8c9a-1d2e3f4a5b6c",
"released_at": "2026-11-04T06:30:00.000Z"
}
}lead.status_changedEnquiry status changed
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "lead.status_changed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"lead_id": "2b4d6f8a-0c2e-4a6b-9d1f-3a5c7e9b1d3f",
"external_id": "ENQ-7781",
"status": "contacted",
"previous_status": "new",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"changed_at": "2026-10-24T02:15:09.123Z"
}
}case.status_changedCase status changed
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f60",
"type": "case.status_changed",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"case_id": "8a0c2e4b-6d8f-4c1a-9e3b-5d7f9b1c3e5a",
"status": "resolved",
"previous_status": "investigating",
"member": {
"id": "c41f7a52-8d0e-4b8e-9a51-2f3c6d7e8a90",
"membership_no": "PM00012345"
},
"changed_at": "2026-10-24T02:15:09.123Z"
}
}webhook.testSent by Send test event
{
"id": "8f6c2d4e-4c1b-4a8e-9d0f-1b2c3d4e5f61",
"type": "webhook.test",
"api_version": "2026-10-01",
"created_at": "2026-10-24T02:15:09.123Z",
"data": {
"endpoint_id": "4c6e8a0b-2d4f-4b6a-8c1e-3f5a7c9e1b3d",
"message": "Test event from Clive. No action is needed."
}
}Request format
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Clive-Webhooks/1
X-Clive-Event: appointment.checked_in
X-Clive-Delivery: 3e0a… (one delivery of the event to your endpoint)
X-Clive-Signature: t=1792807209,v1=5f2b…- Respond with any 2xx within 10 seconds. The response body is ignored, though the first 2 KB is kept in the delivery log.
- Redirects are not followed and count as failures.
- 410 Gone tells Clive that you have retired the endpoint. It is disabled at once and receives nothing more until someone enables it again.
Verifying signatures
X-Clive-Signature is t=<unix seconds>,v1=<hex HMAC-SHA256>. The MAC is computed with your endpoint secret (whsec_…) over the string "<t>.<raw request body>". To verify a request:
- Take the raw body bytes. Re-serialising parsed JSON changes them.
- Compute the HMAC of
t + "." + bodywith your secret. - Compare it in constant time with each
v1value. During a secret rotation the header carries twov1values, one per secret, and either may match. - Reject the request if
tis more than 5 minutes from your clock. This stops replayed requests.
Node.js
import crypto from 'node:crypto'
import express from 'express'
const SECRET = process.env.CLIVE_WEBHOOK_SECRET // whsec_…
const TOLERANCE_SECONDS = 300
function verify(rawBody, header) {
if (!header) return false
const parts = header.split(',').map((p) => p.trim().split('='))
const t = Number(parts.find(([k]) => k === 't')?.[1])
const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v)
if (!Number.isInteger(t) || sigs.length === 0) return false
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false
const expected = crypto.createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex')
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)))
}
const app = express()
app.post('/webhooks/clive', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verify(raw, req.get('X-Clive-Signature'))) return res.sendStatus(400)
const event = JSON.parse(raw)
res.sendStatus(204) // acknowledge first…
queue.add(event) // …then process asynchronously, deduplicating on event.id
})Python
import hashlib, hmac, os, time
from flask import Flask, request, abort
SECRET = os.environ["CLIVE_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def verify(raw_body: bytes, header: str | None) -> bool:
if not header:
return False
pairs = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
ts = next((v for k, v in pairs if k == "t"), None)
sigs = [v for k, v in pairs if k == "v1"]
if ts is None or not ts.isdigit() or not sigs:
return False
if abs(time.time() - int(ts)) > TOLERANCE_SECONDS:
return False
expected = hmac.new(SECRET, ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in sigs)
app = Flask(__name__)
@app.post("/webhooks/clive")
def clive_webhook():
raw = request.get_data() # raw bytes, before any JSON parsing
if not verify(raw, request.headers.get("X-Clive-Signature")):
abort(400)
event = request.get_json()
enqueue(event) # process asynchronously; dedupe on event["id"]
return "", 204PHP
<?php
$secret = getenv('CLIVE_WEBHOOK_SECRET');
$tolerance = 300;
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_CLIVE_SIGNATURE'] ?? '';
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', trim($part), 2), 2, null);
if ($k === 't') { $t = $v; }
if ($k === 'v1') { $sigs[] = $v; }
}
$valid = false;
if ($t !== null && ctype_digit($t) && abs(time() - (int) $t) <= $tolerance) {
$expected = hash_hmac('sha256', $t . '.' . $raw, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) { $valid = true; break; }
}
}
if (!$valid) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(204);
// Hand $event to a queue; dedupe on $event['id'].Retries and dead letter
An attempt fails on a non-2xx response (other than 410), a redirect, a connection error or the 10-second timeout. Clive then retries on this schedule, with ±10 % jitter:
| Attempt | After the previous one | About this long after the event |
|---|---|---|
| 1 | at once | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 30 minutes | 36 min |
| 5 | 2 hours | 2 h 36 min |
| 6 | 5 hours | 7 h 36 min |
| 7 | 8 hours | 15 h 36 min |
| 8 | 8 hours | 23 h 36 min |
After the 8th failed attempt the delivery is dead (the dead letter). Its event is not sent again unless it is replayed. Every attempt is logged with its HTTP status, the first 2 KB of the response, the error and the duration. The log is under Settings › Webhooks and GET /public/v1/webhooks/deliveries.
If 20 deliveries in a row end dead, the endpoint is disabled and the reason is recorded. A successful delivery resets the count. Enabling the endpoint again resets it as well. Revoking an API client disables the endpoints it registered.
Events are delivered at least once and not in a guaranteed order, so a retry can arrive after a later event. Use created_at and the status fields to keep the latest state.
Consent gate
Deliveries to endpoints registered by a partner's API client pass the consent gate at every attempt. result.released needs the member's grant of share_results_<provider> (e.g. share_results_partner_v1); member.updated and member.merged need care not to be withdrawn. When consent is missing or withdrawn the delivery is not sent: it ends as cancelled with cancel_reason consent_withdrawn (or no_consent), no further attempts are made, and the endpoint's failure count is untouched. Every decision is logged on the member (Consent tab › Sharing). See partner-contract-v1.md.
Replay
- One delivery: replay any delivery (for example a dead one). This creates a new delivery of the same event, with the same
idanddata, and its own attempts. - A time range: replay every event of an endpoint's subscribed types (or a subset you name) that happened in
[from, to). The range is at most 7 days and 10 000 events, from the last 30 days (how long the outbox is kept). The response says how many were enqueued and whether the cap was hit.
Replays carry the original event ids, so a receiver that deduplicates sees no double effects.
Secret rotation
Rotating an endpoint's secret returns a new secret (shown once). By default the previous secret keeps signing for 24 hours (overlap_hours, 0 to 168), so each request carries two v1 signatures. Deploy the new secret to your receiver within that window. With overlap_hours: 0 the old secret stops at once.
Managing endpoints through the API
These routes need a client-credentials token with the webhooks:manage scope. Partners see and manage only the endpoints their own API client registered. JSON is snake_case.
| Method and path | Purpose |
|---|---|
GET /public/v1/webhooks/endpoints |
List your endpoints |
POST /public/v1/webhooks/endpoints |
Register { url, description?, event_types }. The response includes secret, shown once |
GET /public/v1/webhooks/endpoints/{id} |
Read one |
PATCH /public/v1/webhooks/endpoints/{id} |
Change url, description, event_types or status (active or disabled) |
DELETE /public/v1/webhooks/endpoints/{id} |
Delete it, with its delivery history |
POST /public/v1/webhooks/endpoints/{id}/rotate |
New secret { overlap_hours? }. The response includes secret |
POST /public/v1/webhooks/endpoints/{id}/test |
Send a webhook.test event |
POST /public/v1/webhooks/endpoints/{id}/replay |
Replay { from, to, event_types? } |
GET /public/v1/webhooks/deliveries |
Delivery log. Filters: endpoint_id, status, event_type, event_id, from, to, before (cursor), limit (≤ 200) |
POST /public/v1/webhooks/deliveries/{id}/replay |
Replay one delivery |
Endpoint URLs must use https://, may not contain credentials or a fragment, and must resolve only to public addresses. This is checked when the endpoint is registered and again on every connection, so a DNS change can't point deliveries into a private network.
Best practices
- Deduplicate on the event
id. Delivery is at least once, and replays repeat ids by design. - Respond fast. Return 2xx as soon as the signature checks out, then process asynchronously. Anything over 10 seconds counts as a failure.
- Verify every request against the raw body, and enforce the 5-minute timestamp tolerance.
- Don't rely on order. Compare
created_atand statuses instead. - Use 410 only to retire an endpoint. For a temporary problem, return 5xx and let the retries cover it.
- Fetch details with the API, which applies scopes and the member's consent, rather than expecting them in payloads.