Urogram FHIR R4 API (1.7.1)
Integration guide and API reference for hospital EHR/EMR systems connecting to the Urogram FHIR R4 API.
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.
| Endpoint | Method | Auth Required | Description |
|---|---|---|---|
/fhir/health/ | GET | Yes | Representative FHIR processing checks; ordinary JSON, outside the R4 resource API |
/fhir/R4/metadata/ | GET | No | Server CapabilityStatement (FHIR conformance discovery) |
/fhir/R4/Location/ | GET | Yes | List available wards for your hospital (see note below) |
/fhir/R4/Device/ | GET | Yes | List devices (optionally filtered by ward) |
/fhir/R4/Observation/ | GET | Yes | List observations (filterable by ward, code, date) |
/fhir/R4/Observation/{id}/ | GET | Yes | Get a single observation by ID (e.g., m-{uuid} or d-{uuid}) |
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.
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.
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"
}
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()
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.
- MUST keep the
client_secretin a secret store, never in source control, logs or URLs. It grants read access to your hospital's data. - MUST cache the access token and reuse it for every request until shortly before
expires_inelapses (for example, renew 5 minutes early). On a401, 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. - MUST NOT retry a
403in a loop. It means the token lacks them2m.readscope or the application is not linked to a hospital, which only a configuration change fixes; contact us.
- MUST treat network errors, timeouts,
429and every5xxas 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 carriesRetry-After, wait at least that long. - 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
500stopped receiving data entirely. - 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.
- 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.
- SHOULD log and alert on any other
4xxinstead of retrying it unchanged. It points to a bug in the request or a configuration problem.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- MUST expect validity to change after a measurement was first delivered, and update the stored copy (requirement 12).
- 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.
- 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.
- 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.
- 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.
- SHOULD pin to the API's own version tag (
fhir-v…orhl7v2-v…), not the application version, and read the Changelog before upgrading. We announce breaking changes before they ship. - SHOULD validate the integration against the Test environment before production, and contact us through Support about credentials, outages or unexpected data.
- Get credentials — Receive
client_idandclient_secretfrom HTG - Obtain token —
POST /oauth/v1/token/withscope=m2m.read - Discover wards —
GET /fhir/R4/Location/to see available ward IDs and names - Check capabilities —
GET /fhir/R4/metadata/returns the CapabilityStatement listing the supported resources, search parameters and auth scheme (no auth required) - Fetch devices —
GET /fhir/R4/Device/with optionallocationward filter - Pull observations —
GET /fhir/R4/Observation/with optionallocation,code,date, and_lastUpdatedparameters - Set up incremental sync — Poll
GET /fhir/R4/Observation/?_lastUpdated=gt{last_sync}every 5–15 minutes
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.
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:
| Field | Description |
|---|---|
hospital_id | UUID of your hospital (matches your API credentials) |
hospital_name | Human-readable hospital name |
wards[].id | Ward UUID — use this as the location query parameter |
wards[].name | Human-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 FHIRLocationresources, which is why it is not declared in the CapabilityStatement. Use it to discover ward UUIDs for thelocationparameter; do not point a generic FHIR client at it.
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).
All Device and Observation endpoints return FHIR Bundle resources (type: "searchset"). The top-level structure is:
| Field | Description |
|---|---|
resourceType | Always "Bundle" |
type | Always "searchset" |
total | Number of matching resources (excludes the Organization entry) |
timestamp | ISO 8601 timestamp when the bundle was generated |
meta | Bundle metadata (version, security tags) |
signature | Source signature from the Urogram system |
entry | Array 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
dateandlocation, 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.linkis empty — there is nonextlink, and_count/_pageare 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 withdate/locationfilters and prefer narrow incremental_lastUpdatedpolls thereafter.
The entry array contains:
- Organization entry (always first) — an included reference to the HTG Medical organization that produced the data. Has
search.mode: "include". - Resource entries — the actual Device or Observation resources. Each has
search.mode: "match".
Each entry object contains:
| Field | Description |
|---|---|
fullUrl | Canonical URL for the resource |
resource | The 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" } }
]
}
| Parameter | Endpoints | Required | Description |
|---|---|---|---|
location | Device, Observation, Observation/{id} | No | Filter by ward UUID (from GET /Location/). Omit to return all wards. |
code | Observation | No | LOINC code: 28009-9 (measurements) or 9187-6 (diuresis). Omit to return both. |
device | Observation | No | Filter by FHIR device reference. Accepts Device/{device_id} or bare {device_id}. |
device:identifier | Observation | No | Filter by device serial number. Accepts {serial_number} or .../Device/serial-number|{serial_number}. |
date | Observation | No | Filter by clinical time. Prefix with gt or lt. Can be specified twice for a range. |
_lastUpdated | Observation | No | Filter by server receipt time. Use for incremental sync. Prefix with gt or lt. Can be specified twice for a range. |
Timestamp format:
dateand_lastUpdatedaccept only UTC instants —YYYY-MM-DDThh:mm:ss[.ffffff]followed byZor+00:00, always with agtorltprefix. A date without a time, or an offset such as+02:00, is rejected with HTTP 400. Both comparisons are strict (gtis after,ltis before).
dateand diuresis: for raw measurements (28009-9)datecompares againsteffectiveDateTime. For diuresis (9187-6) there is no single instant, sogtcompares againsteffectivePeriod.endandltagainsteffectivePeriod.start— a diuresis record matches a range when its period overlaps that range.
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).
Each Device resource contains:
| Field | Description |
|---|---|
id | Internal device ID (opaque string — treat it as such, do not assume a UUID) |
identifier | Array of identifiers (see below) |
status | Device status: "active", "inactive", "entered-in-error", or "unknown" (the default for a device that has not yet reported) |
manufacturer | Fixed: "htgmedical" |
Each device always has a device-id identifier, and may also have a serial-number:
| Identifier System | Always Present | Description |
|---|---|---|
.../Device/device-id | Yes | Internal UUID — unique system identifier |
.../Device/serial-number | No | Physical 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.
The API exposes two types of Observations:
- Measurements (LOINC
28009-9— Volume of Urine): Raw urine volume readings from IoT devices. These are the individual data points collected by the hardware. - 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 Type | LOINC Code | ID Prefix | Description |
|---|---|---|---|
| Volume of Urine (raw measurement) | 28009-9 | m-{uuid} | Individual weight measurements converted to volume |
| Urine Output (calculated diuresis) | 9187-6 | d-{uuid} | Calculated diuresis over a time period |
| Field | Description |
|---|---|
id | Unique identifier, prefixed with m- (e.g., m-550e8400-...) |
meta.lastUpdated | Server timestamp when the record was received or last modified. Use this for incremental sync. |
identifier | Array 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. |
note | Optional 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. |
category | Fixed: "procedure" |
code | LOINC 28009-9 — Volume of Urine |
effectiveDateTime | ISO 8601 timestamp of when the measurement was physically taken by the device |
valueQuantity | Urine 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. |
dataAbsentReason | Included instead of valueQuantity when no usable numeric volume exists, including legacy heartbeat markers. |
device | Reference 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 records represent the change in urine volume between two consecutive valid measurements. They are calculated server-side and only include validated data.
| Field | Description |
|---|---|
id | Unique identifier, prefixed with d- (e.g., d-e5f6g7h8-...) |
meta.lastUpdated | Server timestamp when the record was created or last modified |
identifier | Array with one entry: system = .../Observation/observation-id, value = same as id |
status | Observation status (e.g., "final") |
category | Fixed: "vital-signs" |
code | Dual-coded: LOINC 9187-6 (Urine output) and SNOMED CT 444990003 (Measurement of urine output) |
effectivePeriod | Time window with start and end timestamps representing the period between the two consecutive measurements |
valueQuantity | Urine 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. |
device | Reference to the Device resource |
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).
Each Observation has two important timestamps:
| Field | Description | Use Case |
|---|---|---|
effectiveDateTime | When the measurement was taken by the device | Filtering by clinical time, reporting |
meta.lastUpdated | When the data was received or last modified by the server | Incremental 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}"
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).
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
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:
- Filter by status: Only use measurements with
status: "final". Skip all"entered-in-error"measurements. - Sort by time: Order valid measurements by
effectiveDateTimeascending. - Calculate deltas: For each consecutive pair of valid measurements from the same device, calculate the volume difference.
- 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.
- Positive deltas only: Only include positive volume changes (increasing urine volume). Negative changes indicate container emptying.
- Group by device: Calculate diuresis per-device, not across devices.
The FHIR API does not include patient references in observations (requirement 18). Your system should:
- Fetch devices via
GET /fhir/R4/Device/ - Use the
serial-numberidentifier (the physical label on the device, e.g., "D-1009") to match devices to patients in your EHR - All observations reference the device that produced them via the
devicefield
| HTTP Status | Meaning | Action |
|---|---|---|
| 401 Unauthorized | Token missing, invalid, or expired | Request one new access token and retry once (requirement 2) |
| 403 Forbidden | Token valid but no hospital linked, or missing m2m.read scope | Do 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 return a FHIR OperationOutcome with HTTP 400 or 404:
| HTTP Status | Cause |
|---|---|
| 400 Bad Request | Invalid location parameter (not a valid UUID) |
| 400 Bad Request | Invalid date or _lastUpdated format (must use gt/lt prefix and YYYY-MM-DDThh:mm:ss[.ffffff]Z format) |
| 400 Bad Request | More than one code, device, or device:identifier parameter provided |
| 400 Bad Request | Empty device or device:identifier value |
| 400 Bad Request | device reference that is not Device/{device_id} or a bare {device_id} |
| 400 Bad Request | device:identifier qualified with a system other than .../Device/serial-number |
| 400 Bad Request | Invalid Observation ID format (must start with d- or m- prefix followed by a valid UUID) |
| 404 Not Found | location 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."
}
]
}
| HTTP Status | Meaning | Action |
|---|---|---|
| 429 Too Many Requests | Rate limit reached | Wait for Retry-After, see Rate Limits |
| 5xx, timeout, network error | Service or network temporarily unavailable; the body may not be an OperationOutcome | Retry with backoff and keep polling (requirements 4–5); if it persists, contact HTG |
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.
Requirement 19 pairs general service health with consumer-specific FHIR processing. Neither is a guarantee of measurement freshness (requirement 7).
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.
| Outcome | Dashboard interpretation |
|---|---|
200, overall ok | Completed checks passed; inspect skipped checks |
503, overall down | Database, representative processing or rate-limit infrastructure failure |
| 401 | Token missing, invalid or expired; refresh credentials/token |
| 403 | Missing scope or application hospital link; check access configuration |
| 406 | Use Accept: application/json |
| 429 | Polling limit reached; back off |
| Timeout, network failure, other 5xx | Integration 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.
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.
- Obtain a token through
POST /oauth/v1/token/with client credentials andscope=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. - 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.
- 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.
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, webhookurogram-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 webhookurogram-stagingis 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.
That is a valid answer, not an error, and it is the normal steady state when polling with _lastUpdated. If you expected data:
| # | Cause | How to tell | Fix |
|---|---|---|---|
| 1 | Nothing new since your cursor | Normal during incremental sync | None. gt is strictly greater-than, so a record exactly on the cursor is excluded |
| 2 | code filter excludes what you want | Retry without code | 28009-9 returns only measurements, 9187-6 only diuresis. Omit it to get both |
| 3 | The date window misses the readings | Widen the range | For diuresis, date compares against effectivePeriod, not effectiveDateTime |
| 4 | device or device:identifier matches nothing | Retry without it | Both match exactly. A well-formed value that matches no device returns an empty Bundle, not a 404 |
| 5 | The device is not in your hospital | Retry without device filters | All 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.
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.
There is no pagination — see the note under Response Format. Bound broad queries with date and location, then use _lastUpdated for steady-state polling.
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.
| Change | Version bump | What you should expect |
|---|---|---|
| New optional search parameter, new element populated | Minor | Backward compatible |
| Changed code, unit or resource shape | Flagged as breaking | We will contact you before such a change ships — see the note below |
| Documentation-only correction | Patch | No 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.
For technical support or to request API credentials:
- Email: max@htgmedical.com
- Phone: +420 774 123 097
| Version | Date | Changes |
|---|---|---|
| 1.7.1 | 2026-10 | GET /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.7 | 2026-10 | Measurement 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.6 | 2026-07 | valueQuantity.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.5 | 2026-05 | Consolidated 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.4 | 2026-03 | Added ward filtering (location parameter) for Device and Observation endpoints, added GET /Location/ endpoint for ward discovery |
| 1.3 | 2026-03 | Added measurement validity (entered-in-error status), field descriptions, implementation best practices |
| 1.2 | 2026-01 | Added _lastUpdated filter and meta.lastUpdated for handling late-arriving data from offline devices |
| 1.1 | 2026-01 | Added serial identifier to Device resources (matches physical device label) |
| 1.0 | 2026-01 | Initial FHIR R4 API release |