Skip to main content

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.
This endpoint accepts unknown fields silently. Sending proprietary metadata won’t 400 — it just won’t be persisted.

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

Identification

Demographics & contact

Address

Workflow / assignment / tags

Nested entities


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:
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_iddemographicsphone_fuzzy_nameemail_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. 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 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. 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:
Let the patient’s state disambiguate a generic Medicaid name:
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:

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.
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.
POST /v1/patients/batch does not accept this field; a CSV row carrying it fails that row’s validation.

Response

matched and created are exclusive — exactly one is true.

Payor result fields

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

Update via external_id

Same external_id — value updates flow through:

Forgiving normalization in action


Differences vs. other patient endpoints

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