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.
- Address:
https://on port 443, a public host. IP addresses,localhost, private networks and Moonshot's own domains are refused. Redirects are never followed. - Events: each one is opt-in, and a clinic only receives events that happen after it turns one on. Turning on an appointment or form event requires the clinic to confirm the receiver is covered by its BAA with you and that the events are not forwarded to advertising platforms. Lead events need no such confirmation.
- Signing secret: generated by the clinic and shown once; copy it into your receiver. Replacing it signs every later send, including queued retries, with the new one.
- Send test: sends a sample (
<event>.test, placeholder values) and shows the result next to the button.
Event catalog
| Event | When it fires | Clinic confirms BAA |
|---|---|---|
lead.created | Staff 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.converted | Staff click Convert to patient and a new patient is created. | No |
form.completed | A form is completed (patient link, portal, mobile intake, staff in the office), or a public interest or registration form is submitted. | Yes |
appointment.booked | An 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.rescheduled | An appointment's start time changes. A provider-only change is not a reschedule. | Yes |
appointment.cancelled | An 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:
| Header | Value |
|---|---|
X-Moonshot-Event | The event type, for example appointment.booked. |
X-Moonshot-Delivery | A UUID; the same on every retry of the same event. |
X-Moonshot-Signature | sha256=<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…" }
}
visit_mode:in_person,telehealthormobile.actor:patient,stafforsystem.appointment.rescheduledaddsprevious_start_time, andprevious_appointment_idwhen the patient portal replaced the appointment (otherwisenull).appointment.cancelledhas the same fields asappointment.booked; restoring a cancelled appointment sends nothing.
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
}
kind:form(a form on a patient chart),intake_interest(the public interest form) orintake_registration(the public registration form).submission_idis the form submission id, or the intake id for the two intake kinds.lead_id,patient_idandappointment_idare set when there is one, otherwisenull.form_template_nameis present only when the clinic turns on sending names (a form name can reveal the kind of care).- Editing a form that is already completed does not send a second
form.completed. - A refill request a patient sends from the portal with its questionnaire answered is stored as a completed form, so it sends
form.completed(picked up by the next 5-minute run, so usually within about 5 minutes). - A lead event or form event is not sent if, by its first attempt, the lead was archived or the patient's chart was deleted.
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
- Answer with any
2xxwithin 5 seconds. The response body is ignored. - First attempt. Appointment events: within seconds.
lead.created: within seconds after staff save the lead.form.completed: within seconds when the form arrives through a patient form link, the patient portal or the public intake forms; otherwise at the EHR's next 5-minute run, so usually within about 5 minutes; longer when the clinic has a backlog or its receiver is timing out. When your receiver times out, that clinic's other waiting events pause for 15 minutes. - Retries (every event except
lead.converted): a timeout, connection error,408,429or5xxis retried after waits of about 1, 5, 30 and 120 minutes (5 attempts in all). Retries run on the EHR's 5-minute schedule, so each real gap can be up to about 5 minutes longer. Any other4xxor a redirect is final. lead.converted: one quick retry about a second later, then final.- An event still not delivered 24 hours after it happened is not sent later. Unticking an event stops queued ones of that type.
- At most 60 attempts per clinic per minute; a burst is spread over the following minutes, never dropped.
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).