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 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
| Field | Type | Notes |
|---|
external_id.type_id | UUID | Must reference an external_id_type belonging to your company. |
external_id.value | string | Your system’s identifier for this patient. Used as the highest-priority match key. |
| Field | Type | Normalization |
|---|
first_name, last_name, middle_name | string | Trimmed, stored as given. |
date_of_birth | string | Accepts 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. |
gender | string | Coerced to male / female / other. Accepts synonyms (M, F, man, woman, nb, non-binary, x, unknown, …). |
phone_number, additional_phone_number | string | Normalized to E.164 (+1XXXXXXXXXX). Accepts 10 digits, 11 digits starting with 1, with or without formatting. |
email | string | Lowercased and regex-validated. |
Address
| Field | Type | Notes |
|---|
address, address2, city, zip | string | Trimmed pass-through. |
state | string | 2-letter postal code, full name (California, new york), or common short form (Calif, Mass, Tenn). |
| Field | Type | Notes |
|---|
workflow_stage_id | string | UUID or stage name. Unresolvable values are dropped. |
assigned_user_id | string | UUID or user email. Unresolvable values are dropped. |
tags | string[] | Array of tag UUIDs or tag names. Tags that don’t resolve are dropped (full or partial → tags appears in dropped_fields). |
Nested entities
| Field | Type | Notes |
|---|
referral | object | Same shape as POST /v1/patients. Internal validators still apply. |
payors | array | Insurance-keyed payor upsert on match — see Payors on match. |
custom_fields | object | Pass-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_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 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 state | Behavior |
|---|
type_id doesn’t belong to your company | 400 Bad Request (validation error). |
Patient has no record for this type_id | A new external-id record is created. |
Patient already has the same (type_id, value) | No-op. |
Patient has a different value for this type_id | Existing 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):
- Match each request payor by
insurance_id to existing patient payors.
- 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.
- 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.
- 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": []
}
| Field | Type | Meaning |
|---|
patient | object | The full patient record after the operation. |
matched | boolean | true if an existing patient was updated. |
created | boolean | true if a new patient was created. |
match_reason | string | null | One of external_id, demographics, phone_fuzzy_name, email_fuzzy_name. null when created is true. |
dropped_fields | string[] | 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:
| Cause | Fields affected |
|---|
| Unparseable value | phone_number, additional_phone_number, email, date_of_birth, gender, state |
| Phone-immutability lock on matched patient | phone_number |
| Unique constraint conflict (another patient owns it) | phone_number, email |
| Unresolvable name / UUID | workflow_stage_id, assigned_user_id, tags (full or partial) |
| External-id conflict (existing or owned by another patient) | external_id |
Payor without insurance_id | payors |
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
| Behavior | POST /patients | POST /patients/batch | POST /patients/upsert |
|---|
| Bad phone / email / DOB | 400 | 400 (whole batch fails) | Silently dropped |
| Existing patient match | 409 conflict | Additive merge (never overwrites) | Overwrites with provided fields |
| Required fields | Strict | Strict | Phone or full demographics |
| Returns match metadata | No | No | Yes (match_reason, dropped_fields) |
| Status code | 201 create / 409 conflict | 201 | 200 (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
| Symptom | Cause | Fix |
|---|
400 Insufficient identifying information | Phone 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 company | Wrong 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 patient | An 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 update | Either 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 names | At least one tag name didn’t match any existing company tag. | Confirm tag names are exact (case-sensitive) and exist in your company. |