Skip to main content

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.
  1. External ID — if you sent your own ID for this patient before, that’s an exact match.
  2. Demographics — same first name + last name + date of birth.
  3. Phone + name match — same phone number, name looks like a match (typos OK), and the DOBs don’t disagree.
  4. Email + name match — same email, name looks like a match (typos OK), and the DOBs don’t disagree.
If none of those hit → a new patient is created. If a match hits → the existing patient is updated with whatever new fields you sent.

When It Matches vs. Creates

You send…Existing patient has…Result
External ID EHR-123A patient already linked to External ID EHR-123Match (Tier 1)
Anna Smith, DOB 1985-03-20Anna Smith, DOB 1985-03-20Match (Tier 2)
anna f. Smith, DOB 1985-03-20Anna Smith, DOB 1985-03-20Match (Tier 2 — names compared case-insensitively, then token-subset)
Anna Smith, phone +15551234567 (no DOB)Anna Smith, phone +15551234567Match (Tier 3)
Anna Smyth, phone +15551234567Anna Smith, phone +15551234567Match (Tier 3 — typo tolerated, fuzzy ≥ 0.85)
Anna Smith, phone +15551234567, DOB 1990-01-01Anna Smith, phone +15551234567, DOB 1985-03-20No 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.comMatch (Tier 4)
Bob Jones, phone +15551234567Anna Smith, phone +15551234567No match. A new patient Bob Jones is created with phone_number = null (Anna keeps the phone).
Anna Smith, DOB 1990-01-01, phone +15551234567Carol Wong, DOB 1985-03-20, phone +15551234567No 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 send external_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.
POST /v1/patients/upsert
{
  "external_id": { "type_id": "uuid-of-your-id-type", "value": "EHR-123" },
  "first_name": "Anna",
  "last_name": "Smith"
}

Tier 2 — Demographics (first + last + DOB)

Looks up patients with the same first_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 same phone_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 WyattEzekiel Wyatt matches; annaanna F. matches.
  • Jaro-Winkler similarity ≥ 0.85 on both first and last name. Catches typos: SmithSmyth, CastillaCastila, BinkhorstBinkwerth.
Empty/null names on either side are treated as wildcards (skip that pair, keep checking the other). 3. DOB conflict. If both sides have a DOB and they’re different, the match is rejected. DOB has to be an exact match — there’s no fuzzy on dates. If either side is missing DOB, this check is skipped (wildcard). 4. Overlap requirement. At least one of first name, last name, or DOB must be present on both sides. This is the safety net for the “two strangers happen to share a household phone” case — without any overlap to verify, the system refuses to merge them on the contact channel alone.
These checks exist to protect against accidental merges when two family members share a phone or email. Sending Sam Smith’s phone with the name “Jane Smith” won’t overwrite Sam’s record — Jane will be created as a separate patient (with phone dropped via the sibling-conflict rule). Same idea for the DOB conflict — two distinct people on the same household line can never be merged into one.

Tier 4 — Email + name check

Same logic as Tier 3 but keyed by email. 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:
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.
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.
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.
When sent via the upsert, tags replaces the patient’s existing tag set. Form submissions don’t send tags this way — they apply form-derived tags additively in a separate step. If you’re integrating directly and want additive tags, use the patient PATCH endpoint with add_tags / remove_tags instead.
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.
If neither is true, the upsert returns 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.
This prevents a “household phone” from causing a unique-constraint violation, but it does mean the new sibling patient has no contact channel until you update them. The colliding field is reported in 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; WyattEzekiel 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:
1

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.
2

Send full demographics whenever possible

First, last, and DOB together. Tier 2 catches most “same person, different contact channel” cases.
3

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.
4

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).
5

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.
6

Don't send tags through the upsert unless you mean to replace them

For additive tag operations, use the patient PATCH endpoint with add_tags / remove_tags.

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 = null is 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:
{
  "patient": { /* full Patient resource */ },
  "matched": true,
  "created": false,
  "match_reason": "demographics",
  "dropped_fields": []
}
FieldMeaning
matchedtrue when an existing patient was found (and updated).
createdtrue 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_fieldsNames 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.

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.