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:
- Normalizes forgiving fields — bad phone / email / DOB / gender / state values are silently dropped.
- Looks for an existing patient in priority order (external_id → demographics → phone+name → email+name).
- 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.
- Returns which patient path it took, a list of fields it dropped, and one ordered processing result for every submitted payor.
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).
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. 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 fromPOST /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
Theexternal_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_idwhen your integration already knows the configured insurance UUID. - Send
insurance_namewhen Solum should look up the configured insurance from free text.
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:
- A unique normalized match against a configured canonical name or alias.
- A unique match after adding the patient’s state name and postal code before and after the submitted name.
- 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.
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: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:- Resolve every
insurance_nameto a canonicalinsurance_id. Unresolved names are skipped and reported throughdropped_fields; explicit IDs bypass this lookup. - Match each surviving request payor by
insurance_idto existing patient coverage chains. When the patient holds two coverages on that same carrier, the tie is broken byexternal_idfirst (a coverage already carrying the ID you sent wins), then byinsured_member_id. If neither decides it, a new coverage is started rather than guessing between two policies. - If the existing payor has any service in
in_progressorcompletedverification status, it is archived (preserved as historical) and a new payor row is created. Otherwise it’s updated in place. - 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. - Invalid explicit IDs and name-based matches that become unavailable before validation are skipped, and
payorsis added todropped_fields.
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
nullor an empty string, leaves whatever is already stored untouched. To remove an ID, use the payor endpoints, where an explicitnullmeans “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_idthen appears indropped_fields. (POST /v1/patientsrejects the whole request with409instead; the forgiving behavior is specific to this endpoint.)
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.
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 indropped_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.

