This page tells you which fields you can rely on being present, per event — so you can decide what to type as required vs. optional in your consumer.
The example payload on an event's reference page shows the shape of a delivery. It is not a presence contract: a field appearing in an example does not mean it is sent on every delivery. Presence is determined by the code path that emits the event, and it varies by event. The tables below are the authoritative source, verified against the emitting code (audit date: 2026-07-17).
This page describes v1 (legacy) payloads — the fields as they are set when each event is created. v2 payloads apply an additional sanitization layer on top of this: even a field that is "Always present" here may be dropped from v2 if it is not on its category's allowlist. See Webhooks v2 — Overview and the v2 note at the bottom of this page.
How to read a delivery
Every event, regardless of type, carries a fixed envelope. These fields are schema-enforced and always present:
| v1 (legacy) | v2 | Notes |
|---|---|---|
eventType | event_type | Same value in both versions (e.g. order_created) |
_id / id | event_id | Stable across retries — safe as your idempotency key |
targetEntity, targetEntityModel | — (v2 exposes typed ids instead) | |
ownerEntity, ownerEntityModel | — | |
eventTitle | — (not in v2) | Free-text; do not parse |
eventDescription | — (not in v2) | Free-text, and often absent |
createdAt, updatedAt | timestamp | Not equivalent. v1 createdAt is the event's creation time and is stable across retries. v2 timestamp is the delivery-attempt time — it is regenerated on every retry, so a retried older event can carry a newer timestamp than a fresh event. Do not use v2 timestamp to order events; v2 has no stable creation-time field today. |
deleted, __v | — | |
| — | api_version | Fixed schema version, currently "2026-08-09" |
| — | affiliate_id | |
| — | patient_id, patient_external_id, patient_marketing_consent, is_returning_patient | v2 always enriches with these four |
| — | attribution, journey_id | ⚠️ Conditional independently. journey_id is present when the event has a resolved journey relationship. attribution is present only when the journey captured tracking codes. Each field is omitted when its own condition is not met. Neither is emitted on support or patient events. See Attribution Passthrough |
Everything else lives in the event-specific payload — eventData (v1, nested) or the top-level snake_case fields (v2). That is what the tables below govern.
eventTitle/eventDescriptionare human-readable strings that can change without notice, and they do not exist in v2. Never key business logic off them — useeventTypeand the structured fields.
✅ Always present
For these events, the listed field(s) are set unconditionally by every emitting code path — safe to type as required (for v1).
| Event | Guaranteed eventData field(s) |
|---|---|
order_created | orderId |
order_expedited | performedBy {id, role, name} |
affiliate_admin_was_assigned | performedBy {id, role, name} |
consultation-billed-to-affiliate | amount |
consultation-billed-to-patient | amount |
order_asset_unlocked | orderAssetId, orderId, unlockedAt |
patient_communication_answered_call | performedBy {id, role, name} |
patient_communication_unanswered_call | performedBy {id, role, name} |
patient_communication_text_message_sent | performedBy {id, role, name} |
patient_communication_follow_up_required | performedBy {id, role, name} |
pharmacy_bundle_fax_sent | pharmacy |
pharmacy_prescription_status_update | newStatus |
prescription_approved_by_practitioner | performedBy {id, role, name}, prescription |
prescription_sent_to_pharmacy | pharmacy, affiliate |
provider_was_assigned | practitioner |
provider_was_reassigned | practitioner |
visit_type_updated | visitId, performedBy {id, role, name} |
subscription_renewed | subscription_id, subscription_status |
Always present when the event fires — but the event fires only conditionally
The fields are reliable, but the event itself is only emitted under a specific condition. Handle the field as required inside your handler, but don't assume the event always arrives.
| Event | Guaranteed field(s) | Only fires when |
|---|---|---|
lab_order_critical_result_received | labOrderId, labName, interpretation, externalOrderId, providerEventType, providerOrderStatus, latestProviderEventStatus, reportedAt, webhookPayload | A critical lab result is received |
lab_order_abnormal_result_received | (same as critical) | An abnormal result is received and abnormal-follow-up is enabled for the affiliate |
visit_unlocked | orderId, visitId, visitIds | A visit is unlocked |
order_ready_for_submission | — (envelope + order entity fields) | Affiliate settings require manual submission and the order reaches the ready state |
visit_type_overridden | performedBy {id, role, name} | The project defines a visit-type override |
pharmacy_processing_prescription | pharmacy_status | Pharmacy lifecycle webhook fires |
pharmacy_fulfilled_prescription | pharmacy_status | Pharmacy lifecycle webhook fires |
shipment_delivered | shipment_status, tracking fields | Package delivery is recorded |
Envelope only (no event-specific fields — by design)
These deliver just the envelope; eventData is intentionally empty. There are no payload fields to rely on.
order_submitted · prescription_refill · external_questionnaire_instance_synchronization_attempt
⚠️ Conditional
For these events, some or all event-specific fields are not guaranteed — they depend on which code path fired, on optional data, or on an integration being configured. Type these as optional / nullable.
| Event | Reliable | Conditional / may be absent |
|---|---|---|
new_status_set_to_request | newStatus | performedBy (only on some firings) |
order_updated | — | action, performedBy (most firings send empty eventData) |
order_payment_collected | — | performedBy (only some firings) |
order_product_variation_transitioned | — | titrationConfig, configName, statusTransition, etc. (shape varies by firing) |
case_status_updated | caseId | externalCaseStatus (may be undefined) |
shipping_details_set | shippingDetails (object always present) | trackingNumber (the CTRL-pharmacy path can fire with only a trackingUrl, and the admin-set path passes the submitted details through unvalidated); shippingCompany (optional on the CTRL and admin paths); order (absent on admin-set and some transformer paths) |
message-from-admin-practitioner-received | patientConversationId, message, type, sender | senderRole (only for known sender ids) |
message-from-patient-received | patientConversationId, message, type, channelType, sender, senderRole | senderRole is always "patient" |
order_reminder_sent | orderLink, reminderType | reminderType is initial or scheduled |
support_case_created | keys always present | externalCaseIdentifier, externalCaseStatus, externalCaseUrl may be empty strings when Jira omits them |
support_case_status_updated | — | Field set differs between status-change and assignee-change firings |
lab_order_status_update | newStatus (most paths) | performedBy; lab-integration paths emit a different shape entirely |
lab_order_sent_to_lab | — | Fields present only via the Junction integration; the SpotLab path sends empty eventData |
prescription_pharmacy_delivery_error | error present | The internal shape of error is a raw pass-through — not a guaranteed {message, code} |
order_appointment_scheduled | orderId, appointmentType, startDateTime, duration | participants may be empty; only emitted by the appointment-scheduling integration |
journey_checkout_started | journey_id, affiliate_id, template_id, total_value_cents, currency, products | template_display_name, journey_type, patient_id, patient_external_id, started_at, coupon_code, attribution |
journey_completed | (same as checkout_started) | (same as checkout_started) |
journey_abandoned | (same, plus recovery_url, recovery_url_expires_at) | (same as checkout_started) |
ecommerce_order_paid | ecommerce_order_id, commerce_status, total_value_cents, currency | payment_status |
ecommerce_order_refunded | ecommerce_order_id, commerce_status, refund_amount_cents, refund_type | payment_status |
subscription_cancelled | subscription_id, subscription_status | total_value_cents, currency |
subscription_paused | subscription_id, subscription_status | total_value_cents, currency |
subscription_payment_failed | subscription_id, subscription_status | ecommerce_order_id, commerce_status (not set by live renewal dead-letter emission — test-fire samples may include them) |
post_purchase_disqualification_processed | order_id; ecommerce_order_id, commerce_status (v1: from targetEntity; v2: sanitizer) | refund_amount_cents (successful refund branch only — absent on void and when a refund attempt fails); orchestration detail fields (branch, failed, subscription_cancelled, clinical_order_id, note_id, disqualifiers) are set in v1 eventData but stripped from v2 |
⛔ Not delivered
Envelope only — documented event-specific fields are not actually sent. These events fire, but their eventData is empty; do not rely on any payload fields. (In v2 you still get the envelope + patient-identity block.)
patient_enrolled · payment_declined
Never emitted — no current code path creates these. Subscribing will not reliably deliver a webhook. Do not build on them until they are implemented.
pharmacy_rejected_prescription · order_entered_waiting_room
v2 note — fields redacted relative to v1
v2 payloads run the v1 eventData through a per-category allowlist and a PHI blocklist, so a field that is "Always present" above can still be absent from v2 if it is not on its category's allowlist. The most impactful redactions for integrators:
| Event | Present in v1 | Redacted from v2 |
|---|---|---|
message-from-admin-practitioner-received | message, type, sender, senderRole | Message body and sender identity — type is delivered as message_type |
message-from-patient-received | message, type, sender, senderRole | Message body and sender identity — type is delivered as message_type; channelType is delivered as channel_type |
order_payment_collected, affiliate_admin_was_assigned, visit_type_updated, visit_type_overridden | performedBy | performedBy (blocked as PHI-adjacent) |
provider_was_assigned | practitioner | practitioner |
post_purchase_disqualification_processed | order_id, ecommerce_order_id, commerce_status (target entity), refund_amount_cents (successful refund branch only), branch, failed, subscription_cancelled, clinical_order_id, note_id, disqualifiers | Orchestration detail — v2 delivers commerce/order identifiers and refund_amount_cents when applicable |
Fields that are delivered in v2 (contrary to older documentation):
| Event | v2 fields now delivered |
|---|---|
shipping_details_set, shipment_delivered | tracking_number, tracking_url, shipping_carrier, shipment_status |
prescription_pharmacy_delivery_error | Sanitized reason (internal error strings are replaced with safe defaults) |
pharmacy_prescription_status_update | pharmacy_status (newStatus aliases to pharmacy_status, not order_status) |
support_case_created / support_case_status_updated | support_case_id, support_case_status, support_case_name, support_case_category, external case fields |
order_asset_unlocked | order_asset_id, unlocked_at, order_external_identifier, asset_link |
order_reminder_sent | order_link, reminder_type |
If your integration needs any field listed in the first table, keep the affected webhook on v1. The full allowlist per category is on the v2 Overview.
This table reflects a full audit of the emitting code paths as of 2026-07-17. If you find a delivery that contradicts it, please flag it — a mismatch is a bug on our side, not expected behavior.
