Webhooks

Partner contract v1

The signed events a partner platform sends to Clive, the feeds Clive pulls, and the consent gate on data going out.

How a partner platform exchanges member data with a Clive clinic group. Clive publishes this contract and the partner implements it, so integration work can start before the partner's own API specification exists. When the partner's real format is known, Clive maps it onto this contract in a per-provider adapter and nothing else changes.

It has four parts:

  1. Events in: the partner pushes signed events to Clive (Inbound events).
  2. Feeds Clive pulls: member and consent feeds on the partner's side, for the initial load, a single-member re-sync and the 15-minute consent reconciliation (Feeds).
  3. Calls Clive makes: booking write-back, branch availability, results sharing and payment requests, on the partner's API (Calls Clive makes).
  4. Events out: Clive's outbound webhooks (webhooks.md), with the consent gate described in Consent on egress.

TypeScript definitions: packages/shared-types/src/partner.ts. JSON is snake_case. Timestamps are ISO 8601 with an offset (2026-10-24T02:15:09+08:00 or ...Z).

One partner clinic group is one Clive clinic (tenant). Each clinic has its own inbound URL, its own signing secret and its own outbound credential.

Inbound events

Endpoint

POST {Clive API}/webhooks/inbound/{provider}/{integration id or clinic slug}
Content-Type: application/json
X-Partner-Signature: t=1792807209,v1=5f2b…

provider is the provider code Clive agrees with the partner (partner_v1 is the bare contract). The clinic's settings page (Settings › Integrations, under the partner's integration) shows the exact URL, with the integration id; the clinic's booking slug works too.

Envelope

One event per request:

JSON
{
  "id": "evt_01JB7Z6Q4R9W2N3",
  "type": "member.updated",
  "occurred_at": "2026-10-24T02:15:09Z",
  "data": { }
}
Field Rules
id The partner's event id, 1 to 200 characters. Unique per event and the same on every retry of it. Use a UUID or ULID; never reuse an id, including across clinic groups.
type One of the event types. Unknown types are stored and ignored (answered 202).
occurred_at When it happened on the partner's side.
data An object; its shape depends on type.

Signature

X-Partner-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256> where the MAC is computed with the clinic's inbound signing secret (whsec_…) over the string "<t>.<raw request body>". This is the scheme Clive uses for its own outbound webhooks (X-Clive-Signature, see webhooks.md for code in Node, Python and PHP; sign instead of verify).

  • The timestamp must be within 5 minutes of Clive's clock, either way.
  • Several v1= values are allowed; any one matching is enough.
  • The clinic creates and rotates the secret in Settings. It is shown once. After a rotation the previous secret keeps being accepted for 24 hours, so the partner can deploy the new one without dropping events.
  • With no secret configured, every request is refused (fail closed).
JavaScript
const t = Math.floor(Date.now() / 1000)
const body = JSON.stringify(event)
const v1 = crypto.createHmac('sha256', SECRET).update(`${t}.${body}`).digest('hex')
await fetch(url, { method: 'POST', body, headers: { 'Content-Type': 'application/json', 'X-Partner-Signature': `t=${t},v1=${v1}` } })

Responses

Status Meaning Partner should
202 Stored; it will be processed asynchronously. Body { "received": 1, "duplicates": 0, "ids": [...] } Treat as delivered
200 Already received (same id). Nothing was done again. Body { "received": 0, "duplicates": 1 } Treat as delivered
400 Not a valid envelope (not JSON, missing field, bad timestamp) Fix the event; don't retry as is
401 Signature missing, malformed, stale or wrong, or no secret configured Check the secret and the clock; retry after fixing
404 Unknown provider, integration or clinic Check the URL
429 Rate limited (600 requests per minute per source address) Retry with back-off
5xx Clive problem Retry

The response comes before the event is applied. A 202 does not mean the member was updated; it means Clive has the event and will apply it (or record why it could not). Clinic staff see every event, its outcome and any error under Settings › Integrations, and can replay it.

Retries and idempotency

  • Retry on timeouts, connection errors, 429 and 5xx with exponential back-off for at least 24 hours (for example 1 min, 5 min, 30 min, 2 h, 5 h, 8 h, 8 h).
  • Re-send the same envelope with the same id. Clive deduplicates on (provider, clinic, id) and answers 200 to a repeat.
  • Events may arrive out of order. Each data shape carries the partner's own timestamp (updated_at, changed_at), and Clive keeps the newest: an older member record is skipped, and a consent change older than the clinic's record of the same purpose is not applied.
  • Handlers are also idempotent on the partner's business ids (member_id, booking_id, voucher_id, enquiry_id, case_id, request_id), so a replay changes nothing twice.

Event types

Type data Clive does
member.updated Member Creates, links or updates the member's patient record
member.deletion_requested Member request Opens a deletion data subject request for staff (never erases automatically)
member.correction_requested Member request Opens a correction data subject request for staff
consent.changed Consent Records the grant or withdrawal (channel api, source = provider)
booking.created Booking Adds the appointment to the calendar of the venue its branch_ref is mapped to
booking.updated Booking Applies the new start and status (status along the appointment state machine)
booking.rescheduled Booking The older name of booking.updated; still accepted
booking.cancelled Booking Cancels it (voucher bookings: within the voucher's change rules)
availability.blackout Blackout Closes the branch's venue on those days (a read-only closure)
venue.closure Blackout Same as availability.blackout
voucher.purchased Voucher Records the voucher for redemption at the clinic
enquiry.created Enquiry Opens a lead in the clinic's enquiries board
case.created Case Opens a case or complaint
payment.completed Payment Records the payment against the invoice it was requested for
payment.failed Payment Marks the payment request failed
payment.expired Payment Marks the payment request expired
payment.cancelled Payment Marks the payment request cancelled

Member

The partner owns these profile fields: on sync, its values replace the clinic's. The clinic owns everything else (clinical records, notes, status), which sync never touches.

JSON
{
  "member_id": "MEM-0012345",
  "membership_no": "PM00012345",
  "full_name": "Siti Aminah binti Ahmad",
  "identity_number": "900101-14-5678",
  "identity_type": "mykad",
  "phone": "+60123456789",
  "email": "siti@example.com",
  "date_of_birth": "1990-01-01",
  "gender": "female",
  "address": "1 Jalan Ampang, 50450 Kuala Lumpur",
  "updated_at": "2026-10-24T02:15:09Z"
}
  • member_id (required) is the partner's stable member id; membership_no is the card number, when different.
  • updated_at (required) is when the partner last changed this profile.
  • Any other field: omitted = unchanged, null = cleared (full_name can't be cleared).
  • identity_type: mykad, mypr, mykas, mytentera, passport, other. gender: male, female, other.

How a member record becomes a patient:

  1. Already linked (same member_id, or the same membership_no held by exactly one patient): update it.
  2. Exactly one existing patient with the same IC or passport number, not linked to another member: link it and update it.
  3. Weaker matches (same phone or email, name, date of birth), or ambiguous ones: create a new patient, link it, and put the pair in the clinic's duplicate review queue. Clive never merges on its own.
  4. No match: create a patient and link it.

Every run is logged; a field where the clinic's value was replaced is logged as a conflict by field name, with keyed hashes and never the values.

Member request

JSON
{
  "request_id": "DSR-889",
  "member": { "member_id": "MEM-0012345", "membership_no": "PM00012345" },
  "details": "Please correct my date of birth",
  "fields": ["date_of_birth"],
  "requested_at": "2026-10-24T02:15:09Z"
}
JSON
{
  "member": { "member_id": "MEM-0012345" },
  "purpose": "share_results_partner_v1",
  "status": "withdrawn",
  "notice_version": "partner-2026-03",
  "changed_at": "2026-10-24T02:15:09Z"
}

purpose is a Clive consent purpose code: care, share_results_<provider> (your results-sharing purpose), marketing_email, marketing_sms, marketing_whatsapp (any lowercase slug is accepted for future purposes). notice_version names the partner's notice; Clive keeps it as evidence and stamps its own notice in force. A consent event for a member Clive doesn't know yet is retried for a while (the member.updated may still be on its way) and then reconciled.

Booking

JSON
{
  "booking_id": "BK-998877",
  "member": { "member_id": "MEM-0012345", "membership_no": "PM00012345" },
  "start": "2026-11-02T09:00:00+08:00",
  "duration_minutes": 30,
  "status": "confirmed",
  "service_code": "SCREEN-EXEC",
  "practitioner_ref": "DR-17",
  "branch_ref": "KL-01",
  "notes": null
}

member may be a full Member (with updated_at) for a member Clive may not know yet. With a bare reference to a member Clive has not linked, Clive pulls GET {base}/members/{member_id} and applies it through member sync (link on one IC or passport match, else create and send weaker matches to duplicate review); without the feed the event is retried until the member.updated arrives. practitioner_ref is mapped through the clinic's practitioner mapping, else the clinic's default practitioner for partner bookings.

branch_ref is mapped to a venue through the clinic's branch mapping (Settings › Integrations, Branches under the partner's integration). Without any mapping every booking goes to the clinic's main venue; with a mapping, a booking from an unmapped branch fails in the event log (Branch … is not mapped to a venue) for the clinic to map and replay.

status: pending, confirmed, cancelled, checked_in or no_show. booking.updated carries the booking's current start and status. A status reaches the appointment only along Clive's status transition table: a partner can confirm, check in or mark a no-show, but cannot un-cancel, rewind a check-in or override a visit the clinic has completed; such a change is ignored and logged. A reschedule or cancellation of a booking paid with a voucher counts against the voucher's change rules (two changes, none in the last 72 hours, by default); a change past the rules is refused (VOUCHER_CHANGES_EXHAUSTED or VOUCHER_CHANGE_CUTOFF in the event log) for clinic staff to resolve.

Blackout

A blackout or capacity hold: the branch takes no bookings on these days.

JSON
{
  "blackout_id": "BO-2026-12-KL01",
  "branch_ref": "KL-01",
  "date_from": "2026-12-24",
  "date_to": "2026-12-26",
  "kind": "blackout",
  "reason": "Year-end stock take",
  "status": "active"
}
  • date_from and date_to are inclusive local dates at the branch, at most a year apart.
  • kind: blackout (default) or capacity_hold. Both close the day in v1; a partial hold is not modelled yet.
  • status: active (default) or cancelled, which lifts it. Send the same blackout_id to change the dates or lift it.
  • The branch must be mapped to a venue. Clive keeps it as a partner closure (see closure.source under Availability). It is read-only in Clive: staff see it with a badge and cannot remove it (the API answers 409 CLOSURE_READ_ONLY).

Payment

The outcome of a payment request Clive made (Payment requests).

JSON
{
  "reference": "PAY-77812",
  "request_id": "3f6c…",
  "amount": 4500,
  "transaction_id": "TXN-99100",
  "paid_at": "2026-10-24T02:15:09Z",
  "reason": null
}
  • reference (required) is the partner's reference returned when Clive asked for the payment.
  • payment.completed: amount is the number of cents paid (defaults to the requested amount); a partner payment is recorded on the invoice and it moves to paid (or partly paid). A repeat is a no-op.
  • payment.failed, payment.expired, payment.cancelled: reason says why; the request leaves the pending list.
  • A reference that belongs to another clinic is refused. A reference Clive hasn't stored yet (the partner answered before Clive recorded it) is retried.

Voucher

JSON
{
  "voucher_id": "V-55120",
  "code": "AAMX7K2P9",
  "member": { "member_id": "MEM-0012345" },
  "package_code": "PKG-EXEC-2026",
  "valid_from": "2026-10-24T00:00:00+08:00",
  "valid_to": "2027-04-24T23:59:59+08:00",
  "purchased_at": "2026-10-24T02:15:09Z"
}

Enquiry

JSON
{
  "enquiry_id": "ENQ-7781",
  "member": { "member_id": "MEM-0012345" },
  "contact": { "name": "Siti Aminah", "phone": "+60123456789", "email": "siti@example.com" },
  "subject": "Which screening package suits me?",
  "details": "…"
}

Case

JSON
{
  "case_id": "CASE-301",
  "member": { "member_id": "MEM-0012345" },
  "category": "complaint",
  "severity": "medium",
  "subject": "Long wait at the branch",
  "description": "…"
}

category: complaint (default), clinical, billing, service, other. severity: low, medium, high, critical.

Feeds Clive pulls

The partner exposes these under a base URL it gives the clinic, authenticated with a bearer token it issues to the clinic (both entered in Settings). HTTPS only. Each must answer within 15 seconds.

GET {base}/members?updated_since=<ISO>&cursor=<opaque>&limit=100
GET {base}/members/{member_id}
GET {base}/consents?updated_since=<ISO>&cursor=<opaque>&limit=100
Authorization: Bearer <token>
Accept: application/json

List responses are pages:

JSON
{ "data": [ ... ], "next_cursor": "opaque-or-null" }
  • /members items are Member objects; /members/{member_id} returns one, or 404.
  • /consents items are Consent objects, one per member and purpose, with the current state and changed_at of its last change.
  • updated_since is inclusive; without it, the feed returns everything. next_cursor: null ends the feed.
  • 401 or 403 means the token was rejected; Clive stops and records the failure.

How Clive uses them:

Job When What
Initial load A clinic admin presses Run initial load Every page of /members, each applied as a member.updated
Re-sync Re-sync on a patient /members/{member_id} for that member
Consent reconciliation Every 15 minutes /consents?updated_since=<last run − 5 min>; newer timestamp wins (below)

Reconciliation rule, per member and purpose: if the partner's changed_at is newer than Clive's last change and the statuses differ, the partner's state is applied; if Clive's is newer, Clive sends its consent.changed again so the partner catches up; a tie keeps the clinic's record. Each repair is logged. Without a base URL and token, nothing is pulled and nothing fails.

Calls Clive makes

On the same base URL and bearer token as the feeds. Every write carries Idempotency-Key, the same on every retry of one change: apply it once and answer the repeat with the same 2xx. JSON bodies, Content-Type: application/json.

Method and path When Body
POST {base}/bookings/{booking_id}/confirm Staff confirm the booking (or the member confirms by WhatsApp) { occurred_at }
POST {base}/bookings/{booking_id}/check-in The member checks in (front desk, queue, calendar) { checked_in_at }
POST {base}/bookings/{booking_id}/no-show The booking is marked as a no-show { reason, occurred_at }
POST {base}/bookings/{booking_id}/cancel The clinic cancels it { reason, occurred_at }
POST {base}/bookings/{booking_id}/reschedule The clinic moves it { start, duration_minutes, occurred_at }
PUT {base}/branches/{branch_ref}/availability Hours, closures or capacity of the branch's venue change Availability
GET {base}/branches/{branch_ref}/availability?from=&to= Nightly reconciliation Answers { "days": [Day…] }
POST {base}/results A doctor releases results for a member linked to the partner Result share
POST {base}/payment-requests Staff ask the partner to collect a payment from its member Payment request

Responses: any 2xx is success. 400, 404, 409, 410, 422 mean the partner refused the change for good: Clive stops retrying and shows the failure (put a code in the body to say why). 401, 403 mean the token was rejected; 429, 5xx, timeouts (15 seconds) and connection errors are retried.

Booking write-back

Only bookings the partner made (booking.created from this partner) are written back. Every path that changes an appointment in Clive (calendar, queue check-in, WhatsApp confirm or cancel, the appointments API) records the change in the same transaction as an outbox event, and the write-back job pushes it, so a change is never lost. A failed push is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then shown as failed in the job log. Clive never pushes back a change the partner itself sent (a booking.updated that checked the member in does not come back as a check-in), and skips a change that a later one has already replaced. Events may arrive out of order; use occurred_at.

Availability

When a venue's opening hours, closures or service capacity change, Clive pushes the next 90 days of that venue to every branch mapped to it, replacing the partner's view of those days:

JSON
{
  "branch_ref": "KL-01",
  "venue_id": "5a1d…",
  "timezone": "Asia/Kuala_Lumpur",
  "from": "2026-10-24",
  "to": "2027-01-21",
  "generated_at": "2026-10-24T02:15:09Z",
  "days": [
    { "date": "2026-10-24", "closed": false, "closure": null,
      "open": [{ "start": "2026-10-24T01:00:00.000Z", "end": "2026-10-24T05:00:00.000Z" },
               { "start": "2026-10-24T06:00:00.000Z", "end": "2026-10-24T10:00:00.000Z" }] },
    { "date": "2026-10-25", "closed": true, "closure": { "source": "local", "reason": "Renovation" }, "open": [] }
  ]
}

open intervals are UTC instants; closure.source is local (the clinic), holiday (a public holiday the venue observes), or a partner source such as partner (the partner's own blackouts, echoed back). Every night Clive reads GET …/availability for the same 90 days and pushes again when any day differs (closed, or the open intervals as instants). Without a base URL and token nothing is pushed or read. The same days are available to the partner at GET /public/v1/venues/{id}/availability (availability:read).

Result share

When results are released for a member linked to the partner, Clive asks the consent gate for share_results_<provider> (e.g. share_results_partner_v1), at every attempt:

  • Granted: kind: "full" with the structured values and a signed link to the report PDF that works for one hour.
  • Not granted or withdrawn: kind: "notice", saying only that results are available at the clinic. No values, no link.
JSON
{
  "share_id": "8c1e…",
  "kind": "full",
  "result_id": "0b8e…",
  "member": { "id": "c41f…", "member_id": "MEM-0012345", "membership_no": "PM00012345" },
  "branch_ref": "KL-01",
  "released_at": "2026-10-24T02:15:09Z",
  "results": [
    { "analyte": "Glucose (fasting)", "value_numeric": 5.2, "value_text": null, "units": "mmol/L",
      "reference_low": 3.9, "reference_high": 5.6, "flag": "N", "observed_at": "2026-10-23T01:00:00Z" }
  ],
  "report": { "url": "https://…signed…", "expires_at": "2026-10-24T03:15:09Z" }
}

A notice has the same fields without results and report. Deduplicate on share_id. flag is reference-range display (H, L, A, N), not a diagnosis. A FHIR DiagnosticReport rendering may be added to the full share later, as a new field.

Clive records one share per result and partner: its status (pending, shared, withheld, failed), the consent it relied on (id, status, notice version, grant time) and a SHA-256 of what was sent (without the expiring link). Clinic staff see it on the lab order. Every decision is also written to the member's sharing log.

Payment requests

"Request payment via" the partner on an invoice asks the partner to collect from its member in the partner's own payment flow (Clive never handles card data):

JSON
{
  "request_id": "3f6c…",
  "invoice_id": "9d0e…",
  "invoice_number": "INV-2026-000123",
  "purpose": "addon",
  "amount": 4500,
  "currency": "MYR",
  "description": "Vitamin D test",
  "member": { "member_id": "MEM-0012345", "membership_no": "PM00012345" }
}

amount is in cents; purpose is topup, addon or balance. Idempotency-Key is request_id. Answer 2xx with:

JSON
{ "reference": "PAY-77812", "redirect_url": "https://pay.partner.example/…", "expires_at": "2026-10-25T02:15:09Z" }

reference (required, up to 200 characters) names the request in the payment.* events the partner sends when the member pays, the payment fails, expires or is cancelled (Payment). redirect_url (https) and expires_at are optional.

Events out

Clive's outbound webhooks are described in webhooks.md. The partner registers its endpoint through the public API (webhooks:manage) or the clinic does in Settings › Webhooks.

consent.changed carries source: the provider code when the change came from that partner (for example "partner_v1" for a change a partner on the bare contract sent in), so the partner can recognise and ignore the echo of its own change; null otherwise.

member.updated is sent when clinic staff correct a partner-owned field (field names only, never values). member.merged is sent when the clinic merges duplicate records.

Every send of member data to a partner passes one consent gate, and every decision is logged on the patient (Consent tab › Sharing) with the purpose, consent status and notice version it relied on.

Outbound event Purpose Needs
result.released share_results_<provider> An explicit grant
member.updated, member.merged care Not withdrawn
consent.changed not gated Always sent, so a withdrawal reaches the partner
appointment.*, voucher.redeemed, lead.status_changed, case.status_changed not gated Ids and statuses of the partner's own bookings, vouchers and enquiries
Booking write-back, availability, payment requests not gated The partner's own bookings and branches, and an amount it is asked to collect
Result share (POST {base}/results) share_results_<provider> An explicit grant for full; otherwise only the notice
GET /public/v1/members/{id}/results share_results_<provider> An explicit grant; else 403 CONSENT_REQUIRED
GET /public/v1/members/{id}, /members/by-membership/{no} care Not withdrawn; else 403 CONSENT_REQUIRED

The gate applies to endpoints registered by a partner's API client; endpoints staff register in Settings are the clinic's own systems. It is checked at every delivery attempt: if the member withdraws between two retries, the next attempt is not made and the delivery ends as cancelled (consent_withdrawn). Public API reads of results and profiles use the same gate (assertConsent), which answers 403 CONSENT_REQUIRED.

Withdrawal propagates both ways within an hour: a withdrawal in Clive is effective at once (the gate reads it live) and goes out as consent.changed; a withdrawal at the partner arrives as consent.changed and is applied when processed; anything lost on either side is repaired by the 15-minute reconciliation.