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 path it took plus a list of fields it dropped, so you can reconcile on your end.
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

FieldTypeNotes
external_id.type_idUUIDMust reference an external_id_type belonging to your company.
external_id.valuestringYour system’s identifier for this patient. Used as the highest-priority match key.

Demographics & contact

FieldTypeNormalization
first_name, last_name, middle_namestringTrimmed, stored as given.
date_of_birthstringAccepts 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.
genderstringCoerced to male / female / other. Accepts synonyms (M, F, man, woman, nb, non-binary, x, unknown, …).
phone_number, additional_phone_numberstringNormalized to E.164 (+1XXXXXXXXXX). Accepts 10 digits, 11 digits starting with 1, with or without formatting.
emailstringLowercased and regex-validated.

Address

FieldTypeNotes
address, address2, city, zipstringTrimmed pass-through.
statestring2-letter postal code, full name (California, new york), or common short form (Calif, Mass, Tenn).

Workflow / assignment / tags

FieldTypeNotes
workflow_stage_idstringUUID or stage name. Unresolvable values are dropped.
assigned_user_idstringUUID or user email. Unresolvable values are dropped.
tagsstring[]Array of tag UUIDs or tag names. Tags that don’t resolve are dropped (full or partial → tags appears in dropped_fields).

Nested entities

FieldTypeNotes
referralobjectSame shape as POST /v1/patients. Internal validators still apply.
payorsarrayInsurance-keyed payor upsert on match — see Payors on match.
custom_fieldsobjectPass-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:
400 Bad Request
{
  "detail": "Insufficient identifying information: provide either a phone number or complete demographics (first_name, last_name, date_of_birth)"
}
This is the only field-level invariant the endpoint enforces — everything else is best-effort.

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’s the highest-priority match key, and it gets persisted on the resolved patient when there’s room.
Existing stateBehavior
type_id doesn’t belong to your company400 Bad Request (validation error).
Patient has no record for this type_idA new external-id record is created.
Patient already has the same (type_id, value)No-op.
Patient has a different value for this type_idExisting value is left untouched; external_id added to dropped_fields.
Another patient already owns (type_id, value)Insertion fails silently; external_id added to dropped_fields.
External IDs are treated as stable identifiers — the endpoint will not silently rewrite one that’s already been recorded.

Payors on match

When a match is found and payors are included, the endpoint runs an insurance-keyed upsert (not the simpler tier-keyed merge in POST /v1/patients):
  1. Match each request payor by insurance_id to existing patient payors.
  2. 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.
  3. 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.
  4. Payors without an insurance_id can’t be deduped; they’re skipped and payors is added to dropped_fields.
This preserves prior verification-of-benefits context as separate historical rows when carriers change, instead of overwriting them.

Response

200 OK
{
  "patient": { /* full Patient object */ },
  "matched": false,
  "created": true,
  "match_reason": null,
  "dropped_fields": []
}
FieldTypeMeaning
patientobjectThe full patient record after the operation.
matchedbooleantrue if an existing patient was updated.
createdbooleantrue if a new patient was created.
match_reasonstring | nullOne of external_id, demographics, phone_fuzzy_name, email_fuzzy_name. null when created is true.
dropped_fieldsstring[]Field names that were supplied but didn’t make it onto the record. See Dropped fields.
matched and created are exclusive — exactly one is true.

Dropped fields

A field appears in dropped_fields when:
CauseFields affected
Unparseable valuephone_number, additional_phone_number, email, date_of_birth, gender, state
Phone-immutability lock on matched patientphone_number
Unique constraint conflict (another patient owns it)phone_number, email
Unresolvable name / UUIDworkflow_stage_id, assigned_user_id, tags (full or partial)
External-id conflict (existing or owned by another patient)external_id
Payor without insurance_idpayors
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

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"
    }
  }'
{
  "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:
// 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

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

BehaviorPOST /patientsPOST /patients/batchPOST /patients/upsert
Bad phone / email / DOB400400 (whole batch fails)Silently dropped
Existing patient match409 conflictAdditive merge (never overwrites)Overwrites with provided fields
Required fieldsStrictStrictPhone or full demographics
Returns match metadataNoNoYes (match_reason, dropped_fields)
Status code201 create / 409 conflict201200 (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

SymptomCauseFix
400 Insufficient identifying informationPhone 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 companyWrong 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 patientAn 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 updateEither 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 namesAt least one tag name didn’t match any existing company tag.Confirm tag names are exact (case-sensitive) and exist in your company.