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:manage scope (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.

EventWhendata fields
appointment.confirmedAppointment confirmedappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external
appointment.checked_inMember checked in for an appointmentappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external
appointment.no_showAppointment marked as no-showappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external
appointment.cancelledAppointment cancelledappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external
appointment.rescheduledAppointment moved to a new timeappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external, previous_start_time
appointment.completedAppointment completedappointment_id, status, previous_status, start_time, duration_minutes, venue_id, member, external
member.updatedMember profile updatedmember, changed_fields, updated_at
member.mergedDuplicate member records mergedmember, merged_member_ids, merged_at
consent.changedConsent granted or withdrawnconsent_id, member, purpose, status, notice_version, changed_at, channel, source
voucher.redeemedVoucher redeemedvoucher_id, external_id, member, appointment_id, venue_id, redeemed_at
result.releasedResults released to the memberresult_id, member, appointment_id, released_at
lead.status_changedEnquiry status changedlead_id, external_id, status, previous_status, member, changed_at
case.status_changedCase status changedcase_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.

Request bodyJSON
{
  "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/json
  • User-Agent: Clive-Webhooks/1
  • X-Clive-Event: the event type
  • X-Clive-Delivery: the id of this delivery of the event to your endpoint
  • X-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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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"
  }
}

voucher.redeemedVoucher redeemed

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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

Request bodyJSON
{
  "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:

  1. Take the raw body bytes. Re-serialising parsed JSON changes them.
  2. Compute the HMAC of t + "." + body with your secret.
  3. Compare it in constant time with each v1 value. During a secret rotation the header carries two v1 values, one per secret, and either may match.
  4. Reject the request if t is more than 5 minutes from your clock. This stops replayed requests.

Node.js

JavaScript
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

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 "", 204

PHP

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

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 id and data, 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_at and 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.