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.
{
  "sleeps": [{"userId": "garmin-uid", "callbackURL": "https://..."}],
  "dailies": ["..."],
  "activities": ["..."]
}
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
ConstantValuePurpose
BACKFILL_DATA_TYPES5 typesTypes included in backfill (sleeps, dailies, activities, activityDetails, hrv)
ALL_DATA_TYPES16 typesAll types handled by webhooks
BACKFILL_WINDOW_COUNT1Single 30-day window (Garmin’s max allowed range)
BACKFILL_CHUNK_DAYS30Days per window (Garmin API limit)
MAX_BACKFILL_DAYS30Total lookback (Garmin only allows 30 days before the user connected to the developer app)
TRIGGERED_TIMEOUT_SECONDS3005-min timeout per type before skipping
DELAY_BETWEEN_TYPES2Seconds between type requests (rate limit budget)
BACKFILL_LOCK_TTL5100~1.4-hour lock TTL (safety net)
REDIS_TTL6048007-day TTL for all tracking keys
GC_STUCK_THRESHOLD_SECONDS60010 minutes of no activity = stuck
GC_SCAN_INTERVAL_SECONDS180GC runs every 3 minutes
GC_MAX_ATTEMPTS3Max GC cycles before permanently failed
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):
Window 0:  [now - 30d,  now]
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

KeyTypePurpose
:lockstringExclusive backfill lock (SETNX, ~1.4h TTL)
:trace_idstringSession-level trace ID for log correlation
:cancel_flagstringSet to “1” to request graceful cancellation
:permanently_failedstringSet to “1” after 3 GC cycles
:attempt_countintegerGC attempt counter (INCR)

Window tracking

KeyTypePurpose
:window:currentintegerCurrent window index (0-based)
:window:totalintegerTotal windows (1)
:window:anchor_tsISO timestampFixed reference for date range calculations
:window:completed_countintegerNumber of completed windows

Per-type flat keys (current window)

KeyTypeValues
:types:{type}:statusstringpending, triggered, success, timed_out, failed
:types:{type}:triggered_atISO timestampWhen the backfill request was sent
:types:{type}:completed_atISO timestampWhen webhook data arrived
:types:{type}:errorstringError message if failed
:types:{type}:trace_idstringPer-type trace ID
:types:{type}:skip_countintegerNumber of timeouts for this type

Per-window matrix keys (persisted history)

KeyTypeValues
:w:{window}:{type}:statusstringdone, pending, timed_out, failed
These are written by persist_window_results() when advancing to the next window, and by update_window_cell() during retry.

Retry phase keys

KeyTypePurpose
:retry_phasestring”1” when retry phase is active
:retry_targetsJSON listRemaining [{type, window}] entries to retry
:retry_current_windowintegerWindow index of the current retry target
:retry_current_typestringData type being retried

Timeout tracking

KeyTypePurpose
:timed_out_typesJSON listAll timed-out entries [{type, window}] for retry

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": "in_progress",
  "current_window": 0,
  "total_windows": 1,
  "windows": {
    "0": {"sleeps": "done", "dailies": "done", "activities": "timed_out", "activityDetails": "done", "hrv": "pending"}
  },
  "summary": {
    "sleeps": {"done": 1, "timed_out": 0, "failed": 0},
    "dailies": {"done": 1, "timed_out": 0, "failed": 0},
    "activities": {"done": 0, "timed_out": 1, "failed": 0},
    "activityDetails": {"done": 1, "timed_out": 0, "failed": 0},
    "hrv": {"done": 0, "timed_out": 0, "failed": 0}
  },
  "in_progress": true,
  "retry_phase": false,
  "retry_type": null,
  "retry_window": null,
  "attempt_count": 0,
  "max_attempts": 3,
  "permanently_failed": false
}
overall_status values:
ValueMeaning
pendingNo backfill started
in_progressActively processing windows
retry_in_progressIn retry phase
completeAll windows processed
cancelledUser-initiated cancellation
permanently_failedFailed after 3 GC cycles

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
interface BackfillWindowStatus {
  [dataType: string]: 'done' | 'pending' | 'timed_out' | 'failed';
}

interface BackfillTypeSummary {
  done: number;
  timed_out: number;
  failed: number;
}

interface GarminBackfillStatus {
  overall_status: 'pending' | 'in_progress' | 'complete'
    | 'cancelled' | 'retry_in_progress' | 'permanently_failed';
  current_window: number;
  total_windows: number;
  windows: Record<string, BackfillWindowStatus>;
  summary: Record<string, BackfillTypeSummary>;
  in_progress: boolean;
  retry_phase: boolean;
  retry_type: string | null;
  retry_window: number | null;
  attempt_count: number;
  max_attempts: number;
  permanently_failed: boolean;
}

React Hooks

File: frontend/src/hooks/api/use-health.ts
HookPurposePolling
useGarminBackfillStatus(userId, enabled)Fetches backfill statusEvery 10s while in_progress or retry_in_progress
useGarminCancelBackfill(userId)Cancel mutationInvalidates status cache on success
useRetryGarminBackfill(userId)Retry single typeInvalidates status cache on success

API Service

File: frontend/src/lib/api/services/health.service.ts
MethodEndpoint
getGarminBackfillStatus(userId)GET /api/v1/providers/garmin/users/{userId}/backfill/status
cancelGarminBackfill(userId)POST /api/v1/providers/garmin/users/{userId}/backfill/cancel
retryGarminBackfill(userId, typeName)POST /api/v1/providers/garmin/users/{userId}/backfill/{typeName}/retry

Connection Card UI

File: frontend/src/components/user/connection-card.tsx
The Garmin connection card renders different states:
StateDisplay
in_progressProgress bar with backfill status, cancel button
retry_in_progress”Retrying {Type} (window N)…” with spinner
completeCompletion indicator
cancelledCancelled state message
permanently_failedError message: “Backfill failed after 3 attempts. Please disconnect and reconnect your Garmin.”
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:
TypeAPI EndpointData
sleeps/wellness-api/rest/backfill/sleepsSleep sessions with stages
dailies/wellness-api/rest/backfill/dailiesDaily summaries (steps, calories, HR)
activities/wellness-api/rest/backfill/activitiesActivity/workout summaries
activityDetails/wellness-api/rest/backfill/activityDetailsDetailed activity data (laps, samples)
hrv/wellness-api/rest/backfill/hrvHeart rate variability

Webhook-only types (11)

These are not requested during backfill but are accepted when Garmin sends them via webhook: epochs, bodyComps, stressDetails, respiration, pulseOx, bloodPressures, userMetrics, skinTemp, healthSnapshot, moveiq, 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 like sleep sessions, activities)

File Reference

Backend

FilePurpose
backend/app/services/providers/garmin/strategy.pyGarminStrategy — provider entry point composing OAuth, data, and workout services
backend/app/services/providers/garmin/oauth.pyGarminOAuth — OAuth 2.0 + PKCE implementation
backend/app/services/providers/garmin/data_247.pyGarmin247Data — webhook data parsing and DB insertion for all 16 types
backend/app/services/providers/garmin/workouts.pyGarminWorkouts — activity/workout data handling
backend/app/services/providers/garmin/handlers/backfill.pyGarminBackfillService — triggers backfill requests to Garmin API
backend/app/services/providers/garmin/backfill_config.pyAll configuration constants (types, timeouts, windows, rate limits)
backend/app/integrations/celery/tasks/garmin_backfill_task.pyBackfill state machine — lock, trigger, timeout, window, retry, status
backend/app/integrations/celery/tasks/garmin_gc_task.pyGarbage collector — stuck detection and cleanup
backend/app/integrations/celery/core.pyCelery app config and beat schedule
backend/app/integrations/celery/tasks/__init__.pyRe-exports for all task functions
backend/app/api/routes/v1/garmin_webhooks.pyPING/PUSH webhook handlers
backend/app/api/routes/v1/sync_data.pyStatus, cancel, and retry API endpoints
backend/app/api/routes/v1/oauth.pyOAuth callback that triggers backfill

Frontend

FilePurpose
frontend/src/lib/api/types.tsGarminBackfillStatus, BackfillWindowStatus, BackfillTypeSummary
frontend/src/lib/api/services/health.service.tsAPI methods for status, cancel, retry
frontend/src/hooks/api/use-health.tsuseGarminBackfillStatus, useGarminCancelBackfill, useRetryGarminBackfill
frontend/src/components/user/connection-card.tsxBackfill progress rendering, cancel/retry UI