PaceTrack Partner API — PaceTrack

Developers

RU|EN|ES

PaceTrack Partner API

Public API for third-party apps: athlete OAuth consent, completed workouts and wellness, read and write structured plans. Canonical host is pacetrack.ru. English is the canonical language of this spec.

Developer console

Conventions

  • API base: https://pacetrack.ru/api/partner/v1
  • JSON UTF-8, header Content-Type: application/json.
  • Opaque public ids: athlete ptu_, plan ptp_, workout ptw_, wellness ptwl_, app ptapp_, access pta_, refresh ptr_.
  • User calls: header Authorization: Bearer pta_….
  • App webhook endpoints: HTTP Basic client_id:client_secret or the same fields in JSON/form body.
  • CORS is off: server-to-server only, not from a browser.
  • The partner acts as the athlete. A locked day/week/athlete returns 403 plan_locked.
  • Drafts are omitted from lists. Your own draft is GET-by-id from the POST response only.

Response headers (every API and /oauth/token call)

X-RateLimit-Limit-Second: 10
X-RateLimit-Remaining-Second: 7
X-RateLimit-Limit-Hour: 1000
X-RateLimit-Remaining-Hour: 812
X-RateLimit-Limit-Day: 10000
X-RateLimit-Remaining-Day: 6401
X-UserLimit-Limit: 50
X-UserLimit-Remaining: 12
Retry-After: 12

Defaults: 10/s, 1,000/h, 10,000/day. Writes (POST/PUT/DELETE) use 20% of those windows. X-UserLimit is connected athletes, not request quota. Retry-After is only on 429. A superadmin can raise per-app limits.

List pagination: limit (plans/workouts 1–200, default 50; wellness 1–400, default 50) and cursor after = id of the last item from the previous page. The response includes has_more. Deep offsets are not supported.

OAuth

Authorization Code + PKCE S256. Confidential clients only (client_secret). Access tokens last 1 hour; refresh tokens last 180 days and slide on use. Each refresh rotates the refresh token; the old one is invalid immediately. offline is always granted and is not a separate checkbox.

GET https://pacetrack.ru/oauth/authorize

Browser consent screen. Success redirects to redirect_uri with code and state. Denial puts error in the query.

ParamReq.Description
client_idyesApp public id, ptapp_…
redirect_uriyesExact match against the allow-list. https only (localhost if SA enabled is_dev).
response_typeyescode
scopeyesSpace-separated. profile is required. plans:publish only together with plans:write.
stateyesEchoed on redirect. The partner must verify it.
code_challengeyesBASE64URL(SHA256(code_verifier))
code_challenge_methodyesS256
https://pacetrack.ru/oauth/authorize?client_id=ptapp_…&redirect_uri=https://example.com/cb&response_type=code&scope=profile%20plans:read%20plans:write&state=…&code_challenge=…&code_challenge_method=S256

POST https://pacetrack.ru/oauth/token

Body: application/x-www-form-urlencoded or JSON. Authenticate with HTTP Basic or client_id / client_secret fields.

grant_type=authorization_code

FieldDescription
codeOne-time code from authorize
redirect_uriSame URI as authorize
code_verifierPKCE secret that produced the challenge

grant_type=refresh_token

FieldDescription
refresh_tokenCurrent ptr_…

Response 200

{
    "access_token": "pta_…",
    "refresh_token": "ptr_…",
    "expires_in": 3600,
    "scope": "profile plans:read plans:write",
    "token_type": "Bearer"
}

Scopes

ScopeGrants
profileDisplay name, opaque id, locale, sports. No email.
workouts:readWorkout card and FIT, rolling 30 days (SA may raise the window). No GPS or series.
workouts:trackGPS track and sensor series. Separate from workouts:read: an existing grant does not include it. Without it GET …/track → 403 insufficient_scope. Like wellness, an extra consent checkbox.
plans:readPublished plans and structure. Drafts are omitted from lists.
plans:writeCreate and edit your own drafts.
plans:publishPublish a draft and edit/delete an unlocked published day. Otherwise 403 publish_required.
wellness:readDaily sleep/readiness/load, same 30-day window.
offlineRefresh token. Always granted, not a checkbox.

GET/api/partner/v1/me

Profile of the consenting athlete. Email is never returned.

Bearer · profile

Response

{
    "id": "ptu_…",
    "name": "Alex Runner",
    "locale": "ru",
    "sports": [
        "running",
        "cycling"
    ]
}

sports are codes from published plans, workouts, and calendar preference.

POST/api/partner/v1/connection/revoke

The partner disconnects the athlete: tokens revoked, user slot freed.

Bearer · any valid token

Response

{
    "ok": true
}

GET/api/partner/v1/plans

Published plan days. Drafts are excluded. Default sort: plan_date ASC, id ASC.

Bearer · plans:read

QueryDescription
fromYYYY-MM-DD lower bound on plan_date
toYYYY-MM-DD upper bound
limit1–200, default 50
afterCursor: last item ptp_…
updated_afterDATETIME. Sorts by updated_at ASC, id ASC.
created_afterDATETIME filter on created_at

Response

{
    "plans": [
        {
            "id": "ptp_…",
            "date": "2026-08-25",
            "sport": "running",
            "title": "Tempo 8k",
            "description": "<p>Easy warmup, then tempo.</p>",
            "distance_km": 10,
            "duration_s": 3600,
            "is_draft": false,
            "is_locked": false,
            "structured_workout": null,
            "updated_at": "2026-08-25 12:01:03",
            "version": 3,
            "partner_app_id": "ptapp_…"
        }
    ],
    "has_more": false
}

POST/api/partner/v1/plans

Always creates a draft (is_draft: true). HTTP 201.

Bearer · plans:write

Body

FieldTypeDescription
dateYYYY-MM-DDDay date. Required on POST (alias plan_date).
sportstringSport code: running, cycling. Default running.
titlestringUp to 255 characters.
descriptionHTMLQuill HTML, sanitized. Empty becomes a placeholder paragraph.
distance_kmnumberPlanned distance.
duration_sintPlanned duration, seconds.
structured_workoutobject|nullCanonical structure JSON. On PUT, null clears it. PUT /structure accepts the object or {structured_workout}.
expected_versionintCAS if If-Match is absent. A timestamp / expected_updated_at → 400.

Response

{
    "id": "ptp_…",
    "date": "2026-08-25",
    "sport": "running",
    "title": "Tempo 8k",
    "description": "<p>Easy warmup, then tempo.</p>",
    "distance_km": 10,
    "duration_s": 3600,
    "is_draft": false,
    "is_locked": false,
    "structured_workout": null,
    "updated_at": "2026-08-25 12:01:03",
    "version": 3,
    "partner_app_id": "ptapp_…"
}

Idempotency: Idempotency-Key header (or body field). Reuse within 24h returns the same plan, not a duplicate. Publishing is only POST …/publish — is_draft in the body is ignored.

GET/api/partner/v1/plans/{id}

One published day, or this app’s own draft by id from POST. Anyone else’s draft → 404.

Bearer · plans:read

Response

{
    "id": "ptp_…",
    "date": "2026-08-25",
    "sport": "running",
    "title": "Tempo 8k",
    "description": "<p>Easy warmup, then tempo.</p>",
    "distance_km": 10,
    "duration_s": 3600,
    "is_draft": false,
    "is_locked": false,
    "structured_workout": null,
    "updated_at": "2026-08-25 12:01:03",
    "version": 3,
    "partner_app_id": "ptapp_…"
}

version is the integer CAS. updated_at is informational at second precision, not an etag. partner_app_id is the source app public id or null.

PUT/api/partner/v1/plans/{id}

Replace the card and/or structure. Own draft needs write only. A published day also needs plans:publish.

Bearer · plans:write

Body

FieldTypeDescription
dateYYYY-MM-DDDay date. Required on POST (alias plan_date).
sportstringSport code: running, cycling. Default running.
titlestringUp to 255 characters.
descriptionHTMLQuill HTML, sanitized. Empty becomes a placeholder paragraph.
distance_kmnumberPlanned distance.
duration_sintPlanned duration, seconds.
structured_workoutobject|nullCanonical structure JSON. On PUT, null clears it. PUT /structure accepts the object or {structured_workout}.
expected_versionintCAS if If-Match is absent. A timestamp / expected_updated_at → 400.

Response

{
    "id": "ptp_…",
    "date": "2026-08-25",
    "sport": "running",
    "title": "Tempo 8k",
    "description": "<p>Easy warmup, then tempo.</p>",
    "distance_km": 10,
    "duration_s": 3600,
    "is_draft": false,
    "is_locked": false,
    "structured_workout": null,
    "updated_at": "2026-08-25 12:01:03",
    "version": 3,
    "partner_app_id": "ptapp_…"
}

Optimistic locking: If-Match: <version> or body expected_version. Mismatch → 409 (body includes current version and updated_at). A timestamp If-Match → 400.

DELETE/api/partner/v1/plans/{id}

Delete your draft. A published day needs write + publish. If-Match / expected_version accepted.

Bearer · plans:write

Response

{
    "ok": true
}

PUT/api/partner/v1/plans/{id}/structure

Structure only. Body is the structured_workout object or {"structured_workout": …}. Validator errors → 400 invalid_structure with errors.

Bearer · plans:write

Body

{
    "schema": 1,
    "sport": "running",
    "steps": [
        {
            "id": "wu-1",
            "kind": "step",
            "phase": "warmup",
            "end": {
                "type": "time",
                "seconds": 600
            },
            "target": {
                "type": "none"
            }
        },
        {
            "id": "rep-1",
            "kind": "repeat",
            "iterations": 6,
            "steps": [
                {
                    "id": "w-1",
                    "kind": "step",
                    "phase": "interval",
                    "end": {
                        "type": "distance",
                        "meters": 1000
                    },
                    "target": {
                        "type": "hr_zone",
                        "zone": 4
                    }
                },
                {
                    "id": "r-1",
                    "kind": "step",
                    "phase": "recovery",
                    "end": {
                        "type": "time",
                        "seconds": 90
                    },
                    "target": {
                        "type": "none"
                    }
                }
            ]
        },
        {
            "id": "cd-1",
            "kind": "step",
            "phase": "cooldown",
            "end": {
                "type": "time",
                "seconds": 600
            },
            "target": {
                "type": "hr_zone",
                "zone": 2
            }
        }
    ]
}

Response

{
    "id": "ptp_…",
    "date": "2026-08-25",
    "sport": "running",
    "title": "Tempo 8k",
    "description": "<p>Easy warmup, then tempo.</p>",
    "distance_km": 10,
    "duration_s": 3600,
    "is_draft": false,
    "is_locked": false,
    "structured_workout": null,
    "updated_at": "2026-08-25 12:01:03",
    "version": 3,
    "partner_app_id": "ptapp_…"
}

DELETE /plans/{id}/structure — clears structure, the day remains. Same rights and CAS.

POST/api/partner/v1/plans/{id}/publish

Clears is_draft. Repeat on an already published day is idempotent. Lock → 403 plan_locked.

Bearer · plans:write + plans:publish

Response

{
    "id": "ptp_…",
    "date": "2026-08-25",
    "sport": "running",
    "title": "Tempo 8k",
    "description": "<p>Easy warmup, then tempo.</p>",
    "distance_km": 10,
    "duration_s": 3600,
    "is_draft": false,
    "is_locked": false,
    "structured_workout": null,
    "updated_at": "2026-08-25 12:01:03",
    "version": 3,
    "partner_app_id": "ptapp_…"
}

Writing structure does not push to Garmin/Wahoo. Device delivery is an athlete action in PaceTrack.

GET/api/partner/v1/workouts

Completed workouts inside the rolling window (default 30 days from now, app timezone).

Bearer · workouts:read

QueryDescription
fromYYYY-MM-DD or DATETIME, window start on start_time. Default is the 30-day cutoff. Older than the window → 400 lookback_exceeded. Non-ISO format → 400 invalid_request.
toYYYY-MM-DD or DATETIME, upper bound on start_time. A date with no time is inclusive through end of day.
limit1–200, default 50
afterCursor ptw_…
updated_afterDATETIME on updated_at (else uploaded_at). ASC sort; otherwise start_time DESC.

Response

{
    "workouts": [
        {
            "id": "ptw_…",
            "source": "garmin",
            "sport": "running",
            "sport_label": "Run",
            "start_time": "2026-08-24 07:12:00",
            "distance_m": 10012.4,
            "duration_s": 3120,
            "avg_heart_rate": 148,
            "has_fit": true,
            "summary": {
                "distance_m": 10012.4,
                "duration_s": 3120,
                "avg_hr": 148
            },
            "updated_at": "2026-08-24 08:01:11"
        }
    ],
    "has_more": false
}

summary comes from the workout parse_data (fields vary by source). A workout older than the window → 404 by id. GPS and series — GET …/track.

GET/api/partner/v1/workouts/{id}

One workout, same JSON as a list item.

Bearer · workouts:read

Response

{
    "id": "ptw_…",
    "source": "garmin",
    "sport": "running",
    "sport_label": "Run",
    "start_time": "2026-08-24 07:12:00",
    "distance_m": 10012.4,
    "duration_s": 3120,
    "avg_heart_rate": 148,
    "has_fit": true,
    "summary": {
        "distance_m": 10012.4,
        "duration_s": 3120,
        "avg_hr": 148
    },
    "updated_at": "2026-08-24 08:01:11"
}

GPS and metric series are a separate GET …/track, not on this card.

GET/api/partner/v1/workouts/{id}/fit

FIT bytes: the stored original, or a PaceTrack-synthesized file for phone / Apple Watch recordings. Otherwise 404 no_fit (manual entry without a track, or encoder down).

Bearer · workouts:read

Response

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="workout.fit"

<binary FIT>

Synthetic FIT is written in the background (cron) and on this GET. If the encoder is down — 404 no_fit, not 500. Workout JSON has_fit is true only after a file is stored. Strava activities in PaceTrack have no FIT — use GET …/track.

GET/api/partner/v1/workouts/{id}/track

GPS track and every metric series from parse_data. List and workout card omit these fields. Same 30-day window: older → 404. Empty track → 200 with has_gps: false and track: []. Missing workouts:track → 403 insufficient_scope.

Bearer · workouts:read + workouts:track

Response

{
    "id": "ptw_…",
    "source": "strava",
    "source_activity_id": "19921142026",
    "source_url": "https://www.strava.com/activities/19921142026",
    "sport": "running",
    "start_time": "2026-08-27T11:07:49Z",
    "timezone": "Europe/Moscow",
    "distance_m": 3408.8,
    "duration_s": 1129,
    "moving_time_s": 1129,
    "avg_heart_rate": 122,
    "max_heart_rate": 139,
    "elevation_gain_m": 19,
    "elevation_loss_m": 12,
    "has_gps": true,
    "is_manual": false,
    "is_indoor": false,
    "is_treadmill": false,
    "track": [
        {
            "time_s": 0,
            "moving_time_s": 0,
            "distance_m": 0.6,
            "lat": 59.212355,
            "lon": 37.101288
        }
    ],
    "series": {
        "time_s": [
            0,
            1,
            2
        ],
        "moving_time_s": [
            0,
            1,
            2
        ],
        "distance_m": [
            0.6,
            3.1,
            5.8
        ],
        "heart_rate": [
            110,
            118,
            122
        ],
        "altitude": [
            118.4,
            118.6,
            118.5
        ],
        "cadence": [
            160,
            164,
            166
        ],
        "power": [
            null,
            null,
            null
        ],
        "speed_kmh": [
            8.4,
            9.1,
            9
        ],
        "pace_sec_per_km": [
            428,
            396,
            400
        ],
        "temperature": [
            18,
            18,
            18
        ]
    }
}

start_time here is ISO-8601 UTC (Z); the workout card format is unchanged. A naive Polar start_time without a zone is converted with start_time_utc_offset (minutes from UTC). timezone is the IANA zone from cache on rounded GPS (this GET does not call Open-Meteo); cache miss → null. series returns every source key; arrays in the example are truncated. is_treadmill is a heuristic: running/walking/hiking with no GPS. source_url in v1 is Strava only. An existing workouts:read grant does not unlock this endpoint: the athlete must re-consent with workouts:track.

GET/api/partner/v1/wellness

Daily wellness rows. Multiple sources per date are possible (different source).

Bearer · wellness:read

QueryDescription
fromYYYY-MM-DD. Default is the 30-day cutoff.
toYYYY-MM-DD
limit1–400, default 50
afterCursor ptwl_…
updated_afterDATETIME. Sorts updated_at ASC; otherwise metric_date DESC.

Response

{
    "wellness": [
        {
            "id": "ptwl_…",
            "date": "2026-08-24",
            "source": "garmin",
            "readiness_score": 78,
            "readiness_state": "productive",
            "hrv_ms": 62.4,
            "resting_hr": 48,
            "sleep_score": 81,
            "sleep_state": "good",
            "sleep_duration_s": 27600,
            "sleep_efficiency_pct": 91,
            "load_score": 12.3,
            "load_state": "high",
            "updated_at": "2026-08-24 09:00:00"
        }
    ],
    "has_more": false
}

Numeric fields may be null if the source omitted them. source is garmin, polar, whoop, intervals, etc.

structured_workout

The only structure format is PaceTrack JSON. Not Garmin XML, ZWO, or Intervals.icu. Running: schema: 1. Cycling: schema: 2 and optional environment (indoor / outdoor). schema is required. Max 50 executable steps after expanding repeats; repeat nesting is 1 level; iterations 2–99.

{
    "schema": 1,
    "sport": "running",
    "steps": [
        {
            "id": "wu-1",
            "kind": "step",
            "phase": "warmup",
            "end": {
                "type": "time",
                "seconds": 600
            },
            "target": {
                "type": "none"
            }
        },
        {
            "id": "rep-1",
            "kind": "repeat",
            "iterations": 6,
            "steps": [
                {
                    "id": "w-1",
                    "kind": "step",
                    "phase": "interval",
                    "end": {
                        "type": "distance",
                        "meters": 1000
                    },
                    "target": {
                        "type": "hr_zone",
                        "zone": 4
                    }
                },
                {
                    "id": "r-1",
                    "kind": "step",
                    "phase": "recovery",
                    "end": {
                        "type": "time",
                        "seconds": 90
                    },
                    "target": {
                        "type": "none"
                    }
                }
            ]
        },
        {
            "id": "cd-1",
            "kind": "step",
            "phase": "cooldown",
            "end": {
                "type": "time",
                "seconds": 600
            },
            "target": {
                "type": "hr_zone",
                "zone": 2
            }
        }
    ]
}
FieldValues
kindstep | repeat
phasewarmup | interval | recovery | cooldown
end.typetime (seconds) | distance (meters) | lap
target.typenone | hr_zone (1–5) | hr (min_bpm, max_bpm) | pace_zone | pace (slow_s_per_km, fast_s_per_km). Cycling schema 2 also accepts power/cadence targets.

Webhooks

Registered on the app (client credentials), not the athlete token. Events are a thin envelope: GET the object from data_url. Events only for athletes with an active OAuth grant and only for granted scopes. plan.* is not sent for drafts. HTTPS URL, not an IP; SSRF filtered.

PUT/api/partner/v1/apps/webhook

Register or replace the URL and event list. Runs challenge-response immediately.

HTTP Basic client_id:client_secret

Body

{
    "url": "https://example.com/hooks/pacetrack",
    "events": [
        "plan.updated",
        "workout.created",
        "wellness.updated"
    ]
}

Response

{
    "ok": true,
    "verified": true,
    "events": [
        "plan.updated",
        "workout.created",
        "wellness.updated"
    ]
}

URL alias: webhook_url. If events is omitted, the full v1 set is used. POST on the same path equals PUT. Failed verification → 400, no events.

GET/api/partner/v1/apps/webhook

Current subscription.

HTTP Basic

Response

{
    "url": "https://example.com/hooks/pacetrack",
    "verified": true,
    "verified_at": "2026-08-25 10:00:00",
    "status": "verified",
    "error": null,
    "events": [
        "plan.updated",
        "workout.created"
    ]
}

status: none | unverified | verified | failing.

DELETE/api/partner/v1/apps/webhook

Remove the subscription.

HTTP Basic

Response

{
    "ok": true
}

POST/api/partner/v1/apps/webhook/verify

Re-run challenge-response on the saved URL.

HTTP Basic

Response

{
    "ok": true,
    "verified": true
}

POST/api/partner/v1/apps/webhook/test

Test ping only if the URL is already verified. Otherwise 400 webhook_unverified.

HTTP Basic

Response

{
    "ok": true
}

v1 events

EventWhen
plan.createdA published day was created (drafts are hidden)
plan.updatedA published card or structure changed
plan.publishedA draft was published
plan.deletedA published day was deleted
workout.createdA completed workout appeared
workout.updatedWorkout changed (reparse, edit, re-sync)
workout.deletedWorkout deleted
wellness.updatedA wellness row was created or recomputed
connection.revokedAthlete, partner, or admin disconnected the app
webhook.verifyHandshake only, not a subscription event
webhook.testManual test ping

Envelope we POST to your URL

{
    "spec_version": "1",
    "event": "plan.updated",
    "event_id": "a1b2c3…",
    "occurred_at": "2026-08-24T12:01:03+03:00",
    "app_id": "ptapp_…",
    "user_id": "ptu_…",
    "resource_type": "plan",
    "resource_id": "ptp_…",
    "data_url": "https://pacetrack.ru/api/partner/v1/plans/ptp_…"
}
  • X-PaceTrack-Signature: sha256=<hex> — HMAC-SHA256 of the raw body with webhook_signing_secret
  • X-PaceTrack-Delivery-Id — delivery attempt id (dedupe)
  • User-Agent: PaceTrack-Webhook/1.0

Respond 2xx within ~5s. Otherwise retries: 1m, 5m, 30m, 2h, 12h (the worker polls about every 6 minutes). After a streak of failures the subscription status is failing.

URL verification

We POST to the same endpoint. Success: HTTP 2xx and JSON whose challenge matches byte-for-byte. Header X-PaceTrack-Webhook-Verification: 1. Signature is the same as normal events.

{
    "spec_version": "1",
    "event": "webhook.verify",
    "challenge": "<random>",
    "app_id": "ptapp_…"
}

Errors

{
    "error": "conflict",
    "message": "Plan was modified",
    "request_id": "1b408196ec2882df",
    "updated_at": "2026-08-25 12:01:03",
    "version": 4
}
errorHTTPWhen
unauthorized401Missing, expired, or revoked Bearer
invalid_client401/403Bad secret or app not active
invalid_grant400Bad code / PKCE / refresh
invalid_request400Bad parameters, including a timestamp If-Match
invalid_structure400Structure validator, errors field
lookback_exceeded400from older than the history window
webhook_unverified400test ping before a successful verify
not_found404Missing resource or someone else’s draft
no_fit404No FIT file, encoder unavailable, or manual entry without a track
insufficient_scope403Missing scope, including workouts:track on GET …/track
plan_locked403Day, week, or athlete lock
publish_required403Missing plans:publish on a published day
user_limit_exceeded403App connected-user cap
conflict409version mismatch
rate_limited429Request quota or token anti-bruteforce
method405Wrong method for this path
not_ready503Partner API schema not migrated