# Submit several readings for one patient

Canonical page: https://docs.ovok.com/signals/standalone/api/observations/submit-several-readings-for-one-patient

{/* Generated from the API Explorer document by scripts/generate-signals-api-pages.mjs. Edit ovok-core, not this page. */}


# Submit several readings for one patient [#submit-several-readings-for-one-patient]

| Method | Path |
| --- | --- |
| `POST` | `/v1/signals/api/observation/{patientId}/batch` |

<Availability route="POST /v1/signals/api/observation/{patientId}/batch" />

`{observations: [<FHIR Observation>]}`, each as `POST /api/observation/:patientId` takes it. In one transaction, evaluated in the order given. Answers v1's array, one entry per reading in request order: the reading as stored, or for a resend the 409 `DUPLICATE_READING` error body in its place. An array has nowhere to carry the ingest outcome, so a caller that needs `alertIds` posts them singly.

The body is a FHIR R4 Observation: `code`, `value[x]` (`valueQuantity`, `valueInteger`, `valueString`, `valueBoolean`, `valueDateTime`, `valueCodeableConcept`) or `dataAbsentReason`, and a combined device sample's vitals as `component[]` (each `code`, `value[x]`, optional `dataAbsentReason`). The clinical time is `effectiveDateTime`, `effectiveInstant` or `effectivePeriod.start`, else `issued`, else the server's receipt; `device` is `{reference, display}`; the first `identifier`'s `value` is stored as v1's `refId`. Other FHIR elements (`meta`, `extension`, `performer`, `interpretation`, `referenceRange`, …) are accepted and ignored. v1's `observedAt` (it wins over `effective[x]`), `refId`, string `dataAbsentReason` and `device.id` still work. A node with `dataAbsentReason` and no value is judged as carrying no value. `valueSampledData` is judged by its latest sample and stored whole. A `valueDateTime`, on the reading or a component, is judged and aggregated as epoch ms (`valueNumber`) and listed as sent.

**History is never judged.** A reading whose `category` has a coding the tenant names as history (its `historyCategories`; the default pack's: `batch-data`, `statistics`, `presence-batch`, `derived`) is stored and deduplicated like any other, answered with no alerts, and never judged nor read by the rules.

The caller must be one of the patient's submitters, admins included: a known patient it is not attached to is a 403, an unknown one a 404 with type `PATIENT_NOT_FOUND`.

**Idempotent.** A reading is named by its first `identifier` (`system|value`), else by its clinical time, code, device and `refId`. A resend of one stored within the last hour is a 409 `DUPLICATE_READING` whose `data.observationId` is the stored reading, and is neither stored nor evaluated again. A reading with neither an identifier nor a clinical time is not deduplicated.

Concurrent requests are batched server-side. With `Prefer: respond-sync` evaluation finishes first and the reply adds `alertIds`, `alerts` (`[{id, code}]`), `resolvedAlertIds` and `ruleCoverage`; without it `mode` is `async` and alerts are delivered by webhook.

### Through ovok [#through-ovok]

- **Who can call it:** Submitter apps, and users whose access policy grants the capabilities.
- **Capabilities, all required:** `signals_observations:create`. Project admins have every capability; client applications get no admin bypass.
- If your access covers only some patients (anyone but project admins, manager or submitter apps, and anyone whose Patient access has no criteria), the patient must be one you can update. A patient is named by its Signals id, or by its Medplum Patient id where Signals takes a `refId`; ovok asks Signals which patient a Signals id is.
- Each reading counts its patient as monitored for billing.

## Request [#request]

### Path parameters [#path-parameters]

| Name | Type | Required | Description |
| --- | --- | --- | --- |
| `patientId` | string | Yes | The Signals patient id. |

### Body [#body]

Required. Schema `V1ObservationBatchDto`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `observations` | array of object | Yes | — |
| `observations[].resourceType` | string | No | — |
| `observations[].identifier` | array of object | No | — |
| `observations[].refId` | string | No | — |
| `observations[].status` | string | No | — |
| `observations[].category` | array of object | No | Its codings' codes are stored; a code the tenant names in `historyCategories` makes the reading history: stored, never judged. |
| `observations[].note` | object | No | Accepted as a plain string or as FHIR's `[{text, time}]` Annotations, which the mapper flattens to newline-joined text. Deliberately untyped: typing it as a string would reject real readings ovok sends today. |
| `observations[].code` | object | Yes | — |
| `observations[].subject` | object | No | — |
| `observations[].component` | array of object | No | — |
| `observations[].device` | object | No | `id` (v1) or `reference` (FHIR) names the device. |
| `observations[].dataAbsentReason` | object | No | v1's string or FHIR's CodeableConcept; untyped so that neither is refused. |
| `observations[].observedAt` | string | No | — |
| `observations[].effectiveDateTime` | string | No | — |
| `observations[].effectiveInstant` | string | No | — |
| `observations[].effectivePeriod` | object | No | Only `.start` and `.end` are read, as in v1. |
| `observations[].issued` | string | No | — |
| `observations[].valueQuantity` | object | No | — |
| `observations[].valueSampledData` | object | No | — |
| `observations[].valueString` | string | No | — |
| `observations[].valueInteger` | number | No | — |
| `observations[].valueBoolean` | boolean | No | — |
| `observations[].valueDateTime` | string | No | — |
| `observations[].valueCodeableConcept` | object | No | — |

## Responses [#responses]

| Status | Meaning |
| --- | --- |
| `200` | Per reading, in request order: the reading as stored, or a resend's 409 body. |
| `400` | A path value holds a character ovok does not send to Signals, such as a raw `:` or `+`. Percent-encode it. The query carries a raw `#`, or more than 200 parameters. |
| `401` | Your ovok access token, or your client id and secret, are missing or invalid. |
| `403` | ovok refused the call: you lack a capability this route needs, or your session has no project, or you can read a patient the call names but not update it. From Signals: Authenticated, but the client's role does not permit this, or it is not attached to the patient. |
| `404` | A patient the call names is not one you can read. From Signals: No such patient in this tenant. |
| `409` | Your project has no Signals tenant of its own (`SIGNALS_TENANT_NOT_OWN`), or it is moving to another one. |
| `415` | The body is not JSON. |
| `422` | A patient id is not 1 to 64 letters, digits, `.`, `_`, `~` or `-`. From Signals: The payload failed validation. |
| `429` | Too many requests. Retry after the seconds in `Retry-After`. |
| `500` | ovok hit an unexpected fault, such as your project having no Signals submitter. |
| `502` | Signals answered with something ovok couldn't use, or refused ovok's own credential. |
| `503` | Signals is unavailable. Retry after the seconds in `Retry-After`. From Signals: The evaluation backlog is full, or the readings could not be stored and judged (no database connection free in time, a lock timeout, a stalled database). Nothing was stored; resend after `Retry-After` (2 s). |
| `504` | Signals did not answer in time. |

## Example [#example]

```bash
curl --request POST 'https://api.sandbox.ovok.com/v1/signals/api/observation/:patientId/batch' \
  --user "${OVOK_CLIENT_ID}:${OVOK_CLIENT_SECRET}" \
  --header 'Content-Type: application/json' \
  --data @body.json
```
