Healthcare Clinic Network Data Model
Healthcare Clinic Network Data Model
Outpatient clinics across the US and Canada tracked from advertising spend through contact-centre enquiries and booked appointments to the revenue clinicians bill, with patient history and value carried across two currencies.
A network of multi-specialty outpatient clinics across the United States and Canada, modelled from the first advertising impression through to the revenue a clinician bills at the chair. Budget bought by keyword and creative brings visits to the website; a fraction of those visits ask for an appointment, most of them by picking up the phone; a small contact-centre team works those enquiries until they are booked or given up on; and the appointments that follow are either attended, cancelled in advance or simply not turned up to. Patients accumulate a visit history, a lifetime value and a recency segment on top of that, along with the treatment that was planned for them and never delivered. Locations bill in their own currency and every revenue figure is also carried converted, so a network spanning two countries reads as one business.
Scope: this model covers demand generation, intake and delivered care — what it costs to acquire a patient, whether the contact centre reaches them, whether they attend, and what the visit bills. The payer side is out of scope: insurance appears only as how a visit was settled, and there are no insurance plans, claims, reimbursements or denial rates here, so payer mix and revenue-cycle questions have no answer in this model. Nor does it model the schedule as capacity — there are appointments, but no slots or clinician availability — or a coded procedure catalogue. What it answers well is the whole path from spend to attended, billed care.
Example Questions
- Which channels buy
patientsworth keeping rather than merely cheap ones — do the campaigns with the lowest cost per enquiry produce patients who return and accumulate real lifetime value, or ones who attend once and lapse? - Does the contact centre change the outcome — do enquiries someone actually reached get booked and attended more often than the ones that went unanswered, and how many attempts is that worth?
- Where is capacity being lost, and what is it worth — how much revenue sits in no-shows and cancellations by location and specialty, and how much more sits in treatment that was planned and never delivered?
Ad Spend
What the network paid to be found, at the finest grain it is billed: one row per day, channel, campaign, keyword, creative and country, with cost in the original currency and converted to a single one, alongside impressions and clicks. This is the mart to use when the question is which keyword or creative is consuming budget, rather than which channel — that coarser view is already rolled up with its outcomes in Attribution. Because healthcare acquisition costs vary enormously by channel, keyword-level spend is usually where a blended cost per patient turns out to be hiding one very expensive route and one very cheap one.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
date | DATE | Date | PK. Calendar date the spend was incurred. FK to Attribution |
source | STRING | Source | PK. Platform or network the spend went to, such as Google or Meta. FK to Attribution |
medium | STRING | Medium | PK. Advertising model used, such as cpc. FK to Attribution |
campaign | STRING | Campaign Name | PK. Campaign the spend belongs to. FK to Attribution |
keyword | STRING | Keyword | PK. Search term the advertising bid on. |
ad_content | STRING | Ad Content | PK. Creative or ad variant the spend ran against. |
country | STRING | Country | PK. Country the advertising was targeted at. |
cost | FLOAT | Cost | Spend in the original billing currency. |
cost_normalized | FLOAT | Normalized Cost | Spend converted to USD. Use this whenever markets are compared. |
impressions | INTEGER | Impressions | Times the advertising was displayed. |
clicks | INTEGER | Clicks | Times the advertising was clicked. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Attribution | date = date, source = source, medium = medium, campaign = campaign | N:N | The acquisition funnel this day of spend paid for. |
Attribution
The funnel on one row: for every day, channel, campaign and country, what was spent and what came back — sessions, enquiries, new patients, completed visits, revenue and the lifetime value now attached to the patients that channel produced. This is the mart that answers what a patient costs to acquire, and it is deliberately built on completed visits rather than on bookings, because a booking that turns into a no-show has not acquired anyone. Having spend and outcome side by side at the same grain is what makes cost per new patient comparable across channels without stitching marts together first.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
attribution_id | STRING | Attribution ID | PK. Unique identifier for this day, channel, campaign and country combination. |
date | DATE | Date | Calendar date the spend and outcomes are attributed to. |
source | STRING | Traffic Source | Origin of the traffic, such as a search engine or social network. |
medium | STRING | Traffic Medium | Traffic type, such as cpc, organic or referral. |
campaign | STRING | Campaign Name | Marketing campaign the spend and outcomes belong to. |
country | STRING | Country | Country the activity is attributed to. |
cost | FLOAT | Raw Cost | Advertising spend in the original billing currency. |
cost_normalized | FLOAT | Normalized Cost | Advertising spend converted to USD. Use this for any cross-country comparison. |
impressions | INTEGER | Impressions | Times the advertising was displayed. |
clicks | INTEGER | Clicks | Times the advertising was clicked. |
sessions | INTEGER | Sessions | Website sessions attributed to this row. |
leads | INTEGER | Leads | Enquiries attributed to this row. |
new_patients | INTEGER | New Patients | First-time patients attributed to this row. The usual denominator for acquisition cost. |
completed_visits | INTEGER | Completed Visits | Visits that were actually attended. The stricter acquisition denominator, since a booking lost to a no-show acquires nobody. |
revenue_normalized | FLOAT | Early Revenue (USD) | Revenue the attributed patients billed within 90 days of their first attended visit, in USD — how fast the acquisition cost on this row started coming back. Deliberately narrower than ltv: measured over the full relationship the two would be the same number. |
ltv | FLOAT | Lifetime Value | Total revenue the patients on this row have billed to date, in USD. Read against revenue_normalized to see how far eventual value runs ahead of the first 90 days. |
Clinic
The network's footprint: one row per clinic location, with where it is, what currency it bills in and which practice-management system its records come from. Locations span the United States and Canada, so a clinic carries both its own billing currency and the state or province it sits in — the cut every regional comparison starts from. is_active separates locations that are open and taking patients from ones that have closed or not yet opened, which matters whenever a per-location average would otherwise be dragged down by a site that was dark for part of the period. This is the dimension that turns any patient, visit or enquiry number into a location-level one.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
clinic_id | STRING | Clinic ID | PK. Unique identifier for this clinic location. |
clinic_name | STRING | Clinic Name | Public-facing name of the location, as patients see it. |
country | STRING | Country | Two-letter country code the location operates in: US or CA. |
state_province | STRING | State / Province | State (US) or province (Canada) the location sits in, e.g. TX, CO, ON. The standard regional cut for comparing locations. |
city | STRING | City | City the location operates in. |
address | STRING | Street Address | Street address of the location. |
phone | STRING | Phone Number | Primary contact number patients call to reach this location. |
currency | STRING | Local Currency | Currency this location bills patients in: USD or CAD. Revenue is also carried normalised to USD wherever it appears. |
ehr_system | STRING | EHR System | Practice-management / electronic health record system the location's records originate from. Check this first when one clinic's data looks structurally unlike another's. |
is_active | BOOLEAN | Is Active | True while the location is open and accepting patients. Exclude inactive locations before comparing per-location averages. |
Communications
Every attempt to reach a patient, and every time one reached us: one row per interaction, with the channel it used, which direction it went, whether it succeeded, and which agent handled it. Calls dominate, because that is how most people still choose a clinician, and the honest measure of a contact centre lives here rather than in the enquiry record — how many attempts it takes before someone answers, which enquiries are given up on, and whether persistence actually changes the outcome. is_first and is_last bracket each conversation, and is_scheduling separates the interactions that were about getting an appointment in the diary from reminders, follow-ups and automated replies.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
communication_id | STRING | Communication ID | PK. Unique identifier for this interaction. |
lead_id | STRING | Lead ID | Enquiry this interaction concerns. FK to Leads |
patient_id | STRING | Patient ID | Patient this interaction concerns, once the enquiry has converted. FK to Patients |
agent_id | STRING | Agent ID | Agent who handled the interaction. FK to Patient Access Agent |
type | STRING | Communication Type | Channel used: call, sms, email, web_chat, portal_message. |
direction | STRING | Direction | inbound when the patient contacted us, outbound when we contacted them. |
status | STRING | Status | Outcome of the attempt: successful or unsuccessful. Counting unsuccessful outbound attempts is how the cost of chasing is measured. |
subject | STRING | Subject | Short label describing what the interaction was about. |
message_text | STRING | Message Text | Body of the message sent or received. |
is_autoreply | BOOLEAN | Is Auto Reply | True when the interaction was generated automatically rather than sent by a person. |
creation_date | DATE | Creation Date | Date the interaction took place. |
is_first | BOOLEAN | Is First Communication | True for the first interaction recorded against the enquiry. |
is_last | BOOLEAN | Is Last Communication | True for the most recent interaction recorded against the enquiry. |
is_scheduling | BOOLEAN | Is Scheduling Interaction | True when the interaction was about getting an appointment into the diary, as opposed to a reminder or follow-up. |
is_last_scheduling | BOOLEAN | Is Last Scheduling Interaction | True for the most recent scheduling interaction on the enquiry. |
count_communications | INTEGER | Communication Count | Always 1 on every row; SUM to count interactions. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Leads | lead_id = lead_id | N:1 | The enquiry this interaction is about. |
| Patient Access Agent | agent_id = agent_id | N:1 | The agent who handled the interaction. |
| Patients | patient_id = patient_id | N:1 | The patient this interaction was with. |
Leads
The moment someone asks for an appointment: one row per inbound enquiry, whether it arrived as a tracked phone call, a website form, a chat, a lead form on an ad platform, a referral from another physician or typed in by staff. Each enquiry carries the location it was directed to, the session that produced it where the two could be matched, and the state it has since reached — still new, being worked, converted into a patient, or one of the two dead ends: never reached, or rejected with a stated reason. This is the only place in the model where the enquiries that were never reached at all sit next to the ones that converted, which is what makes the size of that loss visible rather than assumed.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
lead_id | STRING | Lead ID | PK. Unique identifier for this enquiry. |
session_id | STRING | Session ID | Session the enquiry was submitted during. NULL when the enquiry began outside the web funnel, such as a physician referral or a direct call. FK to Sessions |
patient_id | STRING | Patient ID | Set only once the enquiry converts into a patient. NULL until then — use IS NOT NULL for "converted enquiries", which is why a rejected or unreached enquiry is never counted as a patient. FK to Patients |
clinic_id | STRING | Clinic ID | Location the enquiry was directed to. FK to Clinic |
lead_submitted_at | TIMESTAMP | Lead Submitted At | When the enquiry was received. The series to plot for enquiry volume. |
status | STRING | Status | How far the enquiry got: new, in_work, converted, no_answer, rejected. |
source_system | STRING | Source System | What captured the enquiry: call_tracking, google_ads, meta_lead_form, web_form, web_chat, physician_referral, manual. |
channel_name | STRING | Channel | How the patient reached out: phone, web_form, web_chat, patient_portal. |
rejection_reason | STRING | Rejection Reason | Why the enquiry was rejected: insurance_not_accepted, cost_concern, chose_competitor, wrong_number, duplicate, not_interested. NULL unless status = 'rejected'. |
is_manual_entry | BOOLEAN | Is Manual Entry | True when a staff member created the enquiry by hand rather than it being captured automatically. |
country | STRING | Country | Country the enquiry came from. |
created_at | TIMESTAMP | Created At | When the enquiry record was created in the warehouse. |
count_leads | INTEGER | Lead Count | Always 1 on every row; SUM to count enquiries. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Clinic | clinic_id = clinic_id | N:1 | The clinic the enquiry asked about. |
| Patients | patient_id = patient_id | N:1 | The patient this enquiry was matched to. |
| Sessions | session_id = session_id | N:1 | The website visit the enquiry came from. |
Patient Access Agent Data Mart
Patient Access Agent
The people who answer the phone. Patient access is the function that handles inbound enquiries and books them into a clinician's diary, and since the overwhelming majority of new patients arrive by phone rather than by form, this small team sits directly on the network's growth. One row per agent, with the role they hold — front-line representative, senior representative or supervisor — and the country they work in. On its own the mart is a short staff list; joined to the interaction history it is what makes contact-centre performance answerable at all, because every call, message and booking attempt is attributed to the agent who handled it.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
agent_id | STRING | Agent ID | PK. Unique identifier for this contact-centre agent. |
first_name | STRING | First Name | Agent's given name. |
last_name | STRING | Last Name | Agent's family name. |
role | STRING | Role | Position on the team: patient_access_rep, senior_rep or supervisor. Use this to ask whether experience changes booking rates. |
country | STRING | Country | Two-letter country code the agent works in. |
email | STRING | Email Address | Work email address for the agent. |
is_active | BOOLEAN | Is Active | True while the agent is still on the team. |
Patients
Who the network is treating, and what each patient is worth: one row per patient, combining the identity and contact details the clinics hold with the value metrics built on top of their visit history — lifetime value, average value per visit, how many times they have attended, when they first and last came, and how long it has been since. The distinction between a new and an established patient runs through everything here, because the two behave differently on almost every measure that matters, from how much treatment they accept to how likely they are to attend. has_unused_plan and unused_plan_revenue make the recall opportunity explicit: treatment that was planned and paid attention to but never delivered is revenue already earned and still sitting on the table.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
patient_id | STRING | Patient ID | PK. Unique identifier for this patient. |
lead_id | STRING | Acquisition Lead ID | Enquiry that first brought this patient in. First touch only — a patient who enquired more than once keeps just the earliest here. Denormalised, not a declared relationship; the full history is in Leads. |
session_id | STRING | First Session ID | Website session the patient was first captured in, where one could be matched. |
source_ehr | STRING | Source EHR | Practice-management system this patient record originated in. |
external_ehr_id | STRING | External EHR ID | Identifier for this patient in the originating system. |
first_name | STRING | First Name | Patient's given name. |
last_name | STRING | Last Name | Patient's family name. |
phone | STRING | Phone Number | Primary contact number for the patient. |
email | STRING | Email Address | Primary email address for the patient. |
date_of_birth | DATE | Date of Birth | Patient's date of birth, the basis for any age banding. |
gender | STRING | Gender | Patient's recorded gender. |
country | STRING | Country | Patient's country of residence. |
language | STRING | Language | Preferred language for communication: en, fr or es. |
patient_type | STRING | Patient Type | new for a first-time patient, established for a returning one. Control for this before comparing acceptance, attendance or value. |
rfm_label | STRING | RFM Segment | Recency, frequency and monetary segment: new, promising, loyal, champions, at_risk or lost. |
recency_score | INTEGER | Recency Score | Score for how recently the patient last attended. Kept so segment boundaries can be re-cut. |
frequency_score | INTEGER | Frequency Score | Score for how often the patient attends. |
monetary_score | INTEGER | Monetary Score | Score for how much the patient has spent. |
ltv | NUMERIC | Lifetime Value | Total revenue from this patient to date, in USD. |
avg_visit_value | NUMERIC | Average Visit Value | Average revenue per attended visit for this patient, in USD. |
total_visits | INTEGER | Total Visits | Number of visits this patient has attended. Reconciles with the attended rows in Visits. |
first_visit_date | DATE | First Visit Date | Date of the patient's first visit. |
last_visit_date | DATE | Last Visit Date | Date of the patient's most recent visit. |
days_since_last_visit | INTEGER | Days Since Last Visit | Whole days between the last visit and the reporting date. The recall trigger. |
has_unused_plan | BOOLEAN | Has Unused Plan | True when the patient has planned treatment that has not been delivered. |
unused_plan_revenue | NUMERIC | Unused Plan Revenue | Value of planned but undelivered treatment for this patient, in USD. |
created_at | TIMESTAMP | Created At | When the patient record was first created. |
count_patients | INTEGER | Patient Count | Always 1 on every row; SUM to count patients. |
Provider
The clinicians patients actually see: one row per provider, with the specialty they practise and the location they practise at. "Provider" is the collective the industry uses for the physicians, nurse practitioners and physician assistants who deliver care, and specialty is what makes this mart load-bearing — it is the only place the model says whether a visit was primary care, dermatology, orthopedics or behavioral health, and those service lines behave nothing alike on revenue per visit or on how often patients fail to turn up. is_active distinguishes clinicians currently practising from those who have left, so a provider who worked two months of the year is not compared against one who worked twelve.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
provider_id | STRING | Provider ID | PK. Unique identifier for this clinician. |
clinic_id | STRING | Clinic ID | Location this clinician practises at. FK to Clinic |
first_name | STRING | First Name | Clinician's given name. |
last_name | STRING | Last Name | Clinician's family name. |
specialty | STRING | Specialty | Service line this clinician practises: primary_care, dermatology, orthopedics, cardiology, pediatrics, physiotherapy, behavioral_health. The only path from a visit to its service line. |
email | STRING | Email Address | Work email address for the clinician. |
is_active | BOOLEAN | Is Active | True while the clinician is still practising at the network. Filter on it before comparing providers, so a mid-period joiner is not read as underproductive. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Clinic | clinic_id = clinic_id | N:1 | The clinic the clinician works out of. |
Sessions
Every visit to the website, before anyone has identified themselves: one row per session, with the campaign, keyword and ad creative that brought it, the page it landed on, the device it came from and the city it came from. This is the widest mart in the model and the top of the acquisition funnel — most sessions never become an enquiry, which is the point, because the ratio between sessions and enquiries by landing page and channel is where wasted spend shows up. It also records consent_at, so sessions that predate tracking consent can be separated from those that carry it, and patient_id on the small minority of sessions the network could later tie back to a known patient.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
session_id | STRING | Session ID | PK. Unique identifier for this website session. |
date | DATE | Date | Calendar date the session occurred. The series to plot for traffic volume. Part of the join grain shared with Attribution. FK to Attribution |
source | STRING | Source | Origin of the traffic, such as a search engine or social network. Part of the join grain shared with Attribution. FK to Attribution |
medium | STRING | Medium | Traffic type, such as cpc, organic, referral or (none). Part of the join grain shared with Attribution. FK to Attribution |
campaign | STRING | Campaign | Marketing campaign that produced the session. Part of the join grain shared with Attribution. FK to Attribution |
ad_content | STRING | Ad Content | Specific creative or ad variant the visitor clicked. |
ad_group | STRING | Ad Group | Ad group within the campaign. |
channel_grouping | STRING | Channel Grouping | Pre-rolled channel classification: Paid Search, Paid Social, Organic Search, Direct, Referral. The default grouping for channel reporting. |
keyword | STRING | Keyword | Search term that triggered the ad or organic result. |
landing_page | STRING | Landing Page | Page path the visitor first arrived on. Compare enquiry rates across these to find pages that attract volume but convert poorly. |
landing_host_name | STRING | Landing Host Name | Domain or subdomain the session started on. |
url | STRING | URL | Full web address of the landing page, including protocol and domain. |
session_start | TIMESTAMP | Session Start | Exact moment the session began. |
consent_at | TIMESTAMP | Consent Time | When the visitor granted tracking consent. NULL when no consent was recorded. |
client_id | STRING | Client ID | Browser-level identifier, used to tell devices apart. |
user_id | STRING | User ID | Known-user identifier, stable across sessions once the visitor is recognised. |
country | STRING | Country | Country the session originated from. Part of the join grain shared with Attribution, which is why including it makes each session resolve to exactly one attribution row rather than one per country. FK to Attribution |
region | STRING | Region | State or province the visitor was in. |
city | STRING | City | City the visitor was in. |
device_category | STRING | Device Category | Hardware used: Mobile, Desktop or Tablet. |
is_first_visitor_session | BOOLEAN | Is First Visitor Session | True when this is the first session recorded for that visitor. |
patient_id | STRING | Patient ID | Always NULL. Sessions stay anonymous here by design — resolving one to a patient would need this mart to read Patients, closing a cycle back through Visits and Leads. The linkage lives on Patients.session_id and Leads.session_id instead, so join from those. |
count_sessions | INTEGER | Session Count | Always 1 on every row; SUM to count sessions. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Attribution | date = date, source = source, medium = medium, campaign = campaign, country = country | N:1 | The acquisition funnel this visit rolls into. |
Visits
Where the network earns: one row per appointment, from the moment it is put in the diary to whatever became of it — attended, cancelled in advance, or simply not turned up to. Keeping cancellations and no-shows apart is deliberate, because they are different problems with different fixes: one gives the clinic a chance to refill the slot and the other does not. Each visit records the clinician who delivered it and the service they delivered, the location and its currency, what it billed both locally and converted, how the patient paid, and where the wider treatment plan stood at the time. It is the only mart that carries revenue, so every question about money starts here.
Fields
| Column | Type | Alias | Description |
|---|---|---|---|
visit_id | STRING | Visit ID | PK. Unique identifier for this appointment. |
patient_id | STRING | Patient ID | Patient the appointment is for. FK to Patients |
lead_id | STRING | Lead ID | Enquiry that produced this appointment. NULL for established patients who booked without a fresh enquiry. FK to Leads |
provider_id | STRING | Provider ID | Clinician delivering the appointment. FK to Provider |
clinic_id | STRING | Clinic ID | Location the appointment takes place at. FK to Clinic |
visit_date | DATE | Visit Date | Date the appointment is scheduled for. |
visit_time | TIME | Visit Time | Time of day the appointment is scheduled for. |
visit_type | STRING | Visit Type | new_patient_visit for a patient's first appointment, follow_up for a subsequent one. |
status | STRING | Status | What became of the appointment: scheduled, completed, cancelled (called off in advance) or no_show (not attended, no notice). The last two are distinct because only a cancellation lets the slot be refilled. |
service_name | STRING | Service | Service delivered at the appointment. |
treatment_plan_status | STRING | Treatment Plan Status | Where the patient's wider course of treatment stood: none, active, partial, completed, unused. |
revenue_local | NUMERIC | Revenue (Local) | Revenue billed, in the location's own currency. Only attended visits carry revenue. |
currency | STRING | Local Currency | Currency revenue_local is denominated in: USD or CAD. |
revenue_normalized | NUMERIC | Revenue (USD) | Revenue converted to USD. Use this for any figure spanning both markets. |
fx_rate_to_usd | NUMERIC | FX Rate to USD | Rate used to convert revenue_local into revenue_normalized. |
payment_method | STRING | Payment Method | How the visit was settled: insurance, card, cash, online, payment_plan. |
created_at | TIMESTAMP | Created At | When the appointment was booked. Compare against visit_date for booking lead time. |
is_first_visit | BOOLEAN | Is First Visit | True when this is the patient's first ever attendance at the network. |
count_visits | INTEGER | Visit Count | Always 1 on every row; SUM to count appointments. |
Relationships
| Related data mart | On | Cardinality | Meaning |
|---|---|---|---|
| Clinic | clinic_id = clinic_id | N:1 | The clinic where the visit happened. |
| Leads | lead_id = lead_id | N:1 | The enquiry this appointment came from. |
| Patients | patient_id = patient_id | N:1 | The patient who attended. |
| Provider | provider_id = provider_id | N:1 | The clinician who saw the patient. |
- 1
Install the Import Model plugin
One plugin, installed once, in your own OWOX workspace.
- 2
Import this model
Point it at this bundle and it creates every data mart above, joins and all.
- 3
Plug in your data and destinations
Connect your own sources and send the results where your team already works.
4. Optional — customize as you wish. Rename a column, drop a mart, add your own: once it is imported it is yours, and nothing here syncs back.