> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getsolum.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Patient Matching

> How the system decides whether incoming patient data updates an existing patient or creates a new one — for both technical integrators and operations teams.

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

```json theme={null}
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 `Wyatt` ⊂ `Ezekiel Wyatt` matches; `anna` ⊂ `anna F.` matches.
* **Jaro-Winkler similarity ≥ 0.85** on both first and last name. Catches typos: `Smith` ↔ `Smyth`, `Castilla` ↔ `Castila`, `Binkhorst` ↔ `Binkwerth`.

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.

<Tip>
  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.
</Tip>

### 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:

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="Tags">
    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.
  </Accordion>

  <Accordion title="Source attribution (created_from)">
    Only honored on the create path. Existing patients keep their original `created_from`; the upsert never overwrites it.
  </Accordion>
</AccordionGroup>

***

## 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

<CardGroup cols={2}>
  <Card title="1. Same person re-submits a form" icon="repeat">
    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.
  </Card>

  <Card title="2. Bad data was already in the system" icon="wand-magic-sparkles">
    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.
  </Card>

  <Card title="3. Two siblings sharing a household phone" icon="house">
    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.
  </Card>

  <Card title="4. Same person, different phone" icon="mobile-screen">
    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.
  </Card>
</CardGroup>

***

## Practical Recommendations

For integrators sending data to `/v1/patients/upsert`:

<Steps>
  <Step title="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.
  </Step>

  <Step title="Send full demographics whenever possible">
    First, last, and DOB together. Tier 2 catches most "same person, different contact channel" cases.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`).
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>
</Steps>

***

## 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:

```json theme={null}
{
  "patient": { /* full Patient resource */ },
  "matched": true,
  "created": false,
  "match_reason": "demographics",
  "dropped_fields": []
}
```

| 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

<CardGroup cols={2}>
  <Card title="Upsert Patient" icon="code" href="/guides/upsert-patient">
    Full request/response reference for `POST /v1/patients/upsert`.
  </Card>

  <Card title="Form Submissions" icon="file-pen" href="/guides/form-submissions">
    How form submissions flow through the same matching engine.
  </Card>

  <Card title="Creating a Patient" icon="user-plus" href="/guides/creating-a-patient">
    The strict create endpoint (`POST /v1/patients`) for when you don't want match-or-create semantics.
  </Card>
</CardGroup>
