Importing Measurements
POST /api/v1/import
Authenticated. The central endpoint. One request uploads a batch of 1–50 measurements for one patient. The mode discriminator determines how the patient is identified.
The four modes
The top-level object is a discriminated union on mode. Each variant is .strict() — unknown fields cause 400 (deliberate, to catch adapter bugs early).
a) follow_up — known patient (already linked)
{ "mode": "follow_up", "mkClientId": "<uuid>", "measurements": [ … ] }
The client knows the internal client UUID (from the mapping sync). Measurements are attributed directly to the patient. Precondition: valid consent (treatment consent) for the patient — otherwise 403 CONSENT_MISSING / 403 CONSENT_WITHDRAWN.
b) first_visit — unknown patient (first upload)
{
"mode": "first_visit",
"devicePseudonym": "<32 hex>",
"initialsHint": "AB",
"dobMonthYearHint": "1985-03",
"measurements": [ … ]
}
Lands in the pending queue for manual linking in the portal.
devicePseudonym: exactly 32 hex chars (^[0-9a-f]{32}$, case-insensitive, lowercased). SeedevicePseudonymbelow.initialsHint: 1–3 uppercase letters^[A-ZÄÖÜ]+$(only a visual aid in the portal).dobMonthYearHint:^\d{4}-(0[1-9]|1[0-2])$(birth month/year, only a visual aid).
c) pseudonym_only — pseudonym without hints (fallback)
{ "mode": "pseudonym_only", "devicePseudonym": "<32 hex>", "measurements": [ … ] }
Measurements are accepted only if the pseudonym is already linked server-side. Otherwise no buffering → response with warning resend_with_hints (the client should retry with first_visit + hints).
d) token — persistent re-export (preferred for repeat sends)
{ "mode": "token", "persistentToken": "tk_<32 hex>", "devicePseudonym": "<32 hex>?", "measurements": [ … ] }
persistentToken (^tk_[0-9a-f]{32}$) stably identifies an already-linked patient. An optional devicePseudonym is used only for stale detection (if it differs from the token’s patient, the token wins; warning stale_pseudonym). A foreign token (another practice) → 403 TOKEN_FOREIGN.
Measurement object (identical in all modes)
Required per measurement: eye (OD|OS), measuredAt (ISO-8601), sourceAdapterId (1–64), sourceDevice (1–128). Optional dateOnly: true (date without time → midnight UTC, portal shows date only). All clinical fields are optional/nullable; each has a plausibility min/max (range violation → 400).
| Group | Fields (unit) |
|---|---|
| Source | sourceAdapterId, sourceSerial?, sourceDataId?, sourceDevice, sourceDeviceName? |
| Refraction | sphere (−30…30 D), cylinder (−15…15 D), axis (0…180°), visualAcuitySC/visualAcuityCC (0…2), cycloplegia (bool), method (≤32), addition (0…5 D), vertex (5…25 mm), prism (0…40), prismBase (0…360°), sphericalEquivalent (−30…30 D) |
| Biometry | axialLength (10…45 mm), anteriorChamberDepth (1…7 mm), lensThickness (1…8 mm), vitreousChamberDepth (5…35 mm), axialSnr (≥0), axialMeasurementCount (int ≥0), axialStandardDeviation (≥0), cornealThickness (100…1500 µm), choroidalThickness (10…1000 µm), pupilDiameter (1…12 mm), pupilLighting (≤32), pupilCenterX/pupilCenterY, cornealDiameter (5…18 mm), bodyHeight (30…250 cm) |
| Keratometry | cornealAstigmatism (0…20 D), cornealAxis1/cornealAxis2 (0…180°), cornealRadius1/cornealRadius2 (4…12 mm) |
| Patient | gender, ethnicity (fixed enums) |
sourceSerial + sourceDataId are the building blocks of duplicate detection.
Success response (200)
The import always replies with a counter object (even on partial failures):
{
"success": true,
"data": {
"total": 4,
"uploaded": 3,
"duplicate": 1,
"assigned": 0,
"unmatched": 3,
"errors": 0,
"results": [ { "eye": "OD", "status": "stored" }, { "eye": "OS", "status": "duplicate" } ],
"persistent_token": null,
"skip_pseudonyms": [ "a1b2…", "f5e6…" ],
"warnings": [ { "code": "rejected_pseudonym", "message": "…" } ]
}
}
| Field | Meaning |
|---|---|
total | Number of measurements sent |
uploaded | Newly stored |
duplicate | Detected as duplicate (not stored again) |
assigned | Directly attributed to a patient (only follow_up/token) |
unmatched | Placed in the pending queue (only first_visit) |
errors | Measurements without usable values, etc. |
results[] | Per measurement: eye + status (stored|duplicate|rejected) + optional errorCode |
persistent_token | For a linked patient, the stable tk_… token for future token uploads, else null |
skip_pseudonyms[] | Always present — all currently rejected pseudonyms of the practice; the client caches them and skips them on the next export (identical to GET /rejected-clients) |
warnings[] | e.g. rejected_pseudonym, resend_with_hints, stale_pseudonym |
Duplicate semantics
An import is a duplicate when the same logical measurement already exists. Server-side via INSERT … ON CONFLICT DO NOTHING/UPDATE on the natural key (account_id, device_pseudonym, eye, measured_at in the pending queue, or measurement uniques on the linked patient). duplicate: true only means “already present” — the status of the existing row (even rejected) is left untouched.
devicePseudonym — the patient identifier
- A stable, client-computed hash per patient (in the reference implementation: 128-bit truncated HMAC-SHA256 → 32 hex chars). The server does not compute it and validates only the format (
^[0-9a-f]{32}$). - For the OEM integration it is an opaque, stable key: same patient + same client ⇒ same pseudonym. Your backend treats it, per practice (
account_id), as a unique patient key — it needs to know nothing more about it. - It is unique per practice (tenant-scoped), not global.