Back to Blog

The Kareo/Tebra EHR Sync: Building and Rebuilding a Clinical Data Bridge

· 5 min read
Share:
The Kareo/Tebra EHR Sync: Building and Rebuilding a Clinical Data Bridge

Part of the Technical Breakdown series on my 4-year engagement with Prestige Men's Health.

The clinic's operational system (scheduling, billing, SMS, affiliate tracking) and its EHR of record are two separate systems that both need an accurate, current view of the same patients. That gap — operational database on one side, clinical record on the other — is the entire reason this sync layer exists, and it's one of the longer-running pieces of work in the engagement: 82+ commits starting in March 2024, continuing through a full platform migration in 2025 when the EHR vendor itself rebranded.

The core problem: matching patients across two systems without creating duplicates

The EHR is the source of truth for patient identity; the operational system needs its own client record for every patient, created and kept current automatically. Match too loosely and two different patients collide into one record. Match too strictly and the same real person ends up with duplicate client records that each have half their history.

// AFTER — app/Services/Kareo/Actions/MatchOrCreateClientFromKareoPatient.php (conceptual)
final class MatchOrCreateClientFromKareoPatient
{
    public function handle(KareoPatient $patient): Client
    {
        // Cascading match, strongest signal first — phone is the most
        // reliable because it's what the SMS platform already keyed on.
        return Client::query()->where('phone', $patient->phone)->first()
            ?? Client::query()->where('email', $patient->email)->first()
            ?? Client::query()
                ->where('first_name', $patient->first_name)
                ->where('last_name', $patient->last_name)
                ->where('date_of_birth', $patient->date_of_birth)
                ->first()
            ?? Client::query()->create($patient->toClientAttributes());
    }
}

This logic went through real iteration in production — an early version matched too aggressively and merged distinct patients; a later pass ("kareo sync and remove duplicates," mid-2024) had to retroactively clean up records that had already split. The phone → email → name+DOB cascade above is what the system settled on after that round of cleanup, not what shipped on day one.

Problem: an API call that overwrote instead of appending

One specific, very concrete bug: a notes-sync call to the EHR's API was replacing the existing notes field entirely instead of appending to it — meaning every sync after the first could silently erase prior clinical notes written directly in the EHR.

// BEFORE (conceptual)
public function syncNotes(string $patientId, string $newNote): void
{
    $this->client->patients()->update($patientId, [
        'notes' => $newNote, // overwrites whatever was already there
    ]);
}
// AFTER (conceptual)
public function syncNotes(string $patientId, string $newNote): void
{
    $existing = $this->client->patients()->find($patientId)->notes ?? '';

    $this->client->patients()->update($patientId, [
        'notes' => trim($existing."\n".$newNote),
    ]);
}

This is exactly the kind of bug that's invisible until someone notices clinical history is missing — there's no error, no failed request, just quietly incomplete data. It's also exactly why "the sync looks like it's working" and "the sync is correct" are different claims that need different verification.

The sync strategy: incremental windows, not full dumps

Pulling every patient on every run doesn't scale and hammers a third-party API you don't control the rate limits of. The sync settled on windowed incremental fetches — pulling patients by creation date in rolling windows (originally six months at a time) on a scheduled cron cadence, with appointment sync running as a related but separate job (SyncKareoAppointments) rather than one monolithic sync trying to do everything atomically.

The platform migration: Kareo becomes Tebra

In real life, Kareo (a well-known EHR/practice-management platform) and PatientPop merged and rebranded as Tebra. That's not a clinic-specific detail — it's an industry event that happened to this integration at a very inconvenient time, because it meant the underlying API was moving toward a FHIR-based model rather than Kareo's original REST API.

Starting mid-2025, this meant rebuilding the integration rather than patching it — a TebraFHIRImportService and TebraBulkExportService built around FHIR resources (TebraFhirPatientResource, TebraFhirMedicationRequest) instead of letting the old integration keep running against a deprecated API surface that could be sunset with little warning.

// AFTER — app/Services/Tebra/TebraBulkExportService.php (conceptual)
final class TebraBulkExportService
{
    public function __construct(private readonly TebraFhirClient $client) {}

    public function exportPatientBatch(CarbonImmutable $since, int $batchSize = 100): Generator
    {
        $cursor = null;

        do {
            $page = $this->client->patients()->bulkExport([
                'since' => $since->toIso8601String(),
                'count' => $batchSize,
                'cursor' => $cursor,
            ]);

            yield from $page->resources();

            $cursor = $page->nextCursor();
        } while ($cursor !== null);
    }
}

Bulk FHIR export/import operations against a third-party API are exactly where timeouts show up under real data volume — this integration needed its timeout budget extended more than once ("extend tebra FHIR bulk exp + imp timeout," "increase tebra bulk so does not timeout anymore") as the patient dataset grew past whatever the original timeout assumed.

Problem: an expired auth token silently breaking sync, and a near-miss with overwrite protection

A 401 from the API meant an expired or rotated credential — the kind of failure that needs to fail loudly (alert, retry with fresh auth) rather than silently skip a sync cycle. The same patch that addressed this also added an explicit "do not overwrite" guard on the sync — a direct, learned response to the notes-overwrite bug above, generalized into a standing safeguard rather than a one-off fix.

The autonomous AI layer, applied here too

The same wave of ~65 autonomous Copilot coding-agent PRs from early 2026 that hardened the SMS platform touched this integration directly: copilot/patch-fhir-import-command and copilot/fix-fhir-export-import were both real, independently reviewed and merged PRs fixing FHIR import/export bugs — the agentic engineering phase from the main post wasn't limited to UI features, it touched the clinical data sync layer too.


This is one of four deep-dives off the main technical breakdown post — the others cover the SMS communication platform, the Rx order pipeline, and the patient portal.

Share:

Comments

No comments yet — be the first to share your thoughts.

Leave a comment

Your comment will be reviewed before it appears publicly.

Never published — only used if we need to reach you.

More from the blog