Upsert Patient
POST /v1/patients/upsert is a single endpoint integrations can call instead of POST /v1/patients (strict — 409 on conflict).
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 coverage.
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
MMDDYYYY (such as 04121985) is read with the next release. Until then, eight digits are read only as YYYYMMDD, and any other eight-digit date_of_birth is dropped.Address
Workflow / assignment / tags
Nested entities
Saving reviewed extraction data
Send the reviewed patient, referral, and policy fields directly in the upsert request. Optionally includesource_extraction_id to attach the source documents
from a completed fax extraction in your company. The patient save, document
links, and fax review status commit together.
source_extraction_id records provenance; it does not read or apply the extracted
fields. For document linkage alone, send patient_id and source_extraction_id
without patient, referral, or policy data. An optional workflow_stage_id can
still move the selected patient to a stage. Explicit null values and external-ID
reconciliation alone do not turn a fax link into an application of patient data.
Missing or unavailable extractions return 404; unfinished extractions and
documents owned by another record are rejected without committing the patient
save. Deleted source
documents are skipped; remaining active documents in your company are linked.
Concurrent saves using a locked source extraction or document return 409
immediately. Retry the save after the other request completes.
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.
With the next release,
dropped_fields in this response also lists a phone_number, email or date_of_birth that Solum could not use because it was unreadable, or a date_of_birth outside 1900-01-01 to today. The message names each one and what to send instead.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. 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 todropped_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.
This is the patient’s external ID. Each coverage under policies carries its
own, separate external_id — see Coverage external_id.
Coverages
Every item underpolicies identifies a coverage’s 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.
Besides the insurance reference, a policy item takes:
insurance_name is lookup-only. A successful match is converted to the
configured insurance_id before the coverage 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
policy_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. An explicit insurance_id bypasses name resolution.
When a name cannot be resolved, Solum skips only that coverage, continues creating
or updating the patient and any other valid coverages, and includes policies 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:
Matching an incoming coverage to a policy
After everyinsurance_name resolves (or is skipped), each surviving coverage
is matched against the patient’s existing policies — one policy per real-world
coverage. The whole policies[] list is matched and written together, so a
tier swap between two existing policies comes out right regardless of item
order. A coverage is matched on its identity only, never guessed:
- A
policy_idis given: that policy is updated — including itsmember_number. It must be an active policy of the matched patient on the coverage’s insurance; otherwise the whole request fails with409(param: "policy_id"). - An
external_idis given and an ACTIVE policy holds it: that policy is updated — including its insurance andmember_number, which is how you correct a member ID. An ID held by an ACTIVE policy of a different patient fails the whole request with409(see Coverageexternal_id). When no active policy holds the ID — including when only a deleted policy does — the coverage is matched by rule 2 instead (see Deleting a coverage). - Otherwise, the patient’s ACTIVE policy with the same insurance and the
same
member_number(compared after trimming) is updated. There is at most one: a patient never holds two active policies with the same insurance and member number. If that policy has noexternal_id, it takes the one you sent; if it holds a different one, the whole request fails with409— you are describing a different record. - Nothing matched: a new policy is created. A different
member_numberon an insurance the patient already has is a new policy, not a correction of the old one — send the policy’spolicy_idorexternal_idto correct a member ID.
What can fail, and what fails with it
A request that contradicts itself fails whole, before anything is written — no patient, no coverage:
A problem with one field of one coverage never fails the request or the
patient’s other coverages. Solum drops that field, saves the coverage, and
reports the drop on that coverage’s result and in
dropped_fields:
A coverage whose insurance does not resolve is reported on its own result
(
unresolved) and the others are saved.
Everything else is written as one unit: all the saved coverages, and the
tier moves of any policy they displaced, land together or not at all. If that
write fails (a database error, or a concurrent change that a retry does not
resolve), no coverage of the request is saved and every one is reported as
failed with reason_code: persistence_failed. The patient itself is still
created or updated.
Tier collisions
An incoming coverage that claims acoverage_tier another active policy of
the patient already holds moves that other policy to the first free tier,
primary first. Tiers the same request frees count as free, so moving an
existing policy into the tier above swaps the two. Nothing is deleted to make
room. The move is recorded in that policy’s activity log, which the patient’s
History in the app shows.
For example, the patient has Cigna as primary and Humana as secondary, and you
send Aetna as primary: Aetna becomes primary, Humana stays secondary, and Cigna
moves to tertiary.
The moved policy is reported as its own item in policy_results, appended
after the results for every coverage you submitted, with action: "moved" and
its new coverage_tier. With every tier taken there is nowhere to move it: the
request fails with 409 (param: "coverage_tier") and nothing is written —
delete a policy first.
Deleting a coverage — and reusing its external_id
This endpoint never deletes a coverage: a policy goes away only when you delete it through the policy endpoints (or in the app). There is no way to reactivate a deleted policy — the only way back is a new coverage with the same details. Anexternal_id is unique only among a company’s active policies. Once a
policy is deleted, its external_id is immediately free:
- Sending that same
external_idagain never finds or revives the deleted policy — matching rule 1 above only ever looks at active policies. This applies even when the deleted policy belonged to the same patient. - The coverage is matched by insurance and
member_numberinstead (rule 2). When the patient has an active policy with both, the coverage updates it and the policy takes theexternal_id— how an integration attaches its IDs to coverages that were first created by hand. Otherwise it becomes a new policy under thatexternal_id, with a fresh history.
Coverage external_id
Each policy 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 active coverages in your company can hold the same one (a deleted coverage’s ID is free — see above).
Send it on every sync and it does two jobs: it picks out the right policy when a patient has several on one carrier (matching rule 1), and it is stored on whichever policy the coverage 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 policy endpoints, where an explicitnullmeans “clear”. - Never taken from another patient’s active coverage. If the ID you send already belongs to a different patient’s active policy, the whole request fails with
409(param: "external_id") and nothing is written — the same answerPOST /v1/patientsgives. Usually it means the same person exists twice in Solum; merge the two patients, or send the ID for the patient that holds it. Moving an ID between two active coverages of the same patient is allowed.
policy_id and payor_id (the policy’s
current version once written) in the corresponding policy_results item. Read
the full policy record back with expand[]=policies on the patient endpoints,
or GET /v1/patient-policies/{policy_id}.
If another request claims the same coverage ID in the moment between our check and our write, this endpoint tries the coverages once more; if the ID is still taken, the request fails with
409, like any other taken coverage ID.Response
matched and created are exclusive — exactly one is true.
Policy result fields
An
unresolved action can use no_configured_match,
configured_insurance_inactive, ambiguous_match, match_confidence_too_low,
or resolution_unavailable. A failed action uses
persistence_failed. A moved result (an existing policy pushed to another tier by a tier collision) carries no
reason_code. 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
A coverage takes another one’s tier
Differences vs. other patient endpoints
Use
POST /patients for human-driven flows where errors should surface immediately. Use POST /patients/upsert for partner integrations and reviewed extraction data where input quality varies and matching should create or update the patient.

