AnyBio

Export

FHIR R4 bundles validated against US Core in CI, HL7 v2 ORU over MLLP, the Epic adapter proven against Epic's developer sandbox, and a generic FHIR R4 server target.

An episode can be sent onward as a FHIR R4 transaction bundle or an HL7 v2.5.1 ORU^R01 message. Exports are declared on the program, fire on triggers, run through a coordinator with retries, and leave one status row per attempt. This page documents the targets that exist, how a program binds to them, and how to read the result.

Targets

TargetkindTransportStatus
FHIR R4 serverfhir_medplumTransaction bundle over HTTPS with an OAuth client credential; idempotent Observation writesShipped
Epicfhir_epicTransaction bundle over HTTPS with SMART Backend Services; Patient.Search before writeProven against Epic's developer sandbox; production use requires your organization's Epic grant
HL7 v2 interface enginehl7v2_mllpORU^R01 v2.5.1 over MLLP with ACK handlingShipped
Read-only episode bundlenoneGET /api/v1/episodes/{id}/fhirShipped

Exports go to a FHIR server or an HL7 interface; no export target posts JSON to an arbitrary URL you own. The webhooks in this section are inbound (devices to AnyBio); see device ingest.

The FHIR bundle

GET /api/v1/episodes/{id}/fhir (Read scope) returns the episode as a FHIR R4 Bundle. The same builder feeds both FHIR adapters. The bundle contains:

  • Patient binding - by default no Patient resource is emitted: each Observation's subject is a logical identifier reference (system and value captured at enrollment and held per endpoint), and the receiving system resolves it to its own patient. AnyBio never creates or matches patients in a provider's system. An endpoint can require that binding, in which case an export for a patient without one is refused rather than sent unbound. A separate mode emits a literal Patient for a demonstration server that owns none.
  • Observation - one per linked observation, with the biosignal's LOINC code and unit, a business identifier under https://api.anybio.io/fhir/sid/observation so a re-export can be a conditional create rather than a duplicate, and meta.profile set from the biosignal catalog's declared profiles (for example US Core Heart Rate). A blood-pressure panel is one Observation with systolic and diastolic components.
  • DocumentReference - a telemetry strip rendered as PDF, when the episode produced one.
  • Provenance - who produced what, with in-bundle targets.
  • Organization - the exporting organization.

Per export, a program binding can shape what a FHIR target receives: shaping.mode is raw (default, everything in the window) or decimate with decimate_seconds (at most one observation per biosignal per bucket). Shaping thins what one opted-in export sends by time; it never drops a value and never touches what is stored.

Validation in CI

The backend's CI runs the official HL7 FHIR validator (FHIR 4.0.1, hl7.fhir.us.core#6.1.0) on every change. Two gates block the build: a hand-written known-good fixture must validate with zero errors, and a deliberately malformed fixture must be rejected. A third gate validates the bundle AnyBio actually generates against a checked-in list of known findings; a new finding fails the build.

The HL7 v2 message

The HL7 adapter assembles an ORU^R01 (v2.5.1) from the episode: PID and PV1 from the patient and episode, OBR-4 naming the program, and OBX segments from three sources - the linked biosignal observations as numeric results, condition_detected timeline events as text, and agent_insight events as text. A telemetry strip is referenced with a placeholder OBX pointing at the document, whose delivery is the FHIR adapter's job. Sending is over MLLP; the adapter parses the ACK and maps AA to success, AE and AR to a permanent failure, and a connect or ACK timeout to a retry.

Configure a target

FHIR endpoints

POST /api/v1/fhir-medplum-configs (Write scope), also GET, GET /{id}, PATCH /{id}, DELETE /{id}. Body:

{
  "name": "Sandbox FHIR server",
  "base_url": "https://fhir.example.org/fhir/R4/",
  "oauth_provider_id": 12,
  "project_id": 7,
  "epic_token_url": null,
  "enabled": true
}

oauth_provider_id names the credential row configured under Integrations, Data Destinations. epic_token_url is the SMART Backend Services token endpoint and is set only for an Epic row; it is distinct from base_url, the FHIR resource base. Both URLs pass the same guard at write time and again before any network call: HTTPS only, no user info, no IP literals, no localhost, no dotted-quad embedded in the host, no base that already ends in a resource type or metadata, and the host must be on the deployment's export host allowlist. An empty allowlist fails closed.

HL7 endpoints

POST /api/v1/hl7-endpoints (Write scope), also GET, GET /{id}, PATCH /{id}, DELETE /{id}. Body:

{
  "name": "Interface engine (test)",
  "host": "interface.example.org",
  "port": 6661,
  "sending_application": "ANYBIO",
  "sending_facility": "ANYBIO.IO",
  "receiving_application": "ENGINE",
  "receiving_facility": "CLINIC",
  "project_id": 7,
  "hl7_version": "2.5.1",
  "connect_timeout_ms": 5000,
  "ack_timeout_ms": 10000,
  "auto_submit_on_episode_close": false,
  "enabled": true
}

Optional: encoding_characters, processing_id, metadata.

Data exports

A data export names a target and a kind. POST /api/v1/data-exports (Write scope), also GET, GET /{id}, PATCH /{id}, DELETE /{id} (disable):

{
  "name": "Clinic interface",
  "kind": "hl7v2_mllp",
  "project_id": 7,
  "target_ref": { "kind": "hl7v2_mllp", "hl7_endpoint_id": "<uuid>" },
  "enabled": true
}

target_ref shapes: {"kind":"hl7v2_mllp","hl7_endpoint_id":"<uuid>"}; {"kind":"fhir_medplum","fhir_medplum_config_id":"<uuid>"}; the Epic adapter reads the same endpoint id key from a row whose epic_token_url is set.

POST /api/v1/data-exports/{id}/test sends a synthetic test message through the configured destination. The HL7 adapter forces MSH-11 = T (training) so a downstream reviewer cannot mistake it for clinical traffic.

Bind an export to a program

A program lists its exports in spec.exports. Each binding names a data export and the triggers that fire it:

"exports": [
  {
    "data_export_id": "<data export uuid>",
    "enabled": true,
    "triggers": [
      { "type": "on_episode_start" },
      { "type": "on_schedule", "cron": "0 7 * * *", "timezone": "America/Chicago", "require_new_data": true },
      { "type": "on_attention_crossed", "threshold": 61, "debounce_seconds": 3600 },
      { "type": "on_timeline_event", "event_type": "condition_detected" },
      { "type": "on_episode_end" }
    ],
    "shaping": { "mode": "decimate", "decimate_seconds": 300 }
  }
]
TriggerFires
on_episode_startOnce, at enrollment
on_episode_endOnce, if the episode closes (a long-lived program episode may never close)
on_timeline_eventPer matching episode_events row; optional predicate is a JSON subset match on the payload
on_scheduleOn a five-field cron in a timezone (user_local allowed); require_new_data skips empty windows
on_attention_crossedWhen attention rises above threshold, rate-limited by debounce_seconds (default 3600)
on_demandOnly by POST /api/v1/episodes/{episode_id}/data-exports/{data_export_id}

Read the result

Every fire creates an episode_data_exports row. GET /api/v1/episodes/{episode_id}/data-exports lists them; GET /api/v1/episode-data-exports/{id} returns one with kind, trigger_reason, export_status, started_at, completed_at, protocol_control_id, protocol_ack, error_message, retry_count, max_retries, next_retry_at, payload_ref, payload_bytes, window_start, window_end.

Statuses: pending, in_progress, success, failed, retrying. A transient failure (network, 5xx, redirect, token acquisition, MLLP timeout) schedules a retry at 1, 5 and 15 minutes and reuses the original window; a permanent failure (4xx, AE or AR, a malformed frame, a disabled endpoint, a bundle the builder could not produce) stops. POST /api/v1/episode-data-exports/{id}/retry re-queues a failed row by hand.

The Epic adapter, precisely

What is proven on demand: GET /api/v1/integrations/epic/sandbox-demo runs a read-only round-trip against Epic's public developer sandbox and its synthetic patients - SMART Backend Services authentication (an RS384 client assertion exchanged for a token), Patient.Read, Patient.Search by identifier, and a best-effort Observation.Search. The sandbox URLs are hard-coded in the service; it never reads a configured endpoint and never accepts a URL from the request, so it cannot touch a real Epic instance. When no signing key is configured for the environment it answers available: false with a static reason.

How the write adapter behaves (implemented, exercised in the test suite): it resolves the patient with Patient.Search on the logical identifier and refuses on zero or more than one result (it never creates a Patient), rewrites every in-bundle subject to the resolved Patient/<id>, posts the transaction bundle, and surfaces any non-2xx per-entry response as an error with Epic's diagnostic.

The Epic adapter is validated against Epic's developer sandbox; production use requires your organization's Epic grant.

On this page