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

# Upsert Patient (API)

> Forgiving create-or-update endpoint for integration partners — normalizes messy input, finds existing patients across four match tiers, and never 400s on bad fields.

# Upsert Patient

`POST /v1/patients/upsert` is a single endpoint integrations can call instead of choosing between `POST /v1/patients` (strict — 409 on conflict) and `POST /v1/patients/batch` (additive — never overwrites identity fields).

It's designed for partner systems that produce imperfect data: phones in 12 different shapes, dates that might be `04/12/85`, the same patient sent twice from two upstream feeds. The endpoint:

1. **Normalizes forgiving fields** — bad phone / email / DOB / gender / state values are silently dropped.
2. **Looks for an existing patient** in priority order (external\_id → demographics → phone+name → email+name).
3. **Updates the match** with every non-null field you sent (with two specific carve-outs), or **creates a new patient** if no match was found.
4. **Returns** which patient path it took, a list of fields it dropped, and one
   ordered processing result for every submitted payor.

It returns `200 OK` for both create and update — there is no separate `201`.

<Note>
  This endpoint accepts unknown fields silently. Sending proprietary metadata won't 400 — it just won't be persisted.
</Note>

***

## Request

`POST https://api.getsolum.com/v1/patients/upsert`

Auth: `X-API-Key: <your_api_key>`.

Every field is optional at the schema level — invariants are checked after normalization (see [Required identifying info](#required-identifying-info)).

### Identification

| Field                 | Type   | Notes                                                                              |
| --------------------- | ------ | ---------------------------------------------------------------------------------- |
| `external_id.type_id` | UUID   | Must reference an `external_id_type` belonging to your company.                    |
| `external_id.value`   | string | Your system's identifier for this patient. Used as the highest-priority match key. |

### Demographics & contact

| Field                                     | Type   | Normalization                                                                                                                                                |
| ----------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `first_name`, `last_name`, `middle_name`  | string | Trimmed, stored as given.                                                                                                                                    |
| `date_of_birth`                           | string | Accepts `YYYY-MM-DD`, `YYYYMMDD`, `MM/DD/YYYY`, `MM-DD-YYYY`, `MM/DD/YY`, textual forms (`April 12, 1985`). Range-checked: must be ≥ 1900-01-01 and ≤ today. |
| `gender`                                  | string | Coerced to `male` / `female` / `other`. Accepts synonyms (`M`, `F`, `man`, `woman`, `nb`, `non-binary`, `x`, `unknown`, …).                                  |
| `phone_number`, `additional_phone_number` | string | Normalized to E.164 (`+1XXXXXXXXXX`). Accepts 10 digits, 11 digits starting with 1, with or without formatting.                                              |
| `email`                                   | string | Lowercased and regex-validated.                                                                                                                              |

### Address

| Field                                | Type   | Notes                                                                                                       |
| ------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------- |
| `address`, `address2`, `city`, `zip` | string | Trimmed pass-through.                                                                                       |
| `state`                              | string | 2-letter postal code, full name (`California`, `new york`), or common short form (`Calif`, `Mass`, `Tenn`). |

### Workflow / assignment / tags

| Field               | Type      | Notes                                                                                                                            |
| ------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `workflow_stage_id` | string    | UUID **or** stage name. Unresolvable values are dropped.                                                                         |
| `assigned_user_id`  | string    | UUID **or** user email. Unresolvable values are dropped.                                                                         |
| `tags`              | string\[] | Array of tag UUIDs **or** tag names. Tags that don't resolve are dropped (full or partial → `tags` appears in `dropped_fields`). |

### Nested entities

| Field           | Type   | Notes                                                                                                                                                                                                                                                                                                              |
| --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `referral`      | object | Same shape as `POST /v1/patients`. Internal validators still apply.                                                                                                                                                                                                                                                |
| `payors`        | array  | Each payor must contain exactly one of `insurance_id` or `insurance_name`. See [Resolving a payor's insurance](#resolving-a-payors-insurance) and [Payor reconciliation](#payor-reconciliation). Each payor may also carry an `external_id` for the coverage; see [Coverage `external_id`](#coverage-external-id). |
| `custom_fields` | object | Pass-through to the patient service.                                                                                                                                                                                                                                                                               |

***

## Required identifying info

A new patient (no match found) must have at least one of:

* A normalized `phone_number`, **or**
* All three of `first_name`, `last_name`, `date_of_birth` (post-normalization).

If neither is present after normalization, the request returns:

```json theme={null}
400 Bad Request
{
  "detail": {
    "type": "validation_error",
    "code": "invalid_parameter",
    "message": "Insufficient identifying information to create a new patient. Send a phone_number, or send first_name + last_name + date_of_birth.",
    "param": "patient_identifiers",
    "dropped_fields": [],
    "missing_fields": ["first_name", "last_name", "date_of_birth"]
  }
}
```

`missing_fields` reports the identifying fields absent after normalization. In
this error response, `dropped_fields` identifies a supplied phone number or
email that Solum could not use because it belongs to another patient. For
example, if both values belong to another patient and the remaining demographics
are insufficient to create a new patient, `dropped_fields` contains
`phone_number` and `email`. A value rejected during normalization can still be
absent from `dropped_fields` in this response.

***

## Match resolution

The service walks four tiers in order — `external_id` → `demographics` → `phone_fuzzy_name` → `email_fuzzy_name` — and the first hit wins. The `match_reason` field on the response tells you which tier resolved.

The full rules, including how the name and DOB conflict checks behave on tiers 3 and 4, live in the [Patient Matching guide](/guides/patient-matching). Read that page if you want to know exactly when the system will and won't merge two records.

***

## Update behavior on match

When a match is found, **all non-null fields you sent overwrite the existing patient's values.** This is the deliberate departure from `POST /v1/patients/batch`, which only fills in blanks.

Two exceptions apply on update — phone-immutability after first contact, and sibling-conflict drops when a phone/email belongs to another patient. Both are explained in detail under [What Happens After a Match](/guides/patient-matching#what-happens-after-a-match) in the Patient Matching guide. When either fires, the affected field name is added to `dropped_fields` on the response.

***

## External ID handling

The `external_id` block does double duty: it is the highest-priority match key,
and the supplied value is reconciled onto the resolved patient.

| Existing state                                                       | Behavior                                                                                             |
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `type_id` doesn't belong to your company                             | `400 Bad Request` (validation error).                                                                |
| Patient has no record for this `type_id`                             | A new external-id record is created.                                                                 |
| Patient already has the same `(type_id, value)`                      | No-op.                                                                                               |
| Patient has a different value for this `type_id`                     | The existing value is updated and the old-to-new change is recorded in the patient activity history. |
| A soft-deleted patient owns `(type_id, value)`                       | The orphaned external-id row is retired and the value is assigned to the resolved patient.           |
| Another active patient acquires `(type_id, value)` during the upsert | Solum does not take the value from that patient; `external_id` is added to `dropped_fields`.         |

A successful response normally means the resolved patient carries the supplied
external ID. Callers must still inspect `dropped_fields`: Solum can drop
`external_id` after a concurrent conflict with another active patient or after a
failed reassignment while reclaiming a value from a soft-deleted patient.

***

## Resolving a payor's insurance

Every payor in an upsert request must identify its configured insurance in
exactly one of two ways:

* Send `insurance_id` when your integration already knows the configured
  insurance UUID.
* Send `insurance_name` when Solum should look up the configured insurance from
  free text.

Sending both fields, neither field, a blank `insurance_name`, or an
`insurance_name` longer than 200 characters is a structural request error and
returns `422`. These cases are not converted into forgiving field drops.

`insurance_name` is lookup-only. A successful match is converted to the
configured `insurance_id` before the payor is processed. The submitted name is
not stored, does not populate `insurance_display_name`, and does not create an
alias. It is echoed only in the corresponding
`payor_results[].submitted_insurance_name` so callers can reconcile the result
with their request.

For name-based lookups, Solum resolves each unique name in this order:

1. A unique normalized match against a configured canonical name or alias.
2. A unique match after adding the patient's state name and postal code before
   and after the submitted name.
3. An AI-assisted match over a bounded list of the closest eligible configured
   insurances. Only a high- or medium-confidence selection from that list is
   accepted.

The state used for steps 2 and 3 is the valid normalized `state` in the current
request, then the matched patient's stored state, then no state. If a supplied
state is invalid, `state` is added to `dropped_fields`; a matched patient's
stored state can still provide the lookup context. State helps resolve the
insurance but does not participate in patient matching.

An inferred match can select only an active insurance belonging to the
authenticated company with a nonblank trading-partner service ID. This
eligibility boundary applies only to `insurance_name`. An explicit
`insurance_id` bypasses name resolution and retains its existing behavior,
including support for an active company insurance without a trading-partner
service ID.

When a name cannot be resolved, Solum skips only that payor, continues creating
or updating the patient and any other valid payors, and includes `payors` once
in `dropped_fields`. Resolution is lookup-only: it never creates or modifies an
insurance.

### Insurance reference examples

Use a configured UUID directly:

```json theme={null}
{
  "payors": [
    {
      "insurance_id": "00000000-0000-4000-8000-000000000020",
      "payor_responsibility": "primary",
      "insured_member_id": "W884412307"
    }
  ]
}
```

Let the patient's state disambiguate a generic Medicaid name:

```json theme={null}
{
  "first_name": "Jane",
  "last_name": "Doe",
  "date_of_birth": "2016-03-14",
  "state": "VA",
  "payors": [
    {
      "insurance_name": "Medicaid",
      "payor_responsibility": "primary",
      "insured_member_id": "VA-884412307"
    }
  ]
}
```

For example, this can resolve to an eligible configured Virginia Medicaid
insurance without persisting the word `Medicaid` from the request.

An unresolved name does not fail the patient upsert:

```json theme={null}
// request
{
  "first_name": "Jane",
  "last_name": "Doe",
  "date_of_birth": "2016-03-14",
  "payors": [
    {
      "insurance_name": "Unknown Regional Plan",
      "payor_responsibility": "primary"
    }
  ]
}

// response (patient fields abbreviated)
{
  "patient": { "id": "..." },
  "matched": false,
  "created": true,
  "match_reason": null,
  "dropped_fields": ["payors"],
  "payor_results": [
    {
      "payor_responsibility": "primary",
      "submitted_insurance_name": "Unknown Regional Plan",
      "resolved_insurance_id": null,
      "resolved_insurance_name": null,
      "trading_partner_service_id": null,
      "payor_id": null,
      "coverage_chain_id": null,
      "outcome": "unresolved",
      "resolution_method": null,
      "reason_code": "no_configured_match",
      "message": "No configured insurance matched this name."
    }
  ]
}
```

***

## Payor reconciliation

After the patient is created or matched, each resolved payor is reconciled
independently:

1. **Resolve every `insurance_name` to a canonical `insurance_id`.** Unresolved names are skipped and reported through `dropped_fields`; explicit IDs bypass this lookup.
2. **Match each surviving request payor by `insurance_id`** to existing patient
   coverage chains. When the patient holds **two** coverages on that same
   carrier, the tie is broken by `external_id` first (a coverage already
   carrying the ID you sent wins), then by `insured_member_id`. If neither
   decides it, a new coverage is started rather than guessing between two
   policies.
3. **If the existing payor has any service in `in_progress` or `completed` verification status**, it is **archived** (preserved as historical) and a new payor row is created. Otherwise it's updated in place.
4. **Tier collisions** — any other active payor occupying the same `payor_responsibility` (primary / secondary / tertiary) is archived to satisfy the `(patient_id, payor_responsibility)` unique index.
5. **Invalid explicit IDs** and name-based matches that become unavailable before validation are skipped, and `payors` is added to `dropped_fields`.

Each coverage runs in its own transaction. A failure while saving one payor
therefore does not undo the patient or another successfully saved payor. A
submission that is already represented exactly is reported as `unchanged` and
does not create a new payor, service, subscriber, or service-location row.

This preserves prior verification-of-benefits context as separate historical
rows when carriers change, instead of overwriting them, while making partial
success explicit in the response.

### Coverage `external_id`

Each payor may carry an `external_id` — your own identifier for that coverage. It belongs to the coverage as a whole, so every historical version of it reports the same value, and no two coverages in your company can hold the same one.

Send it on every sync and it does two jobs: it picks out the right coverage when a patient has two policies on one carrier (step 2 above), and it is stored on whichever coverage the payor lands on, replacing any previous value.

Two rules worth knowing:

* **Set only, never clear.** Omitting it, or sending `null` or an empty string, leaves whatever is already stored untouched. To remove an ID, use the payor endpoints, where an explicit `null` means "clear".
* **Never taken from another coverage.** If the ID you send already belongs to a different coverage, the payor is still written and only that field is skipped — `payors.external_id` then appears in `dropped_fields`. (`POST /v1/patients` rejects the whole request with `409` instead; the forgiving behavior is specific to this endpoint.)

The response returns the resolved `payor_id` and `coverage_chain_id` in the
corresponding `payor_results` item. Read the full payor record back with
`expand[]=payors` on the patient or payor endpoints.

<Warning>
  There is one narrow case where a coverage ID you sent is not stored and this endpoint tells you so only through `dropped_fields`: another request claims the same ID in the moment between our check and our write. The patient and payor are already saved by then, so we keep them and skip the ID rather than failing the whole request.

  On `POST /v1/patients` that same race has **no** signal — there is no `dropped_fields` on that endpoint, so you would get a normal success for a patient whose coverage ID was never stored. If you rely on these IDs for reconciliation, read them back with `expand[]=payors` after a create, or use this endpoint and watch `dropped_fields`.
</Warning>

`POST /v1/patients/batch` does **not** accept this field; a CSV row carrying it fails that row's validation.

***

## Response

```json theme={null}
200 OK
{
  "patient": { /* full Patient object */ },
  "matched": false,
  "created": true,
  "match_reason": null,
  "dropped_fields": [],
  "payor_results": []
}
```

| Field            | Type           | Meaning                                                                                                        |
| ---------------- | -------------- | -------------------------------------------------------------------------------------------------------------- |
| `patient`        | object         | The full patient record after the operation.                                                                   |
| `matched`        | boolean        | `true` if an existing patient was updated.                                                                     |
| `created`        | boolean        | `true` if a new patient was created.                                                                           |
| `match_reason`   | string \| null | One of `external_id`, `demographics`, `phone_fuzzy_name`, `email_fuzzy_name`. `null` when `created` is `true`. |
| `dropped_fields` | string\[]      | Field names that were supplied but didn't make it onto the record. See [Dropped fields](#dropped-fields).      |
| `payor_results`  | object\[]      | One result per submitted payor, in request order. Empty when no payors were supplied.                          |

`matched` and `created` are exclusive — exactly one is `true`.

### Payor result fields

| Field                                              | Meaning                                                                           |
| -------------------------------------------------- | --------------------------------------------------------------------------------- |
| `payor_responsibility`                             | Submitted coverage tier.                                                          |
| `submitted_insurance_name`                         | Lookup text supplied by the caller, or `null` for an explicit insurance UUID.     |
| `resolved_insurance_id`, `resolved_insurance_name` | Configured insurance selected for this coverage.                                  |
| `trading_partner_service_id`                       | Service identifier on the selected configured insurance.                          |
| `payor_id`, `coverage_chain_id`                    | Persisted coverage identifiers, when available.                                   |
| `outcome`                                          | `created`, `updated`, `unchanged`, `unresolved`, or `failed`.                     |
| `resolution_method`                                | `canonical_name`, `alias`, `state_variant`, or `ai` for a successful name lookup. |
| `reason_code`                                      | Stable reason for an unresolved or failed result.                                 |
| `message`                                          | Safe human-readable explanation, when one is needed.                              |

An `unresolved` outcome can use `no_configured_match`,
`configured_insurance_inactive`, `insurance_not_vob_ready`, `ambiguous_match`,
`match_confidence_too_low`, or `resolution_unavailable`. A save failure uses
`persistence_failed`. The response omits member IDs and internal exception
details from these result objects.

***

## Dropped fields

A field appears in `dropped_fields` when:

| Cause                                                                                                             | Fields affected                                                                        |
| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| Unparseable value                                                                                                 | `phone_number`, `additional_phone_number`, `email`, `date_of_birth`, `gender`, `state` |
| Phone-immutability lock on matched patient                                                                        | `phone_number`                                                                         |
| Unique constraint conflict (another patient owns it)                                                              | `phone_number`, `email`                                                                |
| Unresolvable name / UUID                                                                                          | `workflow_stage_id`, `assigned_user_id`, `tags` (full **or** partial)                  |
| Concurrent external-id conflict with another active patient, or a failed retry after reclaiming an orphaned value | `external_id`                                                                          |
| Unresolved `insurance_name`, or an invalid, inactive, missing, or cross-company `insurance_id`                    | `payors`                                                                               |
| Coverage `external_id` already held by a different coverage, or sent twice in one request                         | `payors.external_id`                                                                   |

`dropped_fields` is always present (empty array if nothing was dropped). Treat it as a soft-warning channel — surface it to your reconciliation logs.

***

## Examples

### Create a new patient

```bash theme={null}
curl -X POST https://api.getsolum.com/v1/patients/upsert \
  -H "X-API-Key: $SOLUM_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Jane",
    "last_name": "Doe",
    "date_of_birth": "04/12/1985",
    "phone_number": "(555) 123-4567",
    "email": "JANE.DOE@example.com",
    "state": "CA",
    "external_id": {
      "type_id": "8f3b2a1c-...",
      "value": "PMS-99041"
    }
  }'
```

```json theme={null}
{
  "patient": { "id": "...", "first_name": "Jane", ... },
  "matched": false,
  "created": true,
  "match_reason": null,
  "dropped_fields": []
}
```

### Update via external\_id

Same external\_id — value updates flow through:

```json theme={null}
// request
{
  "external_id": { "type_id": "8f3b2a1c-...", "value": "PMS-99041" },
  "email": "jane.new@example.com",
  "address": "123 Main St"
}

// response
{
  "patient": { "id": "...", "email": "jane.new@example.com", ... },
  "matched": true,
  "created": false,
  "match_reason": "external_id",
  "dropped_fields": []
}
```

### Forgiving normalization in action

```json theme={null}
// request — DOB unparseable, gender synonym, bad email
{
  "first_name": "Sam",
  "last_name": "Lee",
  "phone_number": "5551234567",
  "date_of_birth": "13/14/1985",
  "gender": "M",
  "email": "not an email"
}

// response — patient created, two fields dropped, gender normalized
{
  "patient": { "id": "...", "gender": "male", "date_of_birth": null, ... },
  "matched": false,
  "created": true,
  "match_reason": null,
  "dropped_fields": ["email", "date_of_birth"]
}
```

***

## Differences vs. other patient endpoints

| Behavior                | `POST /patients`              | `POST /patients/batch`            | `POST /patients/upsert`                |
| ----------------------- | ----------------------------- | --------------------------------- | -------------------------------------- |
| Bad phone / email / DOB | `400`                         | `400` (whole batch fails)         | Silently dropped                       |
| Existing patient match  | `409` conflict                | Additive merge (never overwrites) | Overwrites with provided fields        |
| Required fields         | Strict                        | Strict                            | Phone **or** full demographics         |
| Returns match metadata  | No                            | No                                | Yes (`match_reason`, `dropped_fields`) |
| Status code             | `201` create / `409` conflict | `201`                             | `200` (always)                         |

Use `POST /patients` for human-driven flows where errors should surface immediately. Use `POST /patients/upsert` for partner integrations where input quality varies and you'd rather get a usable record back than a 400.

***

## Troubleshooting

| Symptom                                                          | Cause                                                                                                                                  | Fix                                                                                                     |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `400 Insufficient identifying information`                       | Phone normalized to null and demographics incomplete.                                                                                  | Send a valid phone OR all of `first_name` + `last_name` + `date_of_birth`.                              |
| `400 external_id.type_id … does not belong to this company`      | Wrong company's `external_id_type` UUID.                                                                                               | Use a `type_id` from your own company's external-id types.                                              |
| `match_reason: phone_fuzzy_name` when you expected a new patient | An existing patient owned that phone and the names fuzzy-matched.                                                                      | Send `external_id` for unambiguous identity, or use a unique phone per patient.                         |
| `dropped_fields: ["phone_number"]` after update                  | Either the matched patient's phone was locked (`first_communication_at` set), or another patient already owns that number.             | Inspect the returned patient — the original phone is preserved.                                         |
| `dropped_fields: ["external_id"]`                                | The `(type_id, value)` already exists on a different patient, OR the matched patient already has a different value for that `type_id`. | Resolve the duplicate identifier upstream.                                                              |
| `dropped_fields: ["tags"]` despite valid tag names               | At least one tag name didn't match any existing company tag.                                                                           | Confirm tag names are exact (case-sensitive) and exist in your company.                                 |
| `dropped_fields: ["payors"]`                                     | At least one insurance name could not be resolved, or an explicit insurance ID was not a valid active insurance for your company.      | Confirm the configured insurance and, for name-based matching, send the patient's state when available. |
