← Back to Blog

Wearables to FHIR: Turning Consumer Health Data Into Clinical Data

April 8, 202610 min readPratik Awaik

There's a weird gap in healthcare right now. Millions of people wear Fitbits and Dexcom CGMs that track their heart rate, blood glucose, sleep, oxygen levels – all day, every day. But when they walk into their doctor's office, that data might as well not exist. It's trapped inside consumer apps that don't talk to clinical systems.

I wanted to close that gap. So I built VitalBridge – a platform that pulls wearable data from Dexcom and Fitbit, converts it into standardized FHIR R4 resources with proper LOINC coding, and hands clinicians a dashboard with real-time alerts and AI-generated weekly summaries. The kind of thing where a doctor opens a patient's chart and sees “glucose time-in-range dropped 15% this week, nocturnal dips detected” instead of...nothing.

This isn't a tutorial. It's more of a build log. I want to talk about the parts that surprised me, the decisions I had to make on the fly, and the stuff that broke in ways I didn't expect. If you're building anything that touches wearable data and FHIR, maybe this saves you a few late nights.

The Problem Nobody Talks About

Fitbit gives you a JSON blob with restingHeartRate: 68. That's great for a mobile app. It's useless for Epic. Epic doesn't know what “restingHeartRate” means. It knows what LOINC code 8867-4 means. It knows what a FHIR Observation with a UCUM unit of /min means. The entire project, when you strip away the dashboard and the alerts and the AI stuff, is really about one thing: turning consumer health data into clinical health data.

Here's what the system looks like at a high level:

VitalBridge Architecture

Dexcom
Fitbit
Ingestion + FHIR Mapper
HAPI FHIR Server
Dashboard
Alerts
AI

Dexcom and Fitbit pump data into an ingestion service that handles OAuth, deduplication, and the FHIR conversion. Everything lands in a HAPI FHIR server running US Core profiles. On the other side, a React dashboard, a threshold-based alert engine, and an AI summarization service that generates weekly clinical notes. The whole thing runs on Azure with the frontend on Vercel.

288 Glucose Readings a Day (and Why That's a Problem)

I didn't think about data volume until it hit me. A Dexcom G7 generates a glucose reading every 5 minutes. That's 288 readings per day, per patient. Over a week, that's 2,016 glucose readings. Now add heart rate. Add SpO2. Add steps and sleep. Multiply by the number of patients a clinician manages.

Two problems showed up immediately. First, deduplication. If the sync scheduler runs every 5 minutes and fetches overlapping time windows, you're going to store duplicates. I built a two-layer dedup – an in-memory SHA-256 cache for speed, backed by a FHIR server query with exact date matching for correctness. The cache handles 99% of duplicates. The FHIR query catches anything the cache missed after a restart.

Second, the AI summaries. You can't send 2,000 FHIR Observations to an LLM. My first approach was naive – just pick every 40th reading to get 50 evenly spaced data points. It captures trends, but it can completely miss a 15-minute hypoglycemic episode at 3 AM. Three dangerous readings, all skipped because the sampling step landed on either side of them. For a trend chart that's fine. For a clinical summary it's not.

The fix: anomaly-preserving sampling. I always include every reading that falls outside safe clinical ranges – glucose below 70 or above 250, heart rate below 50 or above 100, SpO2 below 94%. Then I fill the remaining slots with evenly distributed normal readings. The AI sees every dangerous data point, guaranteed, plus enough normal readings to understand the baseline trend.

The other thing that took some thought: sync frequency. Dexcom data is near-real-time, so it syncs every 5 minutes. Fitbit provides per-minute heart rate and per-15-minute step counts via their intraday API (requires approved access), plus daily SpO2 and sleep. I sync Fitbit every hour – frequent enough for useful monitoring, light enough to stay well within their 150 requests/hour rate limit. One scheduler, different cadences per connector.

Fitbit Sends Auth Differently and It Cost Me an Hour

Both Dexcom and Fitbit use OAuth 2.0. You'd think the token exchange would be the same. It's not. Dexcom sends the client ID and secret in the POST body, like most OAuth implementations I've worked with. Fitbit wants them as an HTTP Basic auth header – base64(client_id:client_secret). I spent an hour staring at 401 responses thinking my refresh token was bad before I read the docs more carefully.

Also worth mentioning: Fitbit's Web API is being deprecated in September 2026 in favor of Google Health Connect. I built the connector anyway because VitalBridge uses a connector interface pattern – each data source implements one interface, and the entire downstream pipeline (FHIR mapper, alerts, dashboard) doesn't care where the data came from. Swapping Fitbit for Google Health Connect later means writing one new class. Everything else stays the same.

Your Heart Rate Alert Is Useless If the Patient Is Running

I had the alert engine working pretty quickly. Heart rate above 120? Fire an alert. Glucose below 70? Fire an alert. Felt good. Then I thought about it for more than five seconds and realized I'd be paging a clinician every time a patient went for a jog. A heart rate of 160 during a run is totally fine. The same number at rest is a genuine emergency. Same reading, completely different clinical meaning.

My first thought was to check step-count data – if there are a lot of steps around the time of the reading, the patient must be active. That works with intraday step data, but it's still an inference. Cycling, swimming, weight training – none of those produce steps. You'd miss them entirely.

The better fix: Fitbit's Activity Log API returns explicit exercise sessions with a start time and duration. “User was running from 7:15 AM to 7:45 AM for 30 minutes.” No guessing from step counts. I pull these during each Fitbit sync, store them as FHIR Observations with an effectivePeriod, and the alert engine checks if the reading falls inside any exercise window:

// Activity-aware suppression
if (rule.restingOnly() && isHeartRateCode(loincCode)) {
    boolean exercising = activityEvaluator
        .isPatientExercising(patientId, readingTime);
    if (exercising) {
        continue; // not an emergency, they're working out
    }
}

There's also a 15-minute cooldown after each session ends. Your heart rate doesn't snap back to resting the second you stop running. Without the cooldown window, I'd fire alerts during recovery. It's a small detail but it's the kind of thing that separates a health app from a fitness app – in clinical software, a false alert isn't just annoying, it's a liability.

LOINC Codes: The Boring Part That Makes Everything Work

I keep coming back to this because it really is the core of the whole project. Every vital sign has a LOINC code – heart rate is 8867-4, blood glucose is 2339-0, SpO2 is 59408-5. When you store a reading with the right code, any EHR in the world knows what it is. No translation layer needed. That's the whole promise of FHIR interoperability.

The clean case is genuinely simple:

// HeartRateMapper.java
observation.getMeta().addProfile(
    "http://hl7.org/fhir/us/core/" +
    "StructureDefinition/us-core-heart-rate");

observation.getCode().addCoding()
    .setSystem("http://loinc.org")
    .setCode("8867-4")
    .setDisplay("Heart rate");

observation.setValue(new Quantity()
    .setValue(72)
    .setUnit("beats/minute")
    .setSystem("http://unitsofmeasure.org")
    .setCode("/min"));

LOINC code, UCUM unit, US Core profile. Three things. Get all three right and the HAPI FHIR server accepts your resource. Get any one wrong and you get a validation error that tells you almost nothing useful.

Then you try SpO2 and discover that US Core's Pulse Oximetry profile requires two LOINC codings on the same Observation – 59408-5 for pulse oximetry AND 2708-6 for oxygen saturation. I missed the second one and got a cryptic “minimum required = 2 codings” error. Blood pressure is another step up – it's not a single value, it's a panel observation with systolic and diastolic as separate components. And the panel code? Not the obvious 55284-4. US Core wants 85354-9.

I ended up with 7 mapper classes – heart rate, glucose, blood pressure, SpO2, steps, sleep, and a base class for shared logic. Each one has exactly one job. Sounds like overkill until you remember that one wrong LOINC code means your data is invisible to every EHR downstream.

Don't Send PHI to an LLM (And How I Didn't)

A clinician managing 40 patients can't review thousands of individual readings per patient per week. So I built an AI layer that generates a weekly summary in plain English – trends, anomalies, time-in-range for glucose, activity assessment, anything the clinician should look at more closely.

The obvious problem: you can't send patient health information to a third-party API. HIPAA is clear about that. So before anything leaves the system, I run a de-identifier that follows HIPAA Safe Harbor rules – strips names, MRN, device serial numbers, addresses, anything from the 18 identifier categories. What the LLM actually receives looks like “47-year-old Female – Heart rate: 72 bpm, Blood glucose: 142 mg/dL.” No way to identify the patient. The clinical data is intact. The identity is gone.

I started with hardcoded mock summaries that said generic things like “Heart rate has remained stable this week.” Honestly, they were fine for demos. But when I switched to Gemini's free tier, the difference was obvious. The AI picks up actual patterns – it notices that glucose spikes happen specifically after breakfast and dinner, it correlates low activity days with elevated resting heart rate, it flags a gradual SpO2 decline over three days.

Worth noting: The AI summary gets stored back into the FHIR server as a DiagnosticReport with LOINC code 11488-4. It's not in a separate database – it lives alongside the patient's other clinical data. Any FHIR-compliant system can query it.

One Proxy to Rule All the FHIR Queries

This was probably the most important architectural decision in the project. The HAPI FHIR server is internal-only – not accessible from the internet. Every query from the dashboard goes through a single proxy endpoint that checks the JWT, extracts the user's role, and injects patient-scoping filters. You can't query another patient's data even if you know their ID, because the proxy replaces whatever patient filter you send with the one your role authorizes.

// Role-based FHIR scoping
String scopedQuery = switch (role) {
    case "PATIENT"   -> forcePatientFilter(query, patientId);
    case "CLINICIAN" -> forceClinicFilter(query, clinicPatientIds);
    case "ADMIN"     -> query;
    default          -> "__DENIED__";
};

Patients see only their own data. Clinicians see data for patients in their clinic – linked through a clinic code system (similar to how Dexcom Clarity handles sharing). Admins see everything. The proxy strips any patient filter the client sends and replaces it with the authorized one. One enforcement point. If the proxy is correct, the entire system is correct.

Every FHIR access is also logged to an append-only audit table – who accessed what resource type, when, from which IP. The audit service runs async so it doesn't slow down requests. And it never logs PHI. Only resource types and IDs.

Honest Mistakes

I built a simulated data connector early on that generates fake readings for every vital type. It was useful for testing the dashboard and the FHIR mapping pipeline without needing real devices. But it masked every real-world problem – OAuth token refresh failures, API rate limits, the Fitbit Basic auth thing I mentioned earlier. None of that exists in a simulator. When I finally wired up the real Dexcom sandbox, a whole category of bugs appeared that I would have caught earlier if I'd started there.

The second one still bugs me. When the HAPI FHIR server kept rejecting our Observations with US Core profile validation errors, I removed the profile declarations instead of fixing the underlying issue. “Just get it working first.” The underlying issue was wrong LOINC codes and missing required codings. I eventually had to add all the profiles back and fix every mapper anyway. Except now I also had to fix all the tests that were written against the broken mappers. Should have just read the US Core spec properly the first time.

Third: email verification. I added it towards the end of development using Resend. Retrofitting it meant updating the auth store, adding redirect logic for the “authenticated but not verified” state, and handling edge cases I hadn't planned for. It's a 2-hour feature if you build it first. It's a 2-day feature if you bolt it on later.

The Stack, If You're Curious

  • Backend: Java 25, Spring Boot 3.4, HAPI FHIR library
  • FHIR Server: HAPI FHIR JPA with US Core 7.0.0
  • Frontend: React 19, TypeScript, TailwindCSS, Recharts
  • AI: Google Gemini with HIPAA Safe Harbor de-identification
  • Infra: Azure Container Apps, Vercel, PostgreSQL

You can see it live at vitalbridge-sh.vercel.app.

We Build This Kind of Thing

VitalBridge is a project by Symbol Health. We build FHIR pipelines, wearable integrations, and HIPAA-compliant healthcare backends for startups and health systems.

If you're working on something in this space and want to talk through the architecture, I'm happy to jump on a call. No pitch – just a conversation about what you're building.

Book a Free 30-Minute Call