Urogram FHIR R4 API (1.7.1)

Integration guide and API reference for hospital EHR/EMR systems connecting to the Urogram FHIR R4 API.

Overview

The Urogram FHIR R4 API provides access to:

  • Device resources: IoT weight monitoring devices assigned to your hospital
  • Observation resources: Urine volume measurements and calculated diuresis values
  • Location endpoint: Discover available wards and their IDs for filtering

All data is automatically scoped to your hospital based on your API credentials. You can optionally narrow results to a specific ward using the location parameter.

Available Endpoints

EndpointMethodAuth RequiredDescription
/fhir/health/GETYesRepresentative FHIR processing checks; ordinary JSON, outside the R4 resource API
/fhir/R4/metadata/GETNoServer CapabilityStatement (FHIR conformance discovery)
/fhir/R4/Location/GETYesList available wards for your hospital (see note below)
/fhir/R4/Device/GETYesList devices (optionally filtered by ward)
/fhir/R4/Observation/GETYesList observations (filterable by ward, code, date)
/fhir/R4/Observation/{id}/GETYesGet a single observation by ID (e.g., m-{uuid} or d-{uuid})

Base URL

EnvironmentURLNotes
Productionhttps://app.htgmedical.com/fhir/R4/Live patient data
Testhttps://staging.app.htgmedical.com/fhir/R4/For integration testing — requires a test tenant, see below
Local developmenthttp://localhost:8000/fhir/R4/Only if you are running Urogram yourself

Getting a Test Environment

Validate your integration against the Test environment before you go near production. It is not self-service: we have to create a hospital tenant for you there and issue credentials scoped to it, so ask us first — see Support. Tell us roughly how many devices you want to see and we will seed the tenant accordingly.

Test credentials are separate from production credentials and are not interchangeable. Everything else is identical, so cutting over means changing the base URL and the client secret and nothing else.

Quickstart

Two calls. Substitute your credentials; the token is valid for 10 hours.

TOKEN=$(curl -sS -X POST https://app.htgmedical.com/oauth/v1/token/ \
  -d grant_type=client_credentials -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET -d scope=m2m.read | jq -r .access_token)

curl -sS -H "Authorization: Bearer $TOKEN" -H "Accept: application/fhir+json" \
  "https://app.htgmedical.com/fhir/R4/Observation/?code=9187-6&date=gt2026-03-15T00:00:00Z"

The response is a searchset Bundle. Filter entry by search.mode == "match" to skip the included Organization. total: 0 with no matching entries is a valid answer — see Troubleshooting.

Authentication

The API uses OAuth2 Client Credentials flow for machine-to-machine authentication.

Credentials

You will receive from HTG:

  • client_id — Your application identifier
  • client_secret — Your secret key (keep this secure!)

Step 1: Request an Access Token

POST /oauth/v1/token/ HTTP/1.1
Host: app.htgmedical.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=m2m.read

cURL example:

curl -X POST https://app.htgmedical.com/oauth/v1/token/ \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=m2m.read"

Response:

{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "expires_in": 36000,
  "token_type": "Bearer",
  "scope": "m2m.read"
}

Step 2: Use the Access Token

Include the token in the Authorization header for all API requests:

GET /fhir/R4/Device/ HTTP/1.1
Host: app.htgmedical.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...
Accept: application/fhir+json

Token Reuse

Tokens expire after 10 hours (expires_in: 36000 seconds). Cache the token and reuse it for every request; request a new one shortly before expiry, or once after a 401 (requirement 2 under Integration Requirements):

import requests
from datetime import datetime, timedelta

class UrogramClient:
    def __init__(self, client_id, client_secret, base_url):
        self.client_id = client_id
        self.client_secret = client_secret
        self.base_url = base_url
        self.token = None
        self.token_expires_at = None

    def get_token(self):
        # Refresh if token is missing or expires within 5 minutes
        if not self.token or datetime.now() >= self.token_expires_at - timedelta(minutes=5):
            response = requests.post(
                f"{self.base_url}/oauth/v1/token/",
                data={
                    "grant_type": "client_credentials",
                    "client_id": self.client_id,
                    "client_secret": self.client_secret,
                    "scope": "m2m.read"
                }
            )
            data = response.json()
            self.token = data["access_token"]
            self.token_expires_at = datetime.now() + timedelta(seconds=data["expires_in"])
        return self.token

    def get_devices(self):
        return requests.get(
            f"{self.base_url}/fhir/R4/Device/",
            headers={"Authorization": f"Bearer {self.get_token()}"}
        ).json()

Integration Requirements

These requirements apply to every Urogram integration, whether it uses the FHIR R4 API or the HL7v2 API. MUST marks what a safe integration needs; SHOULD marks what we strongly recommend, so deviate only for a reason you have agreed with us. Where a requirement depends on API-specific parameters or fields, the named section of this document has them.

Credentials and tokens

  1. MUST keep the client_secret in a secret store, never in source control, logs or URLs. It grants read access to your hospital's data.
  2. MUST cache the access token and reuse it for every request until shortly before expires_in elapses (for example, renew 5 minutes early). On a 401, request one new token and retry once. MUST NOT request a new token for every poll. Each token request creates a server-side token; tokens last 10 hours. See Authentication for a caching example.
  3. MUST NOT retry a 403 in a loop. It means the token lacks the m2m.read scope or the application is not linked to a hospital, which only a configuration change fixes; contact us.

Errors, retries and staying alive

  1. MUST treat network errors, timeouts, 429 and every 5xx as temporary: retry with exponential backoff and jitter (for example 30 s, 1 min, 2 min, 4 min, then every 10–15 min). When a response carries Retry-After, wait at least that long.
  2. MUST NOT stop polling because of an error, however many in a row. Keep retrying until the service answers, then continue from your last successful cursor. During a past outage, an integration that gave up after a single 500 stopped receiving data entirely.
  3. MUST advance the sync cursor only after a response has been received in full and stored. A failed poll then costs nothing: the next one fetches the same data.
  4. MUST alert your own operations staff when no poll has succeeded for a set time (we suggest three polling intervals, for example 30 minutes). An empty response is a success. We cannot see that your side has stopped polling.
  5. SHOULD log and alert on any other 4xx instead of retrying it unchanged. It points to a bug in the request or a configuration problem.

Synchronisation

  1. MUST poll incrementally by server-update time, not by measurement time, every 5–15 minutes (see Data Synchronization). Devices that were offline upload old readings late, and a reading's validity can change after it was first sent; both appear only by server-update time.
  2. SHOULD take the next cursor from the time the request was sent, minus an overlap of about a minute, rather than from when processing finished. Records updated while your poll runs, or hidden by clock differences, are then fetched again rather than missed; requirement 12 makes the repeats harmless.
  3. SHOULD run the initial backfill by measurement time, at most 30 days back, in bounded chunks (day by day, or per ward or device), then switch to incremental polling. Responses are not paginated: one unbounded query returns everything at once.
  4. MUST store records keyed by their stable identifier (see Data Synchronization) and replace the stored copy when a record arrives again, rather than adding a second one. Status changes and corrections arrive as the same record with new content.
  5. SHOULD periodically (for example hourly) re-fetch the diuresis of a recent window by measurement time, such as the last 24 hours, and replace your stored diuresis for that window with the response. Invalidating a measurement replaces the diuresis records that used it with one recalculated record; incremental polling delivers that record but never reports a removal.

Validity

  1. MUST check every measurement's validity status, and exclude invalid measurements from clinical calculations or show them distinctly (see Measurement Validity). Anomaly detection, device hardware checks and clinical staff all flag readings, and flagged readings are still returned.
  2. MUST expect validity to change after a measurement was first delivered, and update the stored copy (requirement 12).

Diuresis

  1. SHOULD use our pre-calculated diuresis rather than recomputing it from measurements. Our calculation applies the A-NA (Anomaly–Non Anomaly) rule during anomaly runs, which cannot be reproduced from the measurements alone; your figures would differ during those periods.
  2. MUST NOT treat a diuresis value as a rate. It is the urine volume in mL accumulated over its interval; a rate needs the interval's length.

Devices and patients

  1. MUST own the mapping from device to patient or bed. Urogram data is device-centric and carries no patient identity of its own. Key the mapping on the device serial number printed on the device label, and update it when a device moves, or new data keeps arriving under the old patient or bed. See Device-to-Patient Pairing.

Health monitoring

  1. SHOULD monitor service health with the public readiness check GET https://www.app.htgmedical.com/web/health/ready/ (200 = ready, 503 = a backend dependency failed) together with the API-specific check under Health Monitoring. A healthy service does not prove your data is fresh, so requirement 7 still applies.

Rate limits

  1. MUST stay within 100 requests per minute per source IP on the data endpoints, and on 429 wait for Retry-After (60 seconds). Monitors and pollers sharing an IP share the limit. Endpoint-specific details are under Rate Limits. The 5–15 minute polling interval sits far below the limit.

Versioning and support

  1. SHOULD pin to the API's own version tag (fhir-v… or hl7v2-v…), not the application version, and read the Changelog before upgrading. We announce breaking changes before they ship.
  2. SHOULD validate the integration against the Test environment before production, and contact us through Support about credentials, outages or unexpected data.

Clinical use

  1. The data provided through this API is intended to support clinical decision-making but MUST NOT be the sole basis for clinical actions. Always correlate it with other clinical observations and exercise professional judgment.

Integration Workflow

Recommended Setup Steps

  1. Get credentials — Receive client_id and client_secret from HTG
  2. Obtain token — POST /oauth/v1/token/ with scope=m2m.read
  3. Discover wards — GET /fhir/R4/Location/ to see available ward IDs and names
  4. Check capabilities — GET /fhir/R4/metadata/ returns the CapabilityStatement listing the supported resources, search parameters and auth scheme (no auth required)
  5. Fetch devices — GET /fhir/R4/Device/ with optional location ward filter
  6. Pull observations — GET /fhir/R4/Observation/ with optional location, code, date, and _lastUpdated parameters
  7. Set up incremental sync — Poll GET /fhir/R4/Observation/?_lastUpdated=gt{last_sync} every 5–15 minutes

Ward Filtering (Optional)

By default, Device and Observation endpoints return data from all wards in your hospital. You can optionally filter by a specific ward using the location query parameter.

Step 1: Discover Available Wards

GET /fhir/R4/Location/ HTTP/1.1
Authorization: Bearer {token}

Response:

{
  "hospital_id": "12345678-abcd-ef01-2345-6789abcdef01",
  "hospital_name": "General Hospital",
  "wards": [
    { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "name": "ICU" },
    { "id": "b2c3d4e5-f6a7-8901-bcde-f12345678901", "name": "Cardiology" }
  ]
}

Response fields:

FieldDescription
hospital_idUUID of your hospital (matches your API credentials)
hospital_nameHuman-readable hospital name
wards[].idWard UUID — use this as the location query parameter
wards[].nameHuman-readable ward name

Only active wards are listed: a ward that has been deactivated disappears from this list. Device and Observation data already filtered by its UUID is unaffected.

/Location/ is a convenience endpoint, not a FHIR search. It returns the plain JSON object above rather than a Bundle of FHIR Location resources, which is why it is not declared in the CapabilityStatement. Use it to discover ward UUIDs for the location parameter; do not point a generic FHIR client at it.

Step 2: Filter by Ward

Pass the ward id as the location parameter:

GET /fhir/R4/Device/?location=a1b2c3d4-e5f6-7890-abcd-ef1234567890
GET /fhir/R4/Observation/?location=a1b2c3d4-e5f6-7890-abcd-ef1234567890&code=9187-6

The location parameter can be combined with all other query parameters (code, device, device:identifier, date, _lastUpdated).

Response Format

All Device and Observation endpoints return FHIR Bundle resources (type: "searchset"). The top-level structure is:

FieldDescription
resourceTypeAlways "Bundle"
typeAlways "searchset"
totalNumber of matching resources (excludes the Organization entry)
timestampISO 8601 timestamp when the bundle was generated
metaBundle metadata (version, security tags)
signatureSource signature from the Urogram system
entryArray of entry objects (see below)

Sizing. A measurement observation is roughly 1.2 kB of JSON and a diuresis observation roughly 1.4 kB, so a device on a 5-minute interval produces on the order of 0.7 MB/day with diuresis included. Twenty such devices over a 30-day backfill is therefore several hundred megabytes in one response — bound the initial sync with date and location, then poll incrementally with _lastUpdated. The measurement interval is configured per device; ask us for the value used in your deployment.

No pagination: search responses are returned in a single Bundle. Bundle.link is empty — there is no next link, and _count/_page are not supported. A broad query (for example a 30-day backfill across every ward) returns every matching resource in one response, so bound your initial sync with date/location filters and prefer narrow incremental _lastUpdated polls thereafter.

Bundle Entries

The entry array contains:

  1. Organization entry (always first) — an included reference to the HTG Medical organization that produced the data. Has search.mode: "include".
  2. Resource entries — the actual Device or Observation resources. Each has search.mode: "match".

Each entry object contains:

FieldDescription
fullUrlCanonical URL for the resource
resourceThe actual FHIR resource (Device, Observation, or Organization)
search.mode"match" for requested resources, "include" for the Organization

When parsing results, filter entries by search.mode == "match" or by resource.resourceType to skip the Organization entry.

Example:

{
  "resourceType": "Bundle",
  "type": "searchset",
  "total": 2,
  "timestamp": "2026-03-20T12:00:00+00:00",
  "entry": [
    { "resource": { "resourceType": "Organization", ... }, "search": { "mode": "include" } },
    { "resource": { "resourceType": "Device", ... }, "search": { "mode": "match" } },
    { "resource": { "resourceType": "Device", ... }, "search": { "mode": "match" } }
  ]
}

Query Parameters Reference

ParameterEndpointsRequiredDescription
locationDevice, Observation, Observation/{id}NoFilter by ward UUID (from GET /Location/). Omit to return all wards.
codeObservationNoLOINC code: 28009-9 (measurements) or 9187-6 (diuresis). Omit to return both.
deviceObservationNoFilter by FHIR device reference. Accepts Device/{device_id} or bare {device_id}.
device:identifierObservationNoFilter by device serial number. Accepts {serial_number} or .../Device/serial-number|{serial_number}.
dateObservationNoFilter by clinical time. Prefix with gt or lt. Can be specified twice for a range.
_lastUpdatedObservationNoFilter by server receipt time. Use for incremental sync. Prefix with gt or lt. Can be specified twice for a range.

Timestamp format: date and _lastUpdated accept only UTC instants — YYYY-MM-DDThh:mm:ss[.ffffff] followed by Z or +00:00, always with a gt or lt prefix. A date without a time, or an offset such as +02:00, is rejected with HTTP 400. Both comparisons are strict (gt is after, lt is before).

date and diuresis: for raw measurements (28009-9) date compares against effectiveDateTime. For diuresis (9187-6) there is no single instant, so gt compares against effectivePeriod.end and lt against effectivePeriod.start — a diuresis record matches a range when its period overlaps that range.

Fetching a Single Observation

To retrieve a single observation, use its prefixed ID in the URL path:

GET /fhir/R4/Observation/m-550e8400-e29b-41d4-a716-446655440000/
GET /fhir/R4/Observation/d-e5f6g7h8-1234-5678-abcd-ef1234567890/

The ID must include the type prefix (m- for measurements, d- for diuresis). The response is a Bundle containing the single matching observation (or an empty bundle if not found).

Device Fields

Each Device resource contains:

FieldDescription
idInternal device ID (opaque string — treat it as such, do not assume a UUID)
identifierArray of identifiers (see below)
statusDevice status: "active", "inactive", "entered-in-error", or "unknown" (the default for a device that has not yet reported)
manufacturerFixed: "htgmedical"

Device Identifiers

Each device always has a device-id identifier, and may also have a serial-number:

Identifier SystemAlways PresentDescription
.../Device/device-idYesInternal UUID — unique system identifier
.../Device/serial-numberNoPhysical label ID — the ID printed on the front of the device (e.g., "D-1009"). Only present if assigned.

The serial-number corresponds to the label physically printed on each device, making it easy to match API data with devices at the bedside. Check the identifier array length before accessing it.

Observation Types

The API exposes two types of Observations:

  1. Measurements (LOINC 28009-9 — Volume of Urine): Raw urine volume readings from IoT devices. These are the individual data points collected by the hardware.
  2. Diuresis (LOINC 9187-6 — Urine output): Server-calculated urine output over a time period, derived from consecutive valid measurements.

Use the code parameter with LOINC codes to filter by observation type:

Observation TypeLOINC CodeID PrefixDescription
Volume of Urine (raw measurement)28009-9m-{uuid}Individual weight measurements converted to volume
Urine Output (calculated diuresis)9187-6d-{uuid}Calculated diuresis over a time period

Measurement Fields (LOINC 28009-9)

FieldDescription
idUnique identifier, prefixed with m- (e.g., m-550e8400-...)
meta.lastUpdatedServer timestamp when the record was received or last modified. Use this for incremental sync.
identifierArray with one entry: system = .../Observation/observation-id, value = same as id
status"final" for valid measurements, "entered-in-error" for invalid measurements (see Measurement Validity below). Other values from the FHIR Observation status value set are possible — treat anything other than "final" as not clinically usable.
noteOptional FHIR Annotation array for entered-in-error measurements. Uses the dashboard's English diagnostic or hiding label, such as Inactive (No clip detected), Invalid (Unstable weight), Flagged as anomaly, or Manually hidden.
categoryFixed: "procedure"
codeLOINC 28009-9 — Volume of Urine
effectiveDateTimeISO 8601 timestamp of when the measurement was physically taken by the device
valueQuantityUrine volume in milliliters (mL, UCUM). Calculated from (raw_g - tare_g) / 1.015. Rounded after conversion to the nearest whole mL, with ties away from zero.
dataAbsentReasonIncluded instead of valueQuantity when no usable numeric volume exists, including legacy heartbeat markers.
deviceReference to the Device resource that produced the measurement

Both observation types expose whole numeric mL (for example, 98.5 becomes 99), rounded to nearest with ties away from zero. Measurement grams are subtracted before conversion and rounding; diuresis is rounded from its stored mL value. The FHIR Quantity remains a decimal with unchanged coding and units. Only response precision changes; stored values and internal calculations are unchanged. Sums of individually rounded observations can differ from a total rounded after summation. Clients must not apply the density conversion again. Historical records predating volume conversion require a deployment-specific audit; this release does not rewrite or silently convert them.

Diuresis Fields (LOINC 9187-6)

Diuresis records represent the change in urine volume between two consecutive valid measurements. They are calculated server-side and only include validated data.

FieldDescription
idUnique identifier, prefixed with d- (e.g., d-e5f6g7h8-...)
meta.lastUpdatedServer timestamp when the record was created or last modified
identifierArray with one entry: system = .../Observation/observation-id, value = same as id
statusObservation status (e.g., "final")
categoryFixed: "vital-signs"
codeDual-coded: LOINC 9187-6 (Urine output) and SNOMED CT 444990003 (Measurement of urine output)
effectivePeriodTime window with start and end timestamps representing the period between the two consecutive measurements
valueQuantityUrine output in milliliters (mL, UCUM) over the effective period. Derived from the device's raw weight delta, converted from grams to mL — consistent with the measurement observations.
deviceReference to the Device resource

Measurement Validity

The API returns all measurements, including those flagged as invalid (requirements 14–15). Invalid measurements have status: "entered-in-error" per the FHIR Observation status value set. Valid measurements have status: "final".

Diagnostic heartbeats remain available as measurement observations with status: "entered-in-error". Their note describes the measurement state at the time of the heartbeat, such as Inactive (Negative net weight, No clip detected). Ordinary rejected readings use Invalid and their available reasons. Notes follow the dashboard's clinical presentation: reasons are deduplicated and ordered, unknown codes appear as Unspecified reason, and admin-only forced inactivity details are omitted. The note is explanatory free text; use status to determine clinical validity. Failed measurement attempts are not observations. Legacy rejected heartbeat markers whose stored fields contain protocol flags instead of weights omit valueQuantity and include dataAbsentReason: error. Measurements without an exportable numeric volume always use entered-in-error, even when a historical row was not hidden.

The pre-calculated diuresis observations (LOINC 9187-6) are calculated from valid measurements only, so if you display those, as requirement 16 recommends, no further filtering is needed. When a measurement's status changes, its meta.lastUpdated changes too: _lastUpdated sync delivers it again under the same id, and you replace your stored copy (see Data Synchronization).

Data Synchronization

Understanding Timestamps

Each Observation has two important timestamps:

FieldDescriptionUse Case
effectiveDateTimeWhen the measurement was taken by the deviceFiltering by clinical time, reporting
meta.lastUpdatedWhen the data was received or last modified by the serverIncremental synchronization

Server-update time (requirement 9) is meta.lastUpdated, queried with _lastUpdated. Use date only for the initial backfill:

# WRONG - misses late uploads from offline devices and status changes
url = f"/fhir/R4/Observation/?date=gt{last_sync_time}"

# CORRECT - catches every record the server added or changed since the cursor
url = f"/fhir/R4/Observation/?_lastUpdated=gt{last_sync_time}"

Recommended Sync Strategy

Sync TypeFilterPurpose
Initial syncdate=gt{30_days_ago}Backfill historical data by clinical time
Incremental sync_lastUpdated=gt{last_sync}Catch all new arrivals, including from offline devices

Initial Sync

  1. Fetch all devices: GET /fhir/R4/Device/
  2. Fetch recent observations by clinical date in bounded chunks (requirement 11), for example one day at a time: GET /fhir/R4/Observation/?date=gt{day_start}&date=lt{day_end}, optionally per location

Incremental Sync

Poll every 5–15 minutes:

GET /fhir/R4/Observation/?_lastUpdated=gt{last_sync_timestamp}

Identifying a Stored Observation

Key stored observations on id (m-{uuid} for a measurement, d-{uuid} for a diuresis), which is also identifier[0].value (requirement 12). A measurement keeps its id when its status changes.

A recalculated diuresis gets a new id, and the record it replaces stops being returned (requirement 13). To reconcile, query code=9187-6 with a date range and replace the stored diuresis whose effectivePeriod overlaps that range: date matches a diuresis by overlap (see Query Parameters Reference).

Example Sync Script (Python)

import requests
from datetime import datetime, timedelta, timezone

class UrogramSync:
    def __init__(self, client):
        self.client = client
        self.last_sync = None

    def sync_observations(self, store):
        url = f"{self.client.base_url}/fhir/R4/Observation/"
        # Cursor from when the request was sent, with a 1-minute overlap (requirement 10)
        poll_started = datetime.now(timezone.utc) - timedelta(minutes=1)

        params = {}
        if self.last_sync:
            # Use _lastUpdated for incremental sync to catch late-arriving data
            params["_lastUpdated"] = "gt" + self.last_sync.strftime("%Y-%m-%dT%H:%M:%SZ")

        response = requests.get(
            url,
            params=params,
            headers={"Authorization": f"Bearer {self.client.get_token()}"},
            timeout=60,
        )
        # On 429/5xx/timeout this raises: back off, retry with the same cursor,
        # and keep polling (requirements 4-6)
        response.raise_for_status()

        # Store (upsert by id) first; advance the cursor only once that succeeded.
        # Skip the included Organization entry: only "match" entries are Observations.
        entries = response.json().get("entry", [])
        store([e for e in entries if e.get("search", {}).get("mode") == "match"])
        self.last_sync = poll_started

Implementation Best Practices

Consuming Diuresis Data

Requirement 16: display the pre-calculated diuresis observations (LOINC 9187-6) rather than recomputing them. Each is a volume over its effectivePeriod (requirement 17).

GET /fhir/R4/Observation/?code=9187-6&_lastUpdated=gt{last_sync}

Calculating Diuresis from Raw Measurements

If you must calculate diuresis independently (e.g., for custom aggregation windows), you can derive it from the raw measurement observations (LOINC 28009-9). Your results will differ from ours during anomaly runs, where our calculation applies the A-NA rule (requirement 16).

Basic diuresis calculation rules:

  1. Filter by status: Only use measurements with status: "final". Skip all "entered-in-error" measurements.
  2. Sort by time: Order valid measurements by effectiveDateTime ascending.
  3. Calculate deltas: For each consecutive pair of valid measurements from the same device, calculate the volume difference.
  4. Time window: Only calculate deltas between measurements that are within 62 minutes of each other. Gaps larger than this indicate the container was likely emptied or replaced.
  5. Positive deltas only: Only include positive volume changes (increasing urine volume). Negative changes indicate container emptying.
  6. Group by device: Calculate diuresis per-device, not across devices.

Device-to-Patient Pairing

The FHIR API does not include patient references in observations (requirement 18). Your system should:

  1. Fetch devices via GET /fhir/R4/Device/
  2. Use the serial-number identifier (the physical label on the device, e.g., "D-1009") to match devices to patients in your EHR
  3. All observations reference the device that produced them via the device field

Error Handling

Authentication Errors

HTTP StatusMeaningAction
401 UnauthorizedToken missing, invalid, or expiredRequest one new access token and retry once (requirement 2)
403 ForbiddenToken valid but no hospital linked, or missing m2m.read scopeDo not retry in a loop; ensure m2m.read scope is requested, or contact HTG to link your application to a hospital (requirement 3)

Validation Errors

Validation errors return a FHIR OperationOutcome with HTTP 400 or 404:

HTTP StatusCause
400 Bad RequestInvalid location parameter (not a valid UUID)
400 Bad RequestInvalid date or _lastUpdated format (must use gt/lt prefix and YYYY-MM-DDThh:mm:ss[.ffffff]Z format)
400 Bad RequestMore than one code, device, or device:identifier parameter provided
400 Bad RequestEmpty device or device:identifier value
400 Bad Requestdevice reference that is not Device/{device_id} or a bare {device_id}
400 Bad Requestdevice:identifier qualified with a system other than .../Device/serial-number
400 Bad RequestInvalid Observation ID format (must start with d- or m- prefix followed by a valid UUID)
404 Not Foundlocation UUID does not match any ward in your hospital

A filter that is well-formed but matches nothing — an unknown device, or a serial number that does not exist — is not an error: it returns 200 with an empty Bundle (total: 0). The same applies to GET /Observation/{id}/ for an ID that is valid in form but not present in your hospital.

All validation errors are returned as FHIR OperationOutcome resources:

{
  "resourceType": "OperationOutcome",
  "issue": [
    {
      "severity": "error",
      "code": "invalid",
      "diagnostics": "Invalid location parameter: not-a-uuid. Must be a valid UUID."
    }
  ]
}

Server Errors and Throttling

HTTP StatusMeaningAction
429 Too Many RequestsRate limit reachedWait for Retry-After, see Rate Limits
5xx, timeout, network errorService or network temporarily unavailable; the body may not be an OperationOutcomeRetry with backoff and keep polling (requirements 4–5); if it persists, contact HTG

Rate Limits

The limit in requirement 20 applies to /Device/, /Observation/, /Location/ and /fhir/health/. The unauthenticated /metadata/ endpoint is not rate limited.

A throttled request returns 429 Too Many Requests with the JSON body {"detail": "Request was throttled. Please try again later."} and the headers Retry-After: 60, X-RateLimit-Limit and X-RateLimit-Remaining: 0.

Health Monitoring

Requirement 19 pairs general service health with consumer-specific FHIR processing. Neither is a guarantee of measurement freshness (requirement 7).

General service health

The readiness check GET https://www.app.htgmedical.com/web/health/ready/ covers the database connection, Redis, a Celery worker and the beat heartbeat. Public /health/ only checks nginx and can remain green when Django is unavailable.

Authenticated FHIR health

GET https://www.app.htgmedical.com/fhir/health/ uses the same bearer token, m2m.read scope and hospital link as your data requests. Staging uses https://www.staging.app.htgmedical.com/fhir/health/ with staging credentials. The path is outside /fhir/R4/ and accepts application/json.

curl --silent --show-error --fail-with-body --max-time 10 \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/json' \
  'https://www.app.htgmedical.com/fhir/health/'

Example response:

{
  "status": "ok",
  "checks": {
    "database": {"status": "ok"},
    "device": {"status": "ok"},
    "measurement": {"status": "ok"},
    "diuresis": {"status": "skipped", "detail": "no records"}
  }
}

The endpoint performs real database queries and selects at most one record of each type from your hospital, ordered by primary key. It exercises the shared Device/Observation converters, Bundle serializer and FHIR JSON renderer. The records, their identifiers, counts and clinical values never appear in the response. No records are created or modified.

ok means that check completed successfully; down means it failed; skipped means it was not exercised. An empty hospital returns overall ok with skipped resource checks: that verifies database/access availability, not clinical serialization. Show skipped checks as incomplete coverage in the dashboard.

OutcomeDashboard interpretation
200, overall okCompleted checks passed; inspect skipped checks
503, overall downDatabase, representative processing or rate-limit infrastructure failure
401Token missing, invalid or expired; refresh credentials/token
403Missing scope or application hospital link; check access configuration
406Use Accept: application/json
429Polling limit reached; back off
Timeout, network failure, other 5xxIntegration unavailable or unreachable from your network

Poll every 30–60 seconds, use a 10-second timeout and alert after repeated failures. The limit is 100 GET requests/minute per source IP, shared by monitors using that IP for this endpoint. Authentication is checked each time; results are not cached. Responses include Cache-Control: no-store. Limits bound rows returned, not database work or request duration.

Celery and Redis health are not probed here, although the request rate limiter uses Redis. A Redis limiter failure returns 503, overall down, with all checks skipped and detail: "checks unavailable". A database outage during authentication or the initial database probe returns database down and resource checks skipped with detail: "database unavailable". A database error during a resource query marks database and that resource down and skips subsequent checks. Processing errors report detail: "check failed" without exception text.

Stronger external integration check

The dedicated check does not traverse the public Device/Observation routes or every search filter, and uses representative records rather than your current query. For stronger coverage, run a small real Observation request from the consumer's network, using its actual OAuth credentials.

  1. Obtain a token through POST /oauth/v1/token/ with client credentials and scope=m2m.read, following the authentication instructions. Store credentials in your monitor's secret store. Reuse tokens until their expiry approaches and test token refresh as part of normal monitoring.
  2. Query an existing observation belonging to your hospital. This API returns a Bundle, including for an observation ID; an absent ID returns an empty Bundle.
  3. Check the response is a searchset Bundle and contains the expected matching Observation. Do not count an HTTP 200 or an included Organization as proof of successful observation retrieval. Keep clinical response bodies out of logs.

Example (Bash with jq; OBSERVATION_ID is a retained, known m-UUID or d-UUID):

set -o pipefail
curl --silent --show-error --fail --max-time 10 \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Accept: application/fhir+json' \
  "https://www.app.htgmedical.com/fhir/R4/Observation/$OBSERVATION_ID/" \
  | jq -e --arg id "$OBSERVATION_ID" \
      '.resourceType == "Bundle" and .type == "searchset" and
       any(.entry[]?; .search.mode == "match" and
           .resource.resourceType == "Observation" and .resource.id == $id)' \
  > /dev/null

Exit zero means this particular read worked. Choose a stable record retained by your integration; legitimate record removal makes this monitor fail and requires updating the reference. It does not check incoming data freshness.

GET /fhir/R4/metadata/ returns a predefined CapabilityStatement describing the API version, operations, search parameters and authentication. It proves metadata routing/reachability, but does not query the database or validate your token.

Freshness needs a separate check against expected device activity, ideally using server receipt/modification time (_lastUpdated) as well as clinical time. Agree thresholds per device/use case: an idle device is not automatically an outage, and a fresh received reading can have an older clinical timestamp.

Provider-operated external monitoring

The application provides two checks with separate execution paths:

  • FHIR integration: an optional GitLab hosted-runner job runs every five minutes once provisioned and scheduled. It obtains a fresh OAuth client-credentials token and reads a fixed synthetic Observation, verifying identity, timestamp, final status, device, LOINC 28009-9, UCUM mL and volume. It reports to internal Sentry Cron Monitoring.
  • Production TTI webhook health: a Sentry Uptime monitor calls TTI directly every minute for application hardwario-testing, webhook urogram-app. It alerts after one failed check and resolves after two successful checks. Paused, uplink-disabled, unhealthy, missing or unknown health fails the check, as do API authentication/network failures. Staging webhook urogram-staging is not monitored by this check.

The FHIR fixture uses a dedicated synthetic hospital, device and OAuth application. Operators must provision it, configure its protected GitLab schedule and Sentry routing, then prove error, missed-run and recovery notifications. A code deployment alone does not activate the FHIR monitor. The TTI monitor uses a dedicated read-only TTI application API key stored in Sentry; it needs no GitLab runner or TTI CI variables. Neither check submits measurements or changes webhook settings. No recurring synthetic-data pruning is needed.

A healthy TTI snapshot and a readable fixed Observation do not prove fresh end-to-end ingestion, payload decoding, sensor/radio transmission, ML inference, derived diuresis or WebSocket delivery. A through-TTI canary is a separate follow-up. Consumers should monitor their own credentials, hospital link, expected measurement freshness and receiving-system processing. Internal Sentry monitoring does not provide a public status dashboard.

Troubleshooting

I get an empty Bundle (total: 0)

That is a valid answer, not an error, and it is the normal steady state when polling with _lastUpdated. If you expected data:

#CauseHow to tellFix
1Nothing new since your cursorNormal during incremental syncNone. gt is strictly greater-than, so a record exactly on the cursor is excluded
2code filter excludes what you wantRetry without code28009-9 returns only measurements, 9187-6 only diuresis. Omit it to get both
3The date window misses the readingsWiden the rangeFor diuresis, date compares against effectivePeriod, not effectiveDateTime
4device or device:identifier matches nothingRetry without itBoth match exactly. A well-formed value that matches no device returns an empty Bundle, not a 404
5The device is not in your hospitalRetry without device filtersAll queries are scoped to the hospital linked to your OAuth2 application

A malformed date/_lastUpdated gives a 400 with an OperationOutcome, not an empty Bundle — so if you get total: 0 your parameters were accepted.

Not a cause: invalid measurements are still returned, with status: "entered-in-error". They are never silently dropped.

I get a 403 rather than data

Your token is valid but the OAuth2 application behind it is not linked to a hospital, or the m2m.read scope was not requested. Contact us to link it — see Support.

The response is much larger than expected

There is no pagination — see the note under Response Format. Bound broad queries with date and location, then use _lastUpdated for steady-state polling.

Versioning and Compatibility

This API is versioned independently of the Urogram application. Releases are tagged fhir-v<major>.<minor>.<patch>, and the version in this document (info.version) tracks that stream.

ChangeVersion bumpWhat you should expect
New optional search parameter, new element populatedMinorBackward compatible
Changed code, unit or resource shapeFlagged as breakingWe will contact you before such a change ships — see the note below
Documentation-only correctionPatchNo behaviour change

Pin to the API tag, not the application version. Application releases (v*) do not imply an API change. Breaking changes are announced before deployment; if one would require work on your side, we will contact you rather than expect you to notice it here.

Support

For technical support or to request API credentials:

  • Email: max@htgmedical.com
  • Phone: +420 774 123 097

Changelog

VersionDateChanges
1.7.12026-10GET /Location/ lists only active wards; deactivated wards are left out. The response shape is unchanged, and filtering Device and Observation by an existing ward UUID still works. Documentation: new shared Integration Requirements section (token reuse, retry with backoff and never stop polling, stale-sync alerting, cursor overlap, upsert keys, diuresis reconciliation, clinical use), kept identical in the FHIR and HL7v2 specs. Added server-error guidance.
1.72026-10Measurement and diuresis valueQuantity.value now always round to whole mL, nearest with ties away from zero. entered-in-error measurements may carry an explanatory Observation.note. dataAbsentReason replaces valueQuantity when no numeric volume exists. Hardware-rejected and non-numeric readings are always entered-in-error. FHIR numeric datatypes, codes, units and stored values are unchanged.
1.62026-07valueQuantity.unit on diuresis observations is the UCUM display for millilitres (valueQuantity.code is mL). Diuresis valueQuantity.value is a volume in millilitres, consistent with the measurement observations. GET /metadata/ returns a CapabilityStatement describing the supported resources, search parameters and OAuth2 security scheme. Documented that responses are unpaginated, that date/_lastUpdated accept UTC instants only, and that /Location/ is a convenience endpoint rather than a FHIR Location search. Device.status may also be unknown, and Device.id is an opaque string rather than a UUID.
1.52026-05Consolidated device serial identity: replaced readable-id with serial-number, added Observation filtering by device and device:identifier, and preserved strict _lastUpdated=gt cursor semantics.
1.42026-03Added ward filtering (location parameter) for Device and Observation endpoints, added GET /Location/ endpoint for ward discovery
1.32026-03Added measurement validity (entered-in-error status), field descriptions, implementation best practices
1.22026-01Added _lastUpdated filter and meta.lastUpdated for handling late-arriving data from offline devices
1.12026-01Added serial identifier to Device resources (matches physical device label)
1.02026-01Initial FHIR R4 API release
Overview
HTG Medical
Servers
Production
https://app.htgmedical.com/fhir/R4
Development
http://localhost:8000/fhir/R4