Skip to main content

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:
  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 coverage.
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.
Coverages are sent as policies, not payors. A request that still sends payors is rejected with 422 — there is no alias. See Coverages below.

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 include source_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).
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.
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 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. This is the patient’s external ID. Each coverage under policies carries its own, separate external_id — see Coverage external_id.

Coverages

Every item under policies identifies a coverage’s 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. 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:
  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. 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:
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:

Matching an incoming coverage to a policy

After every insurance_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:
  1. A policy_id is given: that policy is updated — including its member_number. It must be an active policy of the matched patient on the coverage’s insurance; otherwise the whole request fails with 409 (param: "policy_id").
  2. An external_id is given and an ACTIVE policy holds it: that policy is updated — including its insurance and member_number, which is how you correct a member ID. An ID held by an ACTIVE policy of a different patient fails the whole request with 409 (see Coverage external_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).
  3. 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 no external_id, it takes the one you sent; if it holds a different one, the whole request fails with 409 — you are describing a different record.
  4. Nothing matched: a new policy is created. A different member_number on an insurance the patient already has is a new policy, not a correction of the old one — send the policy’s policy_id or external_id to correct a member ID.
Deleted policies are never matched by any of these rules and never come back: a coverage that looks identical to a deleted one updates an active policy with the same identity when there is one, and otherwise becomes a new policy.

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 a coverage_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. An external_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_id again 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_number instead (rule 2). When the patient has an active policy with both, the coverage updates it and the policy takes the external_id — how an integration attaches its IDs to coverages that were first created by hand. Otherwise it becomes a new policy under that external_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 null or an empty string, leaves whatever is already stored untouched. To remove an ID, use the policy endpoints, where an explicit null means “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 answer POST /v1/patients gives. 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.
The response returns the resolved 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 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

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.

Troubleshooting