Architecture Overview
Garmin data arrives exclusively via webhooks. There are no REST pull endpoints (forbidden by Garmin’s API terms). The system requests historical data via backfill endpoints, then Garmin delivers the data asynchronously through webhook callbacks.Data Flow
Real-time (ongoing after connection)
Historical backfill (on first connection)
OAuth Connection
File:
backend/app/api/routes/v1/oauth.py- Frontend redirects user to
GET /api/v1/oauth/{provider}/authorize - User grants permissions on Garmin’s site
- Garmin redirects back to
GET /api/v1/oauth/{provider}/callback - Callback handler:
- Creates/updates
UserConnectionin the database - Dispatches
sync_vendor_data.delay()for initial sync - For Garmin specifically: dispatches
start_garmin_full_backfill.delay(user_id)
- Creates/updates
Webhook Handlers (PING/PUSH)
File:
backend/app/api/routes/v1/garmin_webhooks.pyPING (callback-based)
POST /api/v1/garmin_webhooks/ping
Garmin sends a notification with callback URLs. The handler fetches the actual data from those URLs.
- Extract callback URL from notification
- Fetch data via
httpx.get(callback_url)with OAuth token - Find internal user via Garmin user ID mapping
- Batch-process via
Garmin247Data.process_items_batch() - Commit to PostgreSQL
- If a backfill is active: call
mark_type_success(user_id, data_type) - On new success transition: call
trigger_next_pending_type.delay()
PUSH (direct payload)
For data types where Garmin sends the payload directly in the webhook body rather than a callback URL.Backfill chain integration
The webhook handlers are the bridge between Garmin’s async data delivery and the backfill state machine. When a webhook arrives for a type that was requested via backfill:mark_type_success()transitions the type fromtriggeredtosuccess- Only on a new transition (returns
True),trigger_next_pending_typeis enqueued - This chains the sequential processing: request type -> await webhook -> next type
Backfill Orchestration
Configuration
File:
backend/app/services/providers/garmin/backfill_config.pyRate limiting: Garmin allows 100 requests/minute. Backfill reserves 30% of the budget (30 req/min), resulting in a 2-second delay between type requests.
State Machine
File:
backend/app/integrations/celery/tasks/garmin/backfill_task.pyPhase 1: Sequential window processing
Phase 2: Retry (after window completes)
When all windows are exhausted, timed-out types get one retry:A second timeout during retry escalates the type from
timed_out to failed (permanent). This is distinct from timed_out which indicates the type may succeed if retried.Phase 3: Garbage collection (background)
See Garbage Collection below.Window Progression
The backfill processes a single 30-day window, covering the maximum range allowed by Garmin (data from the last 30 days before the user connected to the developer app):- For each of the 5 types: trigger -> await webhook or timeout -> next type
persist_window_results()copies flat type status keys to per-window matrix keysadvance_window()increments the window counter and resets flat keys to “pending”- If more windows remain, trigger the first type for the new window
Retry Phase
After the window completes, the system checks for timed-out entries:get_retry_targets()reads thetimed_out_typesJSON list from Redis- Deduplicates by keeping only the latest (highest) window per type
- Each target is retried once using the same
trigger_backfill_for_typeinfrastructure - The retry uses the original window’s date range (not the current sequential window)
- The main window counter is not modified during retry
- Webhook arrives during retry: type marked
donein matrix - Timeout during retry: type escalated to
failed(nottimed_out) failedis a terminal state with no further retries
Garbage Collection
File:
backend/app/integrations/celery/tasks/garmin/gc_task.py- GC preserves completed window data — only the lock is cleared
- The currently-triggered type is recorded for retry via
record_timed_out_entry() - After lock release, the user can re-trigger backfill (via disconnect/reconnect)
- After 3 GC cycles (
GC_MAX_ATTEMPTS), the backfill is marked permanently failed - GC skips users in an active retry phase to avoid interference
- When no backfills are active, the task is essentially a no-op (one empty SCAN)
Redis Key Schema
All keys use the prefixgarmin:backfill:{user_id}: and have a 7-day TTL.
Lock and control
Window tracking
Per-type flat keys (current window)
Per-window matrix keys (persisted history)
These are written by
persist_window_results() when advancing to the next window, and by update_window_cell() during retry.
Retry phase keys
Timeout tracking
API Endpoints
File:
backend/app/api/routes/v1/sync_data.pyAll endpoints require API key authentication (ApiKeyDep).GET /api/v1/providers/garmin/users/{user_id}/backfill/status
Returns the full backfill status matrix. Response:overall_status values:
POST /api/v1/providers/garmin/users/{user_id}/backfill/cancel
Requests graceful cancellation. The backfill stops after the current type completes or times out. Returns 409 Conflict if no backfill is in progress.POST /api/v1/providers/garmin/users/{user_id}/backfill/{type_name}/retry
Retries a specific timed-out type. Valid types:sleeps, dailies, activities, activityDetails, hrv.
Frontend Integration
TypeScript Types
File:
frontend/src/lib/api/types.tsReact Hooks
File:
frontend/src/hooks/api/use-health.tsAPI Service
File:
frontend/src/lib/api/services/health.service.tsConnection Card UI
File:
frontend/src/components/user/connection-card.tsx
Additional elements:
- Attempt counter: “Attempt N of 3” shown when
attempt_count > 0 - Timed-out types: Listed with amber/warning styling, each with a Retry button
- Failed types: Listed with red/destructive styling, no retry (terminal state)
- The visual distinction between timed_out (amber) and failed (red) communicates that timed-out types may succeed on retry while failed types are permanent
Data Types
Backfill types (5)
These are actively requested during historical backfill:Webhook-only types (11)
These are not requested during backfill but are accepted when Garmin sends them via webhook:epochs, bodyComps, stressDetails, allDayRespiration, pulseox, bloodPressures, userMetrics, skinTemp, healthSnapshot, moveIQActivities, mct
Processing
File:
backend/app/services/providers/garmin/data_247.pyGarmin247Data which handles:
- Fetching data from callback URLs
- Parsing type-specific payload formats
- Converting timestamps (Unix epoch seconds to UTC datetimes)
- Batch inserting into PostgreSQL with deduplication
- Two record types:
DataPointSeries(continuous metrics) andEventRecords(discrete events: sleep sessions, activities, menstrual cycle summaries)

