Skip to main content

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
Garmin uses OAuth 2.0 with PKCE. The flow:
  1. Frontend redirects user to GET /api/v1/oauth/{provider}/authorize
  2. User grants permissions on Garmin’s site
  3. Garmin redirects back to GET /api/v1/oauth/{provider}/callback
  4. Callback handler:
    • Creates/updates UserConnection in the database
    • Dispatches sync_vendor_data.delay() for initial sync
    • For Garmin specifically: dispatches start_garmin_full_backfill.delay(user_id)
The backfill starts automatically on connection. There is no manual “Sync Now” button for Garmin in the frontend.
If the user didn’t grant the HISTORICAL_DATA_EXPORT permission, all backfill requests will return 403. All 5 types fail together in this case.

Webhook Handlers (PING/PUSH)

File: backend/app/api/routes/v1/garmin_webhooks.py
Garmin sends two types of webhooks:

PING (callback-based)

POST /api/v1/garmin_webhooks/ping Garmin sends a notification with callback URLs. The handler fetches the actual data from those URLs.
Processing flow per notification:
  1. Extract callback URL from notification
  2. Fetch data via httpx.get(callback_url) with OAuth token
  3. Find internal user via Garmin user ID mapping
  4. Batch-process via Garmin247Data.process_items_batch()
  5. Commit to PostgreSQL
  6. If a backfill is active: call mark_type_success(user_id, data_type)
  7. 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 from triggered to success
  • Only on a new transition (returns True), trigger_next_pending_type is enqueued
  • This chains the sequential processing: request type -> await webhook -> next type
All 16 data types are handled by the webhook regardless of whether they’re included in backfill orchestration. The 5-type restriction only applies to which types are actively requested during backfill.

Backfill Orchestration

Configuration

File: backend/app/services/providers/garmin/backfill_config.py
Rate 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.py
The backfill operates as a Celery task chain with three phases:

Phase 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):
The anchor timestamp is fixed at backfill start so all date boundaries are consistent. Per-window flow:
  1. For each of the 5 types: trigger -> await webhook or timeout -> next type
  2. persist_window_results() copies flat type status keys to per-window matrix keys
  3. advance_window() increments the window counter and resets flat keys to “pending”
  4. If more windows remain, trigger the first type for the new window
Cancel support: The cancel flag is checked between types and between windows. When cancelled, the current window’s results are persisted and the backfill stops gracefully.

Retry Phase

After the window completes, the system checks for timed-out entries:
  • get_retry_targets() reads the timed_out_types JSON list from Redis
  • Deduplicates by keeping only the latest (highest) window per type
  • Each target is retried once using the same trigger_backfill_for_type infrastructure
  • The retry uses the original window’s date range (not the current sequential window)
  • The main window counter is not modified during retry
Escalation rules:
  • Webhook arrives during retry: type marked done in matrix
  • Timeout during retry: type escalated to failed (not timed_out)
  • failed is a terminal state with no further retries

Garbage Collection

File: backend/app/integrations/celery/tasks/garmin/gc_task.py
A Celery beat task runs every 3 minutes to detect and clear stuck backfills: Design choices:
  • 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)
Detection timeline: A stuck backfill is detected within ~13 minutes (10-min threshold + up to 3-min scan interval).

Redis Key Schema

All keys use the prefix garmin: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.ts

React Hooks

File: frontend/src/hooks/api/use-health.ts

API Service

File: frontend/src/lib/api/services/health.service.ts

Connection Card UI

File: frontend/src/components/user/connection-card.tsx
The Garmin connection card renders different states: 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.py
All data types are processed by Garmin247Data 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) and EventRecords (discrete events: sleep sessions, activities, menstrual cycle summaries)

File Reference

Backend

Frontend