Patient Matching
When new patient data arrives, the system has to decide one thing: is this an existing patient, or a new one? This page explains how that decision is made — in plain English first, then with the technical detail integrators need. There is one matching engine. It runs the same way regardless of how the data arrived:POST /v1/patients/upsert(direct API integration)- Form submissions (intake forms, sequence forms)
- AI phone calls
- Internal flows that go through the upsert endpoint
TL;DR — Plain English
When new patient data comes in, the system tries four ways to find an existing match — in order. The first one that succeeds wins.- External ID — if you sent your own ID for this patient before, that’s an exact match.
- Demographics — same first name + last name + date of birth.
- Phone + name match — same phone number, name looks like a match (typos OK), and the DOBs don’t disagree.
- Email + name match — same email, name looks like a match (typos OK), and the DOBs don’t disagree.
When It Matches vs. Creates
| You send… | Existing patient has… | Result |
|---|---|---|
External ID EHR-123 | A patient already linked to External ID EHR-123 | Match (Tier 1) |
| Anna Smith, DOB 1985-03-20 | Anna Smith, DOB 1985-03-20 | Match (Tier 2) |
| anna f. Smith, DOB 1985-03-20 | Anna Smith, DOB 1985-03-20 | Match (Tier 2 — names compared case-insensitively, then token-subset) |
| Anna Smith, phone +15551234567 (no DOB) | Anna Smith, phone +15551234567 | Match (Tier 3) |
| Anna Smyth, phone +15551234567 | Anna Smith, phone +15551234567 | Match (Tier 3 — typo tolerated, fuzzy ≥ 0.85) |
| Anna Smith, phone +15551234567, DOB 1990-01-01 | Anna Smith, phone +15551234567, DOB 1985-03-20 | No match. Phone matches and names match, but the DOBs disagree — Tier 3 rejects. Falls through to email/create. |
Anna Smith, email anna@x.com (no phone, no DOB) | Anna Smith, email anna@x.com | Match (Tier 4) |
| Bob Jones, phone +15551234567 | Anna Smith, phone +15551234567 | No match. A new patient Bob Jones is created with phone_number = null (Anna keeps the phone). |
| Anna Smith, DOB 1990-01-01, phone +15551234567 | Carol Wong, DOB 1985-03-20, phone +15551234567 | No match. A new patient Anna Smith is created with phone_number = null (Carol keeps the phone). |
| New patient with no overlap | (no existing matches) | Created as a new patient. |
The Four Match Tiers
Tiers run in order. The first tier that returns a candidate wins; the rest are skipped.Tier 1 — External ID (highest priority)
If you sendexternal_id and that (type_id, value) pair is already attached to one of your patients, that patient is the match.
This is the most reliable match — use it whenever you have a stable external identifier (EHR ID, scheduling system ID, etc.). It survives the patient renaming themselves, switching phone numbers, or any other identity drift.
Tier 2 — Demographics (first + last + DOB)
Looks up patients with the samefirst_name, last_name, and date_of_birth. Names are compared case-insensitively, DOB must match exactly.
This tier requires all three fields on the submission. Skipped if any are missing.
Tier 3 — Phone + name check
Looks up patients with the samephone_number (E.164 normalized). If a candidate is found, the system runs a conflict check to confirm it’s the same person before matching. The check has four parts:
1. Stub shortcut. If the existing patient has no name and no DOB on file (e.g. a stub created from a missed call before the patient said anything), the match goes through. There’s nothing on the existing record to verify against.
2. Name conflict. If both sides have names, they have to match. The name comparison passes when:
- The submitted and existing names are the same (case-insensitive).
- Token-subset: one side’s name tokens are a subset of the other’s. So
Wyatt⊂Ezekiel Wyattmatches;anna⊂anna F.matches. - Jaro-Winkler similarity ≥ 0.85 on both first and last name. Catches typos:
Smith↔Smyth,Castilla↔Castila,Binkhorst↔Binkwerth.
Tier 4 — Email + name check
Same logic as Tier 3 but keyed byemail. Email is compared case-insensitively.
What Happens After a Match
The existing patient is updated with all fields you provided. Anything you didn’t send is left alone. A few special rules apply:Phone immutability after first contact
Phone immutability after first contact
Once a patient has had their first communication recorded (
first_communication_at is set), the phone number can’t be changed via the upsert. A new phone in the request is silently dropped and reported in dropped_fields. The lock prevents in-flight conversations from being routed to a different number.Sibling-conflict drop
Sibling-conflict drop
If you send a phone or email that already belongs to another patient (not the one we matched), that field is silently dropped instead of duplicating identity. Reported in
dropped_fields. The matched patient keeps whatever they already had on file.Workflow stage
Workflow stage
Updated to whatever you send. For form submissions specifically, the form’s configured stage is sent on every submission, so re-submitting a form re-assigns the patient to that stage.
Tags
Tags
Source attribution (created_from)
Source attribution (created_from)
Only honored on the create path. Existing patients keep their original
created_from; the upsert never overwrites it.What Happens When Nothing Matches
A new patient is created with whatever you sent — provided the data is enough to identify a person. Minimum identifying info (post-normalization):- A usable phone number, OR
- All three of: first name, last name, date of birth.
400 Bad Request with param: "patient_identifiers" and a list of which fields were dropped during normalization.
When a new patient is created, conflict drops can still apply:
- If the phone you sent already belongs to another patient → new patient is created with
phone_number = null. - Same for email.
dropped_fields.
Worked Examples
1. Same person re-submits a form
A patient already in the system fills out a follow-up intake form. They type their name slightly differently (
anna f. vs how it’s stored as anna).Result: matched via Tier 2 demographics. DOB matches; names match case-insensitively + token-subset. The patient record is updated with any new info from the form.2. Bad data was already in the system
A patient was created earlier from a phone call where the AI mistranscribed their name (
Wyatt was stored as Ezekiel wyatt). The patient now fills out a form with their correct name (Wyatt).Result: matched via Tier 3 phone + name. Phone matches; Wyatt ⊂ Ezekiel wyatt passes the name check via token-subset. The form’s correct data overwrites the bad transcription.3. Two siblings sharing a household phone
Patient A (Bob Smith) is in the system with phone
+15551234567. Patient B (Carol Smith) submits an intake form using the same household phone.Result: phone matches Bob, but name check fails (Carol vs Bob). Falls through to email — Carol’s email is different. Falls through to create — the system tries to create Carol but the phone is already Bob’s, so the sibling-conflict drop kicks in and Carol is created with phone_number = null.Carol can be updated with her own phone later.4. Same person, different phone
Anna Smith was originally created with phone
+15551111111. She updates her phone to +15552222222 and submits a new form. The submission includes her name and DOB.Result: matched via Tier 2 demographics (name + DOB match). Her phone is updated to the new one — but only if first_communication_at hasn’t been set. If it has, the new phone is silently dropped (phone-immutability rule), and she keeps the old phone.Practical Recommendations
For integrators sending data to/v1/patients/upsert:
Always send external_id when you have a stable one
Tier 1 is the most reliable — not affected by typos, name changes, phone changes, or any other identity drift. Use the same
(type_id, value) for the same person every time.Send full demographics whenever possible
First, last, and DOB together. Tier 2 catches most “same person, different contact channel” cases.
Don't worry about phone format
US numbers in any common format work —
(555) 123-4567, 5551234567, +15551234567, +1 555 123 4567. The system normalizes to E.164.Don't worry about DOB format
Most reasonable formats parse:
1985-03-20, 03/20/1985, 1985.03.20, Mar 20 1985. Unparseable strings are silently dropped (and reported via dropped_fields).Watch the dropped_fields array
It’s the system telling you which fields it couldn’t apply (bad format, phone-immutability lock, sibling conflict, etc.). If you sent something that didn’t show up on the patient, it’s here.
Why It Works This Way
A few design decisions worth knowing:- External ID > demographics > contact channel. External IDs are stable across name/phone changes — they’re the strongest signal. Demographics (name + DOB) are stable across contact-channel changes. Phone/email are weakest because they get reused across families and change when people switch carriers.
- Names are matched fuzzily, not exactly. Real-world data has typos, missing accents, abbreviated forms, married names appended, middle initials. A strict equality match misses too many real “same person” cases.
- DOB on both sides is required for the demographics tier. A name match without DOB is too weak to confirm identity on its own — kids of the same parents, common names, etc.
- Phone immutability after first contact. Once a clinic has actually communicated with a patient on a phone number, changing it silently could break workflows.
- Sibling-conflict drops over hard rejection. A new patient with
phone_number = nullis recoverable (clinic adds a phone later); a hard rejection of the upsert isn’t. The system optimizes for “data lands somewhere ops can fix” over “data is rejected at the door.”
API Response Reference
POST /v1/patients/upsert returns a single shape regardless of match-vs-create:
| Field | Meaning |
|---|---|
matched | true when an existing patient was found (and updated). |
created | true when no match was found and a new patient was created. Mutually exclusive with matched. |
match_reason | "external_id" | "demographics" | "phone_fuzzy_name" | "email_fuzzy_name" — which tier matched. null when created: true. |
dropped_fields | Names of fields that the system silently dropped during normalization or sibling-conflict resolution. Inspect this if data you sent doesn’t appear on the patient. |
Related
Upsert Patient
Full request/response reference for
POST /v1/patients/upsert.Form Submissions
How form submissions flow through the same matching engine.
Creating a Patient
The strict create endpoint (
POST /v1/patients) for when you don’t want match-or-create semantics.
