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-08-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-17" |
| — | timestamp_epoch | Integer Unix seconds for timestamp. Regenerated on retry with timestamp. |
| — | affiliate_id | |
| — | patient_id, patient_external_id, patient_marketing_consent, is_returning_patient | v2 always enriches with the two booleans (patient_marketing_consent, is_returning_patient; default false). patient_id and patient_external_id are omitted when unavailable — never sent as null. patient_id is present when the event has a linked patient; patient_external_id is present only when that patient also has an external identifier. Affiliate-owned events with no linked patient omit both ids. is_returning_patient is per-patient: another ecommerce order already in paid, processing, or completed. |
| — | 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, ecommerce_order_id, first_purchase (false), cycle_number, plan_type, payment_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 |
order_created | orderId | orderLink is sent only while the order's intake is still incomplete, and omitted once it is done |
order_visit_required | — | orderLink present whenever the follow-up visit link could be generated |
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, first_purchase, plan_type, event_id, timestamp_epoch | template_display_name, journey_type, patient_id, patient_external_id, started_at, coupon_code, ecommerce_order_id, attribution, intake_form_instance_ids (affiliate intake form instance ids on the journey; omitted when none) |
journey_completed | (same as checkout_started) | (same as checkout_started) |
journey_abandoned | journey_id, affiliate_id, template_id, total_value_cents, currency, products, plan_type, event_id, timestamp_epoch, had_cart, recovery_status, recovery_missing_details | template_display_name, journey_type, patient_id, patient_external_id, started_at, coupon_code, ecommerce_order_id, first_purchase (only when had_cart is true), attribution, recovery_url / recovery_url_expires_at when a link can be issued, intake_form_instance_ids (omitted when none) |
journey_contact_captured | journey_id, affiliate_id, template_id, event_id, timestamp_epoch, patient_id | template_display_name, journey_type, patient_external_id, started_at, attribution, intake_form_instance_ids (omitted when none). No money fields, no first_purchase, no raw email/phone. patient_id is always present because this event fires only after the journey is linked to a patient; patient_external_id is omitted when the patient has no external identifier. |
custom_intake_form_completed | affiliate_id, intake_form_id, intake_form_instance_id, completed_at, responses, patient_marketing_consent, event_id, timestamp_epoch | journey_id, patient_id, patient_external_id — omitted when absent (pre-login completion is not emitted until the patient is linked, so delivered payloads normally include patient_id). No template/money fields, no is_returning_patient. One event per intake form instance; preview instances never emit. See Custom Intake Form Completed. |
ecommerce_order_paid | ecommerce_order_id, commerce_status, total_value_cents, currency, first_purchase, plan_type | payment_status, attribution, journey_id |
ecommerce_order_refunded | ecommerce_order_id, commerce_status, total_value_cents, currency, refund_amount_cents, refund_type | payment_status, attribution, journey_id, first_purchase, plan_type |
subscription_cancelled | subscription_id, subscription_status | total_value_cents, currency, attribution, journey_id, first_purchase, plan_type |
subscription_paused | subscription_id, subscription_status | total_value_cents, currency, attribution, journey_id, first_purchase, plan_type |
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); attribution, journey_id, first_purchase, plan_type |
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 |
order_created (intake outstanding), order_visit_required | order_link — the patient's intake/visit URL, expiring 7 days after it is issued |
journey_abandoned | had_cart, recovery_status, recovery_missing_details (no longer stripped) |
ecommerce_order_paid, subscription_renewed; ecommerce_order_refunded, subscription_cancelled, subscription_paused, subscription_payment_failed when a checkout order is resolved; journey_checkout_started, journey_completed; journey_abandoned when had_cart is true; ORDER events when an ecommerce order is resolved | first_purchase, plan_type. Renewals also send cycle_number. Not on journey_contact_captured, and not on PRESCRIPTION, SHIPPING, PHARMACY, LAB, PAYMENT-only, SUPPORT, or PATIENT events. |
journey_contact_captured, journey_checkout_started, journey_completed, journey_abandoned; COMMERCE events; ORDER events; PRESCRIPTION, SHIPPING, PHARMACY, PAYMENT, and LAB events | attribution with optional landing_url / referrer_url on first/last touch — omitted when no tracking data was captured. SUPPORT, PATIENT, and INTERNAL do not receive it. See Attribution Passthrough |
| All v2 envelopes | timestamp_epoch |
products[].display_name and product_display_names are removed in api_version: "2026-08-17".
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-08-17. If you find a delivery that contradicts it, please flag it — a mismatch is a bug on our side, not expected behavior.
