Receive signed events

The clinic points one https address at your system. Moonshot EHR sends it a signed JSON POST when something happens in the patient journey, and the clinic sees every send, with its outcome, in a delivery log.

Turning it on

The clinic sets this up in its Settings (Notifications, Marketing webhook). Nothing is sent until it saves an address and ticks events.

Event catalog

EventWhen it firesClinic confirms BAA
lead.createdStaff add a lead by hand on the Leads page, whatever source they pick. Never sent for leads that arrived from the inbound lead webhook (website forms, agency CRMs), website chat, Communications, or the public intake and registration forms, so a CRM never receives its own leads back.No
lead.convertedStaff click Convert to patient and a new patient is created.No
form.completedA form is completed (patient link, portal, mobile intake, staff in the office), or a public interest or registration form is submitted.Yes
appointment.bookedAn appointment is created: online booking, staff booking, patient portal, or a recurring series. A pre-paid online booking still waiting for payment is not sent.Yes
appointment.rescheduledAn appointment's start time changes. A provider-only change is not a reschedule.Yes
appointment.cancelledAn appointment is cancelled or removed.Yes

lead.created and form.completed are never sent for imports or by demo clinics. They are recorded by the EHR itself whenever a lead is created or a form is completed, so every path is covered (for lead.created, only the staff-entered leads above are then sent); their event_id is the id of that record. Rarely, if the record cannot be written at that moment (for example a database lock conflict), the fact is skipped and logged on Moonshot's side, and no event is sent for it.

Not available yet: payment and membership events.

The request

POST <your URL>, Content-Type: application/json, User-Agent: MoonshotEHR-Webhook/1, plus:

HeaderValue
X-Moonshot-EventThe event type, for example appointment.booked.
X-Moonshot-DeliveryA UUID; the same on every retry of the same event.
X-Moonshot-Signaturesha256=<hex>, only when a secret is set.

Payloads

Appointment events carry opaque ids, the type, times, visit mode, booking source, who made the change and the ad click ids captured at booking. Never the patient's name, email, phone, notes, reasons for visit, diagnoses or form answers. The appointment type name is included only when the clinic turns that option on.

{
  "event": "appointment.booked",
  "event_id": "5f0c…",
  "occurred_at": "2026-11-15T14:02:11.000Z",
  "tenant_slug": "northside",
  "appointment_id": "8d1c…",
  "patient_id": "f47a…",
  "appointment_type_id": "1c9e…",
  "appointment_type": "New patient consult",
  "start_time": "2026-11-16T15:00:00.000Z",
  "end_time": "2026-11-16T15:30:00.000Z",
  "visit_mode": "in_person",
  "booking_source": "Website",
  "actor": "patient",
  "attribution": { "utm_source": "google", "utm_campaign": "launch", "gclid": "Cj0…" }
}

lead.created carries the lead's contact details. source is the source staff picked when they added the lead (for example phone, walk_in or referral), or null; attribution and service_interest are what the lead was captured with, or null:

{
  "event": "lead.created",
  "event_id": "5f0c…",
  "occurred_at": "2026-11-15T14:02:11.000Z",
  "tenant_slug": "northside",
  "lead_id": "8d1c…",
  "email": "dana@example.com",
  "phone": "6125550142",
  "first_name": "Dana",
  "last_name": "Prospect",
  "source": "phone",
  "attribution": null,
  "service_interest": null
}

form.completed carries ids and the kind only. Form answers are never sent:

{
  "event": "form.completed",
  "event_id": "7a21…",
  "occurred_at": "2026-11-15T14:05:40.000Z",
  "tenant_slug": "northside",
  "submission_id": "c3d4…",
  "kind": "intake_registration",
  "form_template_id": "1c9e…",
  "form_template_name": "New patient registration",
  "lead_id": "8d1c…",
  "patient_id": null,
  "appointment_id": null
}

lead.converted keeps its original body (it carries the lead's contact details):

{
  "event": "lead.converted",
  "tenant_slug": "northside",
  "lead_id": "8d1c…",
  "patient_id": "f47a…",
  "email": "dana@example.com",
  "phone": "6125550142",
  "first_name": "Dana",
  "last_name": "Prospect",
  "source": "event",
  "attribution": { "utm_source": "facebook" },
  "service_interest": null,
  "converted_at": "2026-10-02T15:04:05.000Z"
}

Use event_id (or X-Moonshot-Delivery) to ignore a duplicate: a retry after a timeout can arrive after you already accepted the first try.

Verifying the signature

HMAC-SHA256 of the exact raw request body with the shared secret, hex encoded, prefixed sha256=. Compare in constant time, before parsing the JSON.

import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
  const a = Buffer.from(String(header || ''));
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hmac, hashlib

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Delivery and retries

The other direction

New leads travel the other way: see Send leads to Moonshot EHR. The clinic sees and rotates that key on the same Settings card.

Who receives what

Lead events carry contact details, appointment events carry a patient id with an appointment type and time, and form events carry the ids of a form submission and the patient or lead it belongs to. The clinic decides where those go, and points the address only at a system covered by its agreements with that vendor (a BAA where one is required).