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:
- Events in: the partner pushes signed events to Clive (Inbound events).
- 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).
- Calls Clive makes: booking write-back, branch availability, results sharing and payment requests, on the partner's API (Calls Clive makes).
- 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:
{
"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).
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.
{
"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_nois the card number, when different.updated_at(required) is when the partner last changed this profile.- Any other field: omitted = unchanged,
null= cleared (full_namecan't be cleared). identity_type:mykad,mypr,mykas,mytentera,passport,other.gender:male,female,other.
How a member record becomes a patient:
- Already linked (same
member_id, or the samemembership_noheld by exactly one patient): update it. - Exactly one existing patient with the same IC or passport number, not linked to another member: link it and update it.
- 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.
- 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
{
"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"
}Consent
{
"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
{
"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.
{
"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_fromanddate_toare inclusive local dates at the branch, at most a year apart.kind:blackout(default) orcapacity_hold. Both close the day in v1; a partial hold is not modelled yet.status:active(default) orcancelled, which lifts it. Send the sameblackout_idto change the dates or lift it.- The branch must be mapped to a venue. Clive keeps it as a partner closure (see
closure.sourceunder Availability). It is read-only in Clive: staff see it with a badge and cannot remove it (the API answers409 CLOSURE_READ_ONLY).
Payment
The outcome of a payment request Clive made (Payment requests).
{
"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:amountis 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:reasonsays 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
{
"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
{
"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
{
"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/jsonList responses are pages:
{ "data": [ ... ], "next_cursor": "opaque-or-null" }/membersitems are Member objects;/members/{member_id}returns one, or 404./consentsitems are Consent objects, one per member and purpose, with the current state andchanged_atof its last change.updated_sinceis inclusive; without it, the feed returns everything.next_cursor: nullends 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:
{
"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.
{
"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):
{
"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:
{ "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.
Consent on egress
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.