Skip to content

How sync works

The sync engine is a durable job queue inside the mirror, one worker that drains it within Strava's rate limits, a planner that turns gaps in the mirror into jobs, and a scheduler for the periodic listings. It costs no API requests to plan; only running a job does.

Rate limits

Strava enforces two pairs of limits, overall and non-upload reads, each with a 15-minute and a daily window: by default 200 / 2,000 overall and 100 / 1,000 for reads, and 400 / 4,000 and 200 / 2,000 after the self-service upgrade in the API settings (Strava's rate limits page). The 15-minute windows reset at :00, :15, :30 and :45 UTC, the daily one at midnight UTC. The allowance comes only from valid X-RateLimit-* and X-ReadRateLimit-* headers. Last header observations survive restarts through rate_sample; each pool is restored independently. Every HTTP attempt reserves known pools locally, including failures and the one explicit 401 retry. These conservative estimates reset at window boundaries and can differ from header observations; local reservations since the last header are lost on restart. Other clients' requests are unknown until another header arrives.

Each priority class may use only part of a window, so background work can never starve a person:

Class Who Share of the 15-minute window Share of the day
interactive tools and cache-first resources 98% 99.5%
incremental new activities, anything from the last week 85% 90%
backfill everything older 75% 75%

Backfill spreads what it may use over the rest of each window rather than bursting. A 429 pauses every class until the next 15-minute boundary (midnight if a daily limit is spent), or a later Retry-After time. Missing rate headers, including on streams, mean unknown accounting, not quota-free calls: known pools/reserves still apply. When a required pool has no known allowance, starts are paced to one per second per priority; this is local pacing, not an invented provider quota. strava://mirror/budget exposes observations, estimates, reserve shares, unknown pools/paths and reset/cooldown times without contacting Strava.

Single-object refreshes normally cost one request; list refreshes may fetch all upstream pages before local slicing. Use local SQL aggregates before remote reads and avoid tight-loop retries/status polling. Interactive admission waits at most 20 seconds; a stored copy may be returned with a stale reason. Creates, updates, uploads and star changes are never replayed after ambiguous transport failures/5xx/429. Reconcile with a read before retrying a lost write response.

Priorities

Lower runs first; within a priority, newer activities first.

Priority Work
5 On-demand: refreshes queued by tools and webhook events
10 New activities, and details and streams of the last week
15 Profile, zones, stats, routes, clubs, starred segments
20 Backfill: activity details and streams, newest first
21 Backfill: zones
25 Gear and route details, the weekly full listing
26 Route geometry
30 Kudos, comments, photos
35 Club details
40 Segment details
41 Segment geometry

Priorities below 10 use the interactive class, below 20 incremental, the rest backfill.

Schedule

Task Every What it does
list_recent 15 min (6 h with a webhook) Lists activities since two days before the newest stored one; catches late uploads and fresh edits
list_window day Relists the last 90 days to find edits
list_full week Lists everything; activities missing from it are marked deleted
athlete, athlete_stats 6 h Profile and totals (the totals keep a history)
routes hour (STRAVA_ROUTES_INTERVAL_MINUTES, minimum 5) Routes and their starred flag (Strava sends no webhook for starring a route); lower it when something consumes the starred flag promptly, such as garmin-mcp's course cleanup
athlete_zones, starred_segments, clubs day

After every listing the planner compares the mirror with what it should hold and queues the differences: a detail that was never fetched or whose summary changed since, streams and zones that are missing, social payloads whose count on the activity disagrees with what is stored, gear older than a week, routes updated since their last fetch, segments seen in efforts but never fetched. Planning twice adds nothing.

A failed job is retried with exponential backoff (2, 4, 8 ... minutes, capped at a day) and its error is kept in sync_job and sync_log; strava_sync_status shows both.

Freshness and provenance

Every read tool goes through the same rule: serve the stored copy if it is younger than the maximum age for its kind, fetch (at interactive priority) otherwise, and if Strava cannot be asked right now (rate limit, outage) serve the stored copy marked stale.

Data Served from the mirror while
Activity list listed within the last 15 minutes
Activity detail younger than 15 minutes for activities under two days old, 6 hours under two weeks, forever after, and refetched whenever its summary shows a change
Streams, zones, segment efforts and their streams forever (they never change)
Kudos, comments, photos until the count on the activity disagrees, hourly for a week
Athlete profile 6 hours; zones a day; stats an hour
Gear, clubs a day; segments a week
Route list, starred segments an hour
Route detail, geometry and GPX/TCX export a day, and at once when the route list reports the route updated since it was fetched

Each result ends with a line such as [served from the local mirror (fetched from Strava 2026-10-20T08:12:00Z)], [fetched from Strava just now], or [served from the local mirror ...] [stale: Strava could not be asked: ...], so the model and the reader know what they are looking at.

Writes (strava_update_activity, strava_create_activity, strava_star_segment) go to Strava first; the response is stored like any other payload, and a listing is queued so the mirror catches up with anything derived from it. Uploads (strava_create_upload, strava_get_upload) pass through to Strava without being stored, since an upload's status is transient; the activity an upload creates arrives through the listing that is queued right after it. All of them use the same interactive priority and 20-second wait cap as the reads.

Webhook

Without a public URL the server learns about new activities by listing every 15 minutes. With STRAVA_WEBHOOK_URL set to a public HTTPS URL with a secret path, it registers Strava's push subscription for the application at startup (replacing one that points elsewhere, since an application has only one), validates Strava's challenge, and from then on a created, edited or deleted activity is queued within seconds. The listing then only runs every six hours as a safety net.

The callback is authenticated by its path and by the subscription and athlete ids in each event; an event only queues a re-fetch from the API and is never stored as data, so the worst an attacker who guesses the path can do is cause a few extra requests. strava_sync_status reports the subscription state and the last event.

STRAVA_WEBHOOK_URL=https://strava.example.com/webhook/4f1c0e3b9a7d2e5c

The path after the host is yours to choose; make it long and random (openssl rand -hex 16). The host must resolve publicly and present a valid certificate, since Strava calls it.

If strava.example.com already reaches the server (or the oauth gateway) through nginx, Caddy or Traefik, route only the webhook path to port 8000 and leave the rest as it is. Caddy:

strava.example.com {
    handle /webhook/* {
        reverse_proxy strava-mcp:8000
    }
    handle {
        reverse_proxy oauth:3000
    }
}

Set MCP_ALLOWED_HOSTS=strava.example.com so the server accepts that Host.

Create a tunnel in the Cloudflare dashboard (Networking, Tunnels) with a public hostname strava.example.com pointing at http://strava-mcp:8000, add cloudflared to the stack and set the webhook URL on that hostname:

# compose.override.yaml
services:
  cloudflared:
    image: cloudflare/cloudflared:2026.10.1
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}

Everything under that hostname is then public, including /mcp (protected by the bearer token or the gateway). To expose only the webhook, give the tunnel's public hostname a path rule for /webhook/* and no catch-all.

On the host running Docker, with Funnel enabled for the tailnet:

sudo tailscale funnel --bg --https=443 --set-path=/webhook http://127.0.0.1:8000/webhook

gives https://<machine>.<tailnet>.ts.net/webhook/... a public HTTPS front for that path only. Funnel strips the mount path before proxying, which is why the target repeats /webhook: Strava's /webhook/<secret> then arrives at the server unchanged. Set STRAVA_WEBHOOK_URL=https://<machine>.<tailnet>.ts.net/webhook/<secret> and MCP_ALLOWED_HOSTS=<machine>.<tailnet>.ts.net. /mcp stays reachable only inside the tailnet (tailscale serve --bg localhost:8000 publishes it there).

Watching it

strava_sync_status returns the coverage (activities, with detail, with streams, with zones), the queue by kind with the top priority and retry counts, the last run of each periodic task, the outcomes of the last 24 hours, the last errors, the rate-limit usage of both pools, the webhook state, and any fields Strava returned that no typed column maps yet. The Grafana dashboard's sync health row shows the same over time.