Skip to content

Integrations

Curaway integrates with external services for voice input, multilingual support, currency conversion, video consultations, observability, and communication. Each integration is designed for incremental adoption — starting with free or built-in options and graduating to specialized services as usage grows.


Voice Input: Deepgram (3-Phase Plan)

Voice input allows patients to describe their medical situation naturally instead of typing. The implementation follows a three-phase plan, each gated by feature flags.

flowchart LR
    P1[Phase 1<br/>Web Speech API<br/>CURRENT] --> P2[Phase 2<br/>OpenAI Whisper<br/>Feature-flagged]
    P2 --> P3[Phase 3<br/>Deepgram Streaming<br/>WebSocket]

Phase 1: Web Speech API (Current)

Uses the browser's built-in speech recognition. Zero cost, zero backend changes, but limited accuracy and browser support (Chrome/Edge only).

const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = false;
recognition.interimResults = true;
recognition.lang = userProfile.preferredLanguage || 'en-US';

recognition.onresult = (event) => {
  const transcript = event.results[0][0].transcript;
  setInputText(transcript);
};

recognition.onerror = (event) => {
  if (event.error === 'not-allowed') {
    showMicPermissionPrompt();
  }
};
Attribute Value
Cost Free
Accuracy Moderate (browser-dependent)
Languages Depends on browser/OS
Latency Real-time (local processing)
Browser support Chrome, Edge (partial Safari)

Phase 2: OpenAI Whisper (Feature-Flagged)

Server-side transcription with higher accuracy. Audio is recorded on the client, sent to the backend, and transcribed via the Whisper API.

@router.post("/voice/transcribe")
@feature_flag("voice_whisper_enabled")
async def transcribe_audio(audio: UploadFile, claims: TokenClaims = Depends(get_claims)):
    """Transcribe audio using OpenAI Whisper."""
    audio_bytes = await audio.read()

    transcript = await openai_client.audio.transcriptions.create(
        model="whisper-1",
        file=("audio.webm", audio_bytes, audio.content_type),
        language=claims.preferred_language[:2],  # ISO 639-1
    )

    return {"text": transcript.text, "language": transcript.language}

Phase 3: Deepgram Streaming (Future)

Real-time streaming transcription via WebSocket for the lowest latency experience.

@router.websocket("/voice/stream")
async def voice_stream(websocket: WebSocket):
    await websocket.accept()
    claims = await authenticate_websocket(websocket)

    async with deepgram.listen.asynclive.v("1") as dg_connection:
        dg_connection.on(LiveTranscriptionEvents.Transcript, handle_transcript)

        await dg_connection.start(LiveOptions(
            model="nova-2-medical",
            language=claims.preferred_language[:2],
            smart_format=True,
            punctuate=True,
        ))

        while True:
            audio_chunk = await websocket.receive_bytes()
            await dg_connection.send(audio_chunk)
Phase Feature Flag Status
1 N/A (always on) Active
2 voice_whisper_enabled Ready, flag off
3 voice_deepgram_enabled Planned

Multilingual Support

Curaway serves patients across 8+ countries. The multilingual strategy uses canonical English storage with edge translation for display.

Architecture

flowchart TD
    A[Patient Input<br/>Any Language] --> B[LLM Translation<br/>to English]
    B --> C[Canonical English Storage]
    C --> D[ICD/SNOMED Codes<br/>Language-agnostic]
    C --> E[LLM Translation<br/>to Patient Language]
    E --> F[Patient Display<br/>Native Language]

Canonical English Storage

All medical data is stored in English as the canonical language. This ensures:

  • Consistent matching across providers who operate in different languages
  • Reliable ICD-10 and SNOMED CT code mapping
  • Single source of truth for medical terminology

ICD/SNOMED Code Mapping

# Procedures and conditions are stored with standardized codes
procedure = {
    "name": "Total Knee Replacement",
    "icd_10": "0SRD0JZ",
    "snomed_ct": "609588000",
    "name_translations": {
        "es": "Reemplazo total de rodilla",
        "tr": "Total diz protezi",
        "th": "การผ่าตัดเปลี่ยนข้อเข่า",
        "ko": "인공슬관절 전치환술",
        "ar": "استبدال الركبة بالكامل",
    }
}

JSONB Name Translations

Provider and procedure names use a name_translations JSONB column for pre-computed translations of common terms:

CREATE TABLE procedures (
    id UUID PRIMARY KEY,
    name VARCHAR(200) NOT NULL,            -- Canonical English
    icd_10_code VARCHAR(20),
    snomed_code VARCHAR(20),
    name_translations JSONB DEFAULT '{}',  -- {"es": "...", "tr": "...", ...}
    ...
);

Edge Translation via LLM

For dynamic content (chat messages, descriptions), translation happens at the response layer using the conversation LLM:

async def translate_response(text: str, target_language: str) -> str:
    """Translate response to patient's preferred language."""
    if target_language == "en":
        return text

    response = await llm_client.create(
        model="claude-3-haiku-20240307",
        messages=[{
            "role": "user",
            "content": f"Translate to {target_language}, preserving medical terms "
                       f"and a warm, professional tone:\n\n{text}"
        }],
    )
    return response.content[0].text

Qdrant embeddings use the Voyage AI voyage-3.5-lite model, which supports multilingual input. A query in Turkish will find relevant English-stored providers because the embedding space maps semantically similar concepts close together regardless of language.

# Patient searches in Turkish: "diz protezi yapan hastaneler"
# Embedding maps this near "knee replacement hospitals" in vector space
results = qdrant.search(
    collection_name="providers",
    query_vector=embed("diz protezi yapan hastaneler"),  # Turkish input
    limit=10,
)
# Returns English-stored providers specializing in knee replacement

Multicurrency

Curaway displays costs in the patient's local currency while storing all amounts in the smallest unit of the provider's currency (e.g., USD cents) to avoid floating-point errors.

Storage Format

# All monetary values stored as integers in smallest currency unit
class ProcedureCost(BaseModel):
    amount: int          # e.g., 850000 (= $8,500.00)
    currency: str        # ISO 4217: "USD", "EUR", "TRY", etc.

    @property
    def display_amount(self) -> Decimal:
        """Convert to display format."""
        return Decimal(self.amount) / Decimal(10 ** CURRENCY_EXPONENTS[self.currency])

ISO 4217 Currency Handling

# Exponents per ISO 4217
CURRENCY_EXPONENTS = {
    "USD": 2,  # cents
    "EUR": 2,
    "GBP": 2,
    "TRY": 2,  # kurus
    "INR": 2,  # paise
    "THB": 2,  # satang
    "MXN": 2,  # centavos
    "KRW": 0,  # no subdivision
    "AED": 2,  # fils
    "CRC": 2,  # centimos
}

Frankfurter API for Display-Time Conversion

Exchange rates are fetched daily via QStash cron and cached in Redis. Conversion happens only at display time — stored amounts never change.

async def refresh_exchange_rates():
    """Daily cron: fetch latest rates from Frankfurter API."""
    response = await httpx.get(f"{FRANKFURTER_API_URL}/latest?from=USD")
    rates = response.json()["rates"]

    for currency, rate in rates.items():
        await redis.set(
            f"exchange_rate:USD:{currency}",
            str(rate),
            ex=90000,  # 25 hours TTL (overlap with daily refresh)
        )

async def convert_for_display(amount: int, from_currency: str, to_currency: str) -> dict:
    """Convert amount for display only. No floating-point math on stored values."""
    if from_currency == to_currency:
        return {"amount": amount, "currency": from_currency}

    rate_str = await redis.get(f"exchange_rate:{from_currency}:{to_currency}")
    rate = Decimal(rate_str)

    converted = int(Decimal(amount) * rate)
    return {
        "amount": converted,
        "currency": to_currency,
        "rate": str(rate),
        "rate_date": datetime.utcnow().date().isoformat(),
        "disclaimer": "Approximate conversion. Final amount determined at time of payment.",
    }

No Floating-Point Math

All currency calculations use Decimal or integer arithmetic. Python float is never used for monetary values to avoid rounding errors (e.g., 0.1 + 0.2 != 0.3).


Video Consultations: Daily.co

Video consultations connect patients with providers for pre-travel assessments. Daily.co provides HIPAA-eligible video infrastructure with 10,000 free minutes per month.

Data Model

CREATE TABLE consultations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id VARCHAR(100) NOT NULL,
    patient_id UUID NOT NULL REFERENCES patients(id),
    provider_id UUID NOT NULL REFERENCES providers(id),
    doctor_id UUID REFERENCES doctors(id),
    daily_room_name VARCHAR(200),
    daily_room_url TEXT,
    status VARCHAR(20) DEFAULT 'scheduled',  -- scheduled, active, completed, cancelled
    scheduled_at TIMESTAMPTZ NOT NULL,
    started_at TIMESTAMPTZ,
    ended_at TIMESTAMPTZ,
    duration_seconds INTEGER,
    notes TEXT,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE consultation_participants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    consultation_id UUID NOT NULL REFERENCES consultations(id),
    user_id VARCHAR(100) NOT NULL,
    role VARCHAR(20) NOT NULL,       -- patient, doctor, interpreter
    joined_at TIMESTAMPTZ,
    left_at TIMESTAMPTZ,
    duration_seconds INTEGER
);

Room Creation

async def create_consultation_room(consultation_id: str) -> dict:
    """Create a Daily.co room for a consultation."""
    response = await httpx.post(
        "https://api.daily.co/v1/rooms",
        headers={"Authorization": f"Bearer {DAILY_API_KEY}"},
        json={
            "name": f"curaway-{consultation_id[:8]}",
            "privacy": "private",
            "properties": {
                "enable_recording": "cloud",
                "enable_chat": True,
                "exp": int((datetime.utcnow() + timedelta(hours=2)).timestamp()),
                "max_participants": 4,
                "enable_knocking": True,
            },
        },
    )
    room = response.json()
    return {"room_name": room["name"], "room_url": room["url"]}

Video SDK Deferred

The actual Daily.co video SDK integration in the frontend is deferred. The backend creates rooms and manages scheduling, but the patient-facing video UI is a future milestone.


MCP Protocol

Curaway exposes a Model Context Protocol (MCP) server that allows external AI systems to interact with platform data through a standardized tool interface.

Exposed Tools

Tool Description Parameters
search_providers Semantic provider search query, country, procedure_type
get_provider_details Full provider profile provider_id
check_procedure_availability Procedure availability at provider provider_id, procedure_id
get_travel_requirements Visa and travel requirements origin_country, destination_country
estimate_cost Cost estimate with currency conversion procedure_id, provider_id, currency
get_patient_status Current patient journey status patient_id (requires auth)

MCP Server Configuration

from mcp import Server

mcp_server = Server("curaway-medical-travel")

@mcp_server.tool()
async def search_providers(query: str, country: str = None, procedure_type: str = None):
    """Search for medical providers matching patient needs."""
    results = await provider_search_service.search(
        query=query,
        country=country,
        procedure_type=procedure_type,
    )
    return [
        {
            "provider_id": r.id,
            "name": r.name,
            "country": r.country,
            "city": r.city,
            "specialties": r.specialties,
            "accreditations": r.accreditations,
            "match_score": r.score,
        }
        for r in results
    ]

Email: Resend

Resend handles transactional email delivery with 3,000 free emails per month.

Email Templates

Template Trigger Content
match_ready Provider matching completes Top 3 matched providers with summary
intake_reminder Incomplete intake (hourly cron) Gentle nudge to complete medical intake
consent_expiring Consent version outdated (daily cron) Prompt to review updated terms

Sending Implementation

import resend

resend.api_key = RESEND_API_KEY

async def send_email(template: str, to_email: str, data: dict):
    """Send a transactional email via Resend."""
    template_fn = EMAIL_TEMPLATES[template]
    html_content = template_fn(data)

    resend.Emails.send({
        "from": "Curaway <hello@curaway.com>",
        "to": [to_email],
        "subject": EMAIL_SUBJECTS[template],
        "html": html_content,
    })

    await audit_log(
        tenant_id=data["tenant_id"],
        actor_id="system",
        action="email.sent",
        resource_type="email",
        details={"template": template, "to": to_email},
    )

Push Notifications (Stubbed)

Device registration for FCM (Android) and APNs (iOS) is stubbed in the data model, ready for implementation when mobile apps launch.

CREATE TABLE device_registrations (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    patient_id UUID NOT NULL REFERENCES patients(id),
    tenant_id VARCHAR(100) NOT NULL,
    platform VARCHAR(10) NOT NULL,    -- 'fcm' or 'apns'
    device_token TEXT NOT NULL,
    is_active BOOLEAN DEFAULT true,
    registered_at TIMESTAMPTZ DEFAULT NOW(),
    last_seen_at TIMESTAMPTZ
);

PostHog Analytics

PostHog provides product analytics with strict PHI safety measures.

Configuration

import posthog

posthog.api_key = POSTHOG_API_KEY
posthog.host = "https://app.posthog.com"

# CRITICAL: Identify users by UUID only — never PII
posthog.identify(
    distinct_id=patient.id,  # UUID, not email or name
    properties={
        "tenant_id": patient.tenant_id,
        "country": patient.country,          # OK: not PII
        "preferred_language": patient.lang,   # OK: not PII
        # NEVER: name, email, phone, medical info
    },
)

Autocapture Disabled

// posthog-js configuration
posthog.init(POSTHOG_API_KEY, {
  api_host: 'https://app.posthog.com',
  autocapture: false,           // DISABLED: prevents capturing PHI in form fields
  capture_pageview: true,       // Page views are safe
  capture_pageleave: true,
  mask_all_text: true,          // Extra safety: mask text in session recordings
  mask_all_element_attributes: true,
});

PHI Safety

Autocapture is disabled because it would capture form field contents, which may contain medical information. All tracking events are explicitly defined with reviewed properties.


Langfuse: LLM Observability

Langfuse provides production tracing for all LLM interactions, including cost tracking and prompt management.

Trace Integration

from langfuse import Langfuse

langfuse = Langfuse(
    public_key=LANGFUSE_PUBLIC_KEY,
    secret_key=LANGFUSE_SECRET_KEY,
    host=LANGFUSE_HOST,
)

async def generate_response(message: str, session_id: str, tenant_id: str) -> str:
    trace = langfuse.trace(
        name="conversation_turn",
        session_id=session_id,
        metadata={"tenant_id": tenant_id},
    )

    # Classification span
    classification = trace.span(name="input_classification")
    category = await classify_message(message)
    classification.end(output={"category": category})

    # Generation span
    generation = trace.generation(
        name="response_generation",
        model="claude-sonnet-4-20250514",
        input=[{"role": "user", "content": message}],
    )
    response = await llm_client.create(messages=messages)
    generation.end(
        output=response.content[0].text,
        usage={"input": response.usage.input_tokens, "output": response.usage.output_tokens},
    )

    return response.content[0].text

Cost Tracking

Langfuse automatically calculates costs based on token usage and model pricing. The dashboard shows daily/weekly/monthly LLM spend broken down by trace type.

Prompt Management

System prompts are versioned in Langfuse with production/staging labels. The backend fetches the current production prompt at startup and caches it.


Timezone Handling

All timestamps are stored in UTC. Conversion to the user's local timezone happens at the response layer.

Storage

-- All TIMESTAMPTZ columns store UTC
ALTER TABLE patients ADD COLUMN timezone VARCHAR(50) DEFAULT 'UTC';
-- Example: 'America/New_York', 'Asia/Istanbul', 'Asia/Bangkok'

Conversion at Response Layer

from zoneinfo import ZoneInfo

def to_user_timezone(utc_dt: datetime, timezone_str: str) -> str:
    """Convert UTC datetime to user's timezone for display."""
    user_tz = ZoneInfo(timezone_str)
    local_dt = utc_dt.replace(tzinfo=ZoneInfo("UTC")).astimezone(user_tz)
    return local_dt.isoformat()

# Usage in response
{
    "consultation": {
        "scheduled_at_utc": "2026-04-15T14:00:00Z",
        "scheduled_at_local": to_user_timezone(
            consultation.scheduled_at, patient.timezone
        ),
        "timezone": patient.timezone,
    }
}

Cron Jobs and UTC

All QStash cron schedules are in UTC. When a cron job needs to operate in user-local time (e.g., sending digest emails at "8 AM local"), it queries patients grouped by timezone and processes each group at the appropriate UTC offset.