Blue Banana

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.

RequestDoes
GET /api/subscriptionsList your subscriptions.
POST /api/subscriptionsCreate. 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}/deliveriesDelivery log — see §8.
GET /api/subscriptions/eventsNamed event catalogue. No key needed.
  • Up to 25 subscriptions per tenant, enabled or not; the next create answers 429.
  • Validation errors answer 422 with an OperationOutcome.
  • 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.
  • name is required, at most 80 characters. enabled defaults to true.
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

kindFieldsFires
eventevents: 1–20 names from §3On a FHIR write matching any of the events.
criteriaresourceType, interactions, fhirpath, changedOn a FHIR write matching every filter.
templatetemplate + its parameter (table below)On a schedule.

criteria

  • resourceType: any of the 43 supported types.
  • interactions: any of create, update, delete. Default ["create", "update"].
  • fhirpath: up to 10 boolean expressions, each at most 1000 characters, AND-combined. %previous is 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

templateParameterFiresdata.resource
appointment_reminderoffsetMinutes 5–43200 (30 days)Once per appointment, offset before start.The Appointment
appointment_no_showoffsetMinutes 5–1440 (24 h)Once per appointment, offset after start, only if still booked.The Appointment
daily_athourUtc 0–23Daily at that hour, UTC.null
patient_birthdayhourUtc 0–23Daily at that hour: one delivery per patient with a birthday that day, at most 50.The Patient
cronexpression: 5-field, UTC, at most 120 charactersOn 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.

EventResourceWhen
patient.createdPatientA new patient record is created.
patient.updatedPatientAny change to a patient record.
patient.deactivatedPatientA patient is marked inactive (active = false).
patient.deletedPatientA patient record is deleted.
appointment.createdAppointmentA new appointment is created (any status).
appointment.updatedAppointmentAny change to an appointment.
appointment.bookedAppointmentAn appointment becomes booked — created booked, or a request is confirmed.
appointment.cancelledAppointmentAn appointment becomes cancelled.
appointment.rescheduledAppointmentAn existing appointment moves to a new start time.
appointment.arrivedAppointmentThe patient is marked arrived.
appointment.checked_inAppointmentThe patient is checked in.
appointment.completedAppointmentThe visit is completed / checked out (status fulfilled).
appointment.no_showAppointmentThe patient is marked as a no-show.
appointment.deletedAppointmentAn appointment is deleted.
encounter.createdEncounterA new encounter (visit) is started.
encounter.updatedEncounterAny change to an encounter.
encounter.finishedEncounterAn encounter is finished / signed off.
note.createdDocumentReferenceA clinical note or document is added to a chart.
note.signedDocumentReferenceA clinical note is signed (docStatus final).
lab_result.createdDiagnosticReportA lab/diagnostic report is added.
lab_result.finalDiagnosticReportA diagnostic report becomes final.
observation.createdObservationA vital sign, lab value or other observation is recorded.
order.createdServiceRequestA lab, imaging or procedure order (ServiceRequest) is placed. Referrals fire referral.created instead.
order.updatedServiceRequestAny change to a lab, imaging or procedure order. Referrals fire referral.updated instead.
referral.createdServiceRequestA referral (ServiceRequest with a referral category) is created.
referral.updatedServiceRequestAny change to a referral.
medication.prescribedMedicationRequestA new prescription (MedicationRequest) is written.
medication.updatedMedicationRequestAny change to a prescription, e.g. a refill approval or discontinuation.
allergy.createdAllergyIntoleranceAn allergy or intolerance is added.
condition.createdConditionA condition is added to the problem list.
immunization.createdImmunizationAn immunization is recorded.
procedure.createdProcedureA procedure is recorded.
care_plan.createdCarePlanA care / treatment plan is created.
care_plan.updatedCarePlanAny change to a care / treatment plan.
coverage.createdCoverageInsurance coverage is added to a patient.
coverage.updatedCoverageAny change to a patient’s coverage.
claim.createdClaimA claim is created.
claim.updatedClaimAny change to a claim.
eob.createdExplanationOfBenefitAn explanation of benefit (remittance) is received.
charge.createdChargeItemA billable charge (ChargeItem) is created, e.g. when a visit is signed.
invoice.createdInvoiceAn invoice is created.
invoice.paidInvoiceAn invoice is paid in full (status balanced).
payment.receivedPaymentReconciliationA payment is posted (PaymentReconciliation).
task.createdTaskA task lands in the clinical inbox (message, refill request, result to review, …).
task.completedTaskAn inbox task is completed.
message.receivedCommunicationA message (Communication) is recorded.
form.submittedQuestionnaireResponseA patient form / questionnaire is submitted (QuestionnaireResponse completed).

4 · Payload

event.typeSent for
criteria.matchedevent and criteria triggers, right after the write.
template.firedtemplate 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, update or delete. On delete, data.resource is the last version.
  • event.names: the subscribed events this write matched (event triggers only); event.name is the first. One delivery per write per subscription.
  • Updates add data.previous (the version before the write) and data.changes (top-level fields that differ, ignoring meta, extension, id, text).
  • meta.versionId is 1 on create and +1 per update.
  • Every resource carries the http://bluebananaehr.com/synthetic-data extension.
  • data.context: flat fields for mapping without parsing references. patientId for any patient-linked resource; Appointments add practitionerId, status, start, end and reason. Missing values are left out.
  • Test deliveries carry data.context.test = true.

5 · Destination and headers

HeaderValue
Content-Typeapplication/json
X-Keragon-Signaturet=<unix>,v1=<hex> — see §6
X-Keragon-Deliverydel_<uuid>, the same on every retry. Use it to dedupe.
Your static headersFrom destination.headers.
  • destination: { "kind": "http", "url": "https://…", "headers": { … } }. http is the only kind.
  • Up to 8 static headers, values at most 256 characters. Host, Content-Length, Content-Type and X-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.com URLs, 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

OutcomeResult
2xxDelivered.
5xx, 408, 429, network error, no response within 10 sRetried with exponential backoff, up to 5 attempts in total.
3xx, any other 4xxFailed. 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_subscription sends a sample payload built from a real sandbox resource, with data.context.test = true.
  • Echo sink: https://bluebananaehr.com/api/webhooks/echo answers 200 and stores nothing. Point a subscription at it, trigger it, then read the delivery log.