Webhooks
A subscription pairs a trigger with an http destination; when it fires, Blue Banana POSTs a signed JSON payload to your URL.
Request and payload schemas are in the API reference (Subscriptions, Webhooks).
1 · Endpoints
Base https://bluebananaehr.com. Auth is Authorization: Bearer bb_…; the key decides the tenant.
| Request | Does |
|---|---|
| GET /api/subscriptions | List your subscriptions. |
| POST /api/subscriptions | Create. The response carries the signing secret once, as secret_view. |
| GET /api/subscriptions/{id} | Read one. |
| PUT /api/subscriptions/{id} | Replace name, trigger, destination (all required) and enabled (kept if omitted). The secret is kept. |
| DELETE /api/subscriptions/{id} | 204. Pending fires for it are dropped. |
| GET /api/subscriptions/{id}/deliveries | Delivery log — see §8. |
| GET /api/subscriptions/events | Named event catalogue. No key needed. |
- Up to 25 subscriptions per tenant, enabled or not; the next create answers
429. - Validation errors answer
422with anOperationOutcome. - Another tenant's subscription id answers
404. - The secret is 64 hex characters and is never shown again. To change it, create a new subscription and delete the old one.
nameis required, at most 80 characters.enableddefaults totrue.
curl -X POST https://bluebananaehr.com/api/subscriptions \
-H "Authorization: Bearer bb_…" \
-H "Content-Type: application/json" \
-d '{
"name": "Appointment cancelled",
"trigger": { "kind": "event", "events": ["appointment.cancelled"] },
"destination": { "kind": "http", "url": "https://example.com/hook" }
}'2 · Trigger kinds
| kind | Fields | Fires |
|---|---|---|
| event | events: 1–20 names from §3 | On a FHIR write matching any of the events. |
| criteria | resourceType, interactions, fhirpath, changed | On a FHIR write matching every filter. |
| template | template + its parameter (table below) | On a schedule. |
criteria
resourceType: any of the 43 supported types.interactions: any ofcreate,update,delete. Default["create", "update"].fhirpath: up to 10 boolean expressions, each at most 1000 characters, AND-combined.%previousis the version before the write.changed: 1–10 FHIRPath paths; fires only if at least one evaluates differently from the previous version. On create every set field counts as changed.- Transitions:
"changed": ["status"], "fhirpath": ["status = 'cancelled'"]fires once per cancellation;"fhirpath": ["%previous.start != start"]fires on a reschedule.
template
| template | Parameter | Fires | data.resource |
|---|---|---|---|
| appointment_reminder | offsetMinutes 5–43200 (30 days) | Once per appointment, offset before start. | The Appointment |
| appointment_no_show | offsetMinutes 5–1440 (24 h) | Once per appointment, offset after start, only if still booked. | The Appointment |
| daily_at | hourUtc 0–23 | Daily at that hour, UTC. | null |
| patient_birthday | hourUtc 0–23 | Daily at that hour: one delivery per patient with a birthday that day, at most 50. | The Patient |
| cron | expression: 5-field, UTC, at most 120 characters | On the schedule. | null |
- Appointment templates send the Appointment as it reads at fire time, and skip it if it was deleted, cancelled or rescheduled in the meantime.
- Schedules are evaluated every 5 minutes. A cron expression that fires more often misses the fires in between.
3 · Named events
47 events. Each is a preset criteria filter; status events fire when the status becomes that value. Names are stable. Machine-readable list: GET /api/subscriptions/events.
| Event | Resource | When |
|---|---|---|
| patient.created | Patient | A new patient record is created. |
| patient.updated | Patient | Any change to a patient record. |
| patient.deactivated | Patient | A patient is marked inactive (active = false). |
| patient.deleted | Patient | A patient record is deleted. |
| appointment.created | Appointment | A new appointment is created (any status). |
| appointment.updated | Appointment | Any change to an appointment. |
| appointment.booked | Appointment | An appointment becomes booked — created booked, or a request is confirmed. |
| appointment.cancelled | Appointment | An appointment becomes cancelled. |
| appointment.rescheduled | Appointment | An existing appointment moves to a new start time. |
| appointment.arrived | Appointment | The patient is marked arrived. |
| appointment.checked_in | Appointment | The patient is checked in. |
| appointment.completed | Appointment | The visit is completed / checked out (status fulfilled). |
| appointment.no_show | Appointment | The patient is marked as a no-show. |
| appointment.deleted | Appointment | An appointment is deleted. |
| encounter.created | Encounter | A new encounter (visit) is started. |
| encounter.updated | Encounter | Any change to an encounter. |
| encounter.finished | Encounter | An encounter is finished / signed off. |
| note.created | DocumentReference | A clinical note or document is added to a chart. |
| note.signed | DocumentReference | A clinical note is signed (docStatus final). |
| lab_result.created | DiagnosticReport | A lab/diagnostic report is added. |
| lab_result.final | DiagnosticReport | A diagnostic report becomes final. |
| observation.created | Observation | A vital sign, lab value or other observation is recorded. |
| order.created | ServiceRequest | A lab, imaging or procedure order (ServiceRequest) is placed. Referrals fire referral.created instead. |
| order.updated | ServiceRequest | Any change to a lab, imaging or procedure order. Referrals fire referral.updated instead. |
| referral.created | ServiceRequest | A referral (ServiceRequest with a referral category) is created. |
| referral.updated | ServiceRequest | Any change to a referral. |
| medication.prescribed | MedicationRequest | A new prescription (MedicationRequest) is written. |
| medication.updated | MedicationRequest | Any change to a prescription, e.g. a refill approval or discontinuation. |
| allergy.created | AllergyIntolerance | An allergy or intolerance is added. |
| condition.created | Condition | A condition is added to the problem list. |
| immunization.created | Immunization | An immunization is recorded. |
| procedure.created | Procedure | A procedure is recorded. |
| care_plan.created | CarePlan | A care / treatment plan is created. |
| care_plan.updated | CarePlan | Any change to a care / treatment plan. |
| coverage.created | Coverage | Insurance coverage is added to a patient. |
| coverage.updated | Coverage | Any change to a patient’s coverage. |
| claim.created | Claim | A claim is created. |
| claim.updated | Claim | Any change to a claim. |
| eob.created | ExplanationOfBenefit | An explanation of benefit (remittance) is received. |
| charge.created | ChargeItem | A billable charge (ChargeItem) is created, e.g. when a visit is signed. |
| invoice.created | Invoice | An invoice is created. |
| invoice.paid | Invoice | An invoice is paid in full (status balanced). |
| payment.received | PaymentReconciliation | A payment is posted (PaymentReconciliation). |
| task.created | Task | A task lands in the clinical inbox (message, refill request, result to review, …). |
| task.completed | Task | An inbox task is completed. |
| message.received | Communication | A message (Communication) is recorded. |
| form.submitted | QuestionnaireResponse | A patient form / questionnaire is submitted (QuestionnaireResponse completed). |
4 · Payload
| event.type | Sent for |
|---|---|
| criteria.matched | event and criteria triggers, right after the write. |
| template.fired | template triggers; event.trigger echoes the template and its parameter. |
{
"delivery_id": "del_…",
"subscription_id": "sub_…",
"tenant": "dev-…",
"event": {
"type": "criteria.matched",
"fired_at": "2026-09-30T14:05:00.000Z",
"interaction": "update",
"trigger": { "kind": "event", "resourceType": "Appointment" },
"name": "appointment.cancelled",
"names": ["appointment.cancelled", "appointment.updated"]
},
"data": {
"resource": { "resourceType": "Appointment", "id": "…", "status": "cancelled", "meta": { "versionId": "3" } },
"previous": { "resourceType": "Appointment", "id": "…", "status": "booked", "meta": { "versionId": "2" } },
"changes": ["status"],
"context": { "patientId": "…", "practitionerId": "…", "status": "cancelled", "start": "…", "end": "…" }
}
}event.interaction:create,updateordelete. On delete,data.resourceis the last version.event.names: the subscribed events this write matched (event triggers only);event.nameis the first. One delivery per write per subscription.- Updates add
data.previous(the version before the write) anddata.changes(top-level fields that differ, ignoringmeta,extension,id,text). meta.versionIdis1on create and +1 per update.- Every resource carries the
http://bluebananaehr.com/synthetic-dataextension. data.context: flat fields for mapping without parsing references.patientIdfor any patient-linked resource; Appointments addpractitionerId,status,start,endandreason. Missing values are left out.- Test deliveries carry
data.context.test = true.
5 · Destination and headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Keragon-Signature | t=<unix>,v1=<hex> — see §6 |
| X-Keragon-Delivery | del_<uuid>, the same on every retry. Use it to dedupe. |
| Your static headers | From destination.headers. |
destination:{ "kind": "http", "url": "https://…", "headers": { … } }.httpis the only kind.- Up to 8 static headers, values at most 256 characters.
Host,Content-Length,Content-TypeandX-Keragon-*are reserved. - URLs resolving to private, loopback, link-local or unique-local addresses are rejected at registration and again at delivery. So are
bluebananaehr.comURLs, except the echo sink. - Redirects are not followed.
- You cannot supply the secret; the server generates it.
6 · Verify the signature
HMAC-SHA256 over ${t}.${rawBody} with the subscription secret, hex-encoded. Compare in constant time and reject if |now − t| exceeds 300 seconds.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(rawBody: string, header: string, secret: string): boolean {
const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header.trim());
if (!m) return false;
const t = Number(m[1]);
if (Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(m[2], 'hex');
return a.length === b.length && timingSafeEqual(a, b);
}7 · Retries
| Outcome | Result |
|---|---|
| 2xx | Delivered. |
| 5xx, 408, 429, network error, no response within 10 s | Retried with exponential backoff, up to 5 attempts in total. |
| 3xx, any other 4xx | Failed. Not retried. |
A fire whose subscription was deleted or disabled before delivery is dropped.
8 · Delivery log and testing
GET /api/subscriptions/{id}/deliveries?limit=(1–200, default 50): one row per attempt, newest first —delivery_id,attempt,event_type,event_name,resource_ref,url,status_code,ok,duration_ms,error,created_at. The last 200 attempts are kept per subscription.- Test delivery: the MCP tool
test_webhook_subscriptionsends a sample payload built from a real sandbox resource, withdata.context.test = true. - Echo sink:
https://bluebananaehr.com/api/webhooks/echoanswers 200 and stores nothing. Point a subscription at it, trigger it, then read the delivery log.