gcpapi-sync
A typed Python client and command line tool for the Garmin Connect Personal API, the official API that gives you programmatic access to your own Garmin data: activities and their FIT files, sleep, heart rate, HRV, stress, Body Battery, SpO2, steps, weight, nutrition and more.
Disclaimer. This is a personal, private project. It is not affiliated with, endorsed by, or developed on behalf of my employer, Wahoo Fitness, and it is not affiliated with or endorsed by Garmin. "Garmin" and "Garmin Connect" are trademarks of Garmin Ltd. or its subsidiaries. Use of the API is subject to Garmin's terms.
Highlights
- Every endpoint, as typed methods on a sync
GarminClientand an asyncAsyncGarminClientwith identical signatures. - 109 pydantic models, generated from Garmin's OpenAPI document and corrected
where the live API contradicts it (see API quirks). No
dict, noAny: maps with known keys are models, intraday series have typed accessors. - Strictly typed (
py.typed), checked withty(every rule as an error) andmypy --strict, with no suppression comments anywhere in the code base. - Authentication handled for you: API key discovery, the 24-hour token exchange, caching (in memory or on disk, shared with Garmin's own client), renewal before expiry and after a rejected token.
- Resilient: retries with exponential backoff and jitter,
Retry-Aftersupport, and a precise exception hierarchy. - No server limits to remember: date ranges of any length are split to fit each endpoint (concurrently with the async client); searches of any size are paged.
- CLI with everyday commands (
today,activities, incremental FITsync,export) plus raw JSON for every endpoint. - Kept in sync with Garmin: a weekly job regenerates the models from Garmin's published spec and opens a pull request when anything changes.
Installation
uv add gcpapi-sync # in a uv project
pip install gcpapi-sync # with pip
uv tool install gcpapi-sync # just the gcpapi command
uvx --from gcpapi-sync gcpapi today # run the CLI without installing
Requires Python 3.11 or newer. API documentation: camminady.dev/gcpapi-sync.
Getting an API key
-
In Garmin Connect open Settings → Personal API and create a key. Garmin shows it only once; keys expire after 30 days to a year.
-
Store it:
gcpapi setup # prompts for the key (hidden), verifies it, saves it with mode 600
The client looks for the key in this order: the api_key argument, the key_file
argument, the GARMIN_PERSONAL_API_KEY environment variable, and
~/.config/garmin/connect-personal-api-key (where gcpapi setup and Garmin's own
client put it). Never commit the key; this repository's pre-commit hooks refuse to.
Quick start
from gcpapi_sync import GarminClient
with GarminClient() as garmin:
for run in garmin.search_activities(limit=5, activity_type="running"):
km = (run.distance or 0) / 1000
print(f"{run.start_time_local:%Y-%m-%d} {run.activity_name} {km:.1f} km")
sleep = garmin.get_daily_sleep("2026-10-21")
if sleep.sleep_scores and sleep.sleep_scores.overall:
print("sleep score", sleep.sleep_scores.overall.value)
steps = garmin.get_daily_steps("2026-01-01", "2026-10-21") # 14 requests, one list
The async client has the same methods as coroutines and fetches the windows of long date ranges concurrently:
import asyncio
from gcpapi_sync import AsyncGarminClient
async def main() -> None:
async with AsyncGarminClient(max_concurrency=4) as garmin:
sleep, hrv, steps = await asyncio.gather(
garmin.get_daily_sleep_summaries("2026-01-01", "2026-10-21"),
garmin.get_daily_hrv_summaries("2026-01-01", "2026-10-21"),
garmin.get_daily_steps("2026-01-01", "2026-10-21"),
)
async for activity in garmin.iter_activities(start_date="2026-10-01"):
print(activity.activity_id, activity.activity_name)
asyncio.run(main())
Usage
Endpoints
Dates are datetime.date objects or YYYY-MM-DD strings; ranges are inclusive.
| Method | Returns | Garmin's limit per request |
|---|---|---|
search_activities(start=0, limit=20, **filters) |
list[SummarizedActivity] |
~45 per page (paged for you) |
iter_activities(page_size=20, **filters) |
iterator of SummarizedActivity |
|
get_activity(activity_id) |
ActivitySummary |
|
get_activity_workouts(activity_id) |
list[ActivityWorkout] |
|
download_activity(activity_id) |
ActivityFile (original FIT/GPX/TCX) |
|
download_activity_zip(activity_id) |
bytes (Garmin's zip) |
|
get_daily_summary(day) |
UserDailySummary |
|
get_daily_summaries(start, end) |
list[UserDailySummaryV2] |
31 days (split for you) |
get_daily_sleep(day) |
DailySleep |
|
get_daily_sleep_summaries(start, end) |
list[DailySleep] |
31 days (split for you) |
get_daily_heart_rate(day) |
DailyHeartRate |
|
get_heart_rate_zones() |
list[HeartRateZones] |
|
get_daily_hrv(day) |
HrvChart | None |
|
get_daily_hrv_summaries(start, end) |
list[HrvSummary] |
365 days (split for you) |
get_daily_stress(start, end) |
list[MultiValueWellnessChartItem] |
28 days (split for you) |
get_daily_steps(start, end) |
list[StepsDailyStat] |
28 days (split for you) |
get_daily_hydration(start, end) |
list[UserDailyHydration] |
31 days (split for you) |
get_daily_body_battery(start, end, chunks=None) |
list[BodyBatteryDailyReport] |
31 days (split for you) |
get_daily_spo2(day) |
DailySpO2AndAcclimation |
|
get_fitness_age(day) |
FitnessAge |
|
get_weight_range(start, end, include_all=False) |
WeightRange (weights in grams) |
about a year |
get_weight_entries(start, end) |
list[DateWeight] |
currently broken server-side |
get_effective_weight_goals(start, end) |
UserGoalsRange |
|
get_daily_food_logs(day) |
DailyNutritionSummary |
|
get_menstrual_cycle_summary(day) |
MenstrualCycleSummary | None |
|
get_menstrual_cycle_day_view(day, today=None) |
DetailedMenstrualCycleDay | None |
|
get_wellness_activity_summaries(start, end) |
list[WellnessActivitySummary] |
|
get_user_profile_settings() |
UserSettings |
|
get_json(path, params) |
decoded JSON, for endpoints not wrapped yet |
Searching activities
search_activities and iter_activities accept every filter the API supports as
typed keyword arguments (see ActivityFilters): dates, activity and event types,
text search, favorites, distance/duration/elevation/power ranges, a geographic
BoundingBox, dive parameters, sorting and more.
from gcpapi_sync import BoundingBox
long_runs = garmin.search_activities(
limit=100,
activity_type="running",
min_distance=21_000, # meters
start_date="2026-01-01",
bounding_box=BoundingBox(north=50.8, east=7.2, south=50.6, west=7.0),
sort_by="distance",
)
for activity in garmin.iter_activities(activity_type="cycling"):
garmin.download_activity(activity.activity_id).save("~/garmin/fit")
Models
Every response is a pydantic model (gcpapi_sync.models):
- attributes are
snake_case; Garmin'scamelCasenames are the aliases (model_dump(by_alias=True)reproduces Garmin's JSON); - every field is optional, because Garmin omits and nulls fields freely;
- unknown fields are kept in
model_extra, so API additions never break parsing; - timestamps are
datetimeobjects: timezone-aware UTC for Garmin's*GMTfields and naive wall-clock time for*Localfields, whether Garmin sent ISO strings or epoch milliseconds; calendar days aredate, times of day aretime.
Intraday series come as compact rows; typed accessors resolve their columns:
heart_rate = garmin.get_daily_heart_rate("2026-10-21")
for sample in heart_rate.heart_rate_samples(): # list[HeartRateSample]
print(sample.timestamp, sample.heart_rate)
report = garmin.get_daily_body_battery("2026-10-21", "2026-10-21")[0]
levels = report.body_battery_samples() # list[BodyBatterySample]
spo2 = garmin.get_daily_spo2("2026-10-21").spo2_hourly_samples()
Errors
GarminError
├── ConfigurationError no API key found, invalid options
└── APIError
├── APIConnectionError network failure (after retries)
│ └── APITimeoutError
├── APIStatusError non-2xx response (after retries); .status_code, .body, .error_id
│ ├── BadRequestError 400
│ ├── AuthenticationError 401: key or token invalid, expired or revoked
│ ├── PermissionDeniedError 403
│ ├── NotFoundError 404
│ ├── RateLimitError 429; .retry_after
│ └── InternalServerError 5xx
└── ResponseValidationError a 2xx response that does not match the models; .raw
Invalid arguments (malformed dates, reversed ranges, unknown filters) raise
ValueError/TypeError before any request is made.
Configuration
import httpx
from gcpapi_sync import FileTokenCache, GarminClient, RetryConfig
garmin = GarminClient(
key_file="~/secrets/garmin.key",
token_cache=FileTokenCache(), # reuse tokens across processes
timeout=60,
retry=RetryConfig(max_retries=5, max_delay=30),
http_client=httpx.Client(proxy="http://proxy:3128"), # you close it
)
Implement the TokenCache protocol to keep tokens elsewhere (a keyring, Redis, ...).
Logs go to the gcpapi_sync logger (requests at DEBUG, retries at INFO); keys and
tokens are never logged or shown in repr.
Command line
gcpapi setup # store and verify your API key
gcpapi status # key source, token validity
gcpapi today # steps, HR, stress, Body Battery, sleep, HRV, activities
gcpapi today 2026-10-21 --as_json
gcpapi activities --limit 20 --activity_type running
gcpapi sync ~/garmin/fit --since 2026-01-01 # download new FIT files only
gcpapi export 2026-01-01 2026-10-21 ~/garmin/health
gcpapi get_daily_sleep 2026-10-21 # any client method, as JSON
gcpapi search_activities --limit 5 --min_distance 10000
gcpapi logout # delete the cached token
Options go after the command (gcpapi status --no_cache). gcpapi -- --help and
gcpapi <command> -- --help describe everything; python -m gcpapi_sync works too.
API quirks
The client hides a number of places where Garmin's service disagrees with its own documentation (verified against the live API in October 2026):
- Timestamps are documented as strings but several arrive as epoch milliseconds
(sleep, weight); several documented objects arrive as plain strings (
ConnectDate,ConnectTimestamp,LocalTime); the fitness-age endpoint returns an object, not the documented array; intraday series are arrays of numbers, not objects. - Fields documented as non-nullable are frequently
null, and some undocumented fields are returned; both are modeled. - Range limits differ from the documentation: daily summaries fail with HTTP 500 for ranges over roughly 90 days although a year is documented; activity searches fail with HTTP 500 for pages over about 45 activities, including the server's own default page size.
GET /weight-service/weight/all/...answers HTTP 400 for every input.- Activity downloads require
Accept: application/x-zip-compressed.
Versioning
Versions are 0.MINOR.YYYYMMDD. MINOR grows when this package changes (features,
fixes); the date records when the current state of Garmin's API was picked up and
changes only when Garmin changes something. So 0.1.20261022 is followed by
0.1.20261029 if Garmin changes its API the next week, and by 0.2.20261022 if this
package gets a new feature first. Releases are automatic: every new version merged to
main is published to PyPI.
Development
git clone git@github.com:thomascamminady/gcpapi-sync.git && cd gcpapi-sync
uv sync # environment with all dev tools
uv run pre-commit install # ruff, ty, mypy, secret scanning on commit; tests on push
uv run pytest # unit tests (no network)
uv run pytest -m live # against the real API with your key
uv run pre-commit run --all-files
uv run --group docs pdoc gcpapi_sync gcpapi_sync.exceptions gcpapi_sync.cli --docformat google
Models are generated; never edit src/gcpapi_sync/models/_generated.py by hand.
Corrections for the live API live in scripts/generate_models.py:
uv run scripts/generate_models.py # fetch Garmin's current spec and regenerate
uv run scripts/scrub_fixture.py response.json tests/fixtures/name.json # anonymize a fixture
See CONTRIBUTING.md for the full workflow and how API drift is detected.
License
Metadata
Release files for gcpapi-sync 0.1.20261022
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| gcpapi_sync-0.1.20261022.tar.gz | 99.3 kB | With provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gcpapi_sync-0.1.20261022-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 201.1 kB
Release files / gcpapi_sync-0.1.20261022.tar.gz
| Download URL | gcpapi_sync-0.1.20261022.tar.gz |
|---|---|
| Size | 99.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
6bab971af58982f5316452b60e15ae5dec66b8e7a3a53a891c3c13b325658af9
|
|
BLAKE2b-256 checksum How to use checksums |
cf15f9a10a395e70b5a18b6fa976f3a36f226aec6d51542d02710d33c6dca145
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logRelease files / gcpapi_sync-0.1.20261022-py3-none-any.whl
| Download URL | gcpapi_sync-0.1.20261022-py3-none-any.whl |
|---|---|
| Size | 101.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
281e4b2ada7d5d752cbf4465e12cf6a31c95161ed4cf96de59e183a6a2d1978e
|
|
BLAKE2b-256 checksum How to use checksums |
0b440955780b6a2053106ccb7cd5b1e6f979634dd6832429f52073024ac9a181
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logSecurity for gcpapi-sync 0.1.20261022
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for gcpapi_sync-0.1.20261022.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 22, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.