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.
  • App requests are performed on behalf of the athlete. A day, week, or athlete account lock returns 403 plan_locked.
  • Lists contain published plans only. An app can retrieve its own draft by the identifier returned when the draft was created.

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

Default limits are 10 requests per second, 1,000 per hour, and 10,000 per day. Write operations (POST, PUT, DELETE) are limited to 20% of each value. X-UserLimit reports the connected-athlete limit, not a request quota. Retry-After is returned only with a 429 response. Limits may be configured per app.

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

The API uses Authorization Code Flow with PKCE S256. Only confidential clients with a client_secret are supported. Access tokens expire after 1 hour. Refresh tokens expire 180 days after their most recent use. Each refresh returns a new refresh token and immediately revokes the previous one.

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_uriyesMust exactly match an allow-listed URI. HTTPS is required; HTTP is allowed only for localhost apps in development mode.
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:readCompleted-workout details and FIT files within the configured rolling window (30 days by default). GPS tracks and metric series are excluded.
workouts:trackWorkout GPS tracks and metric series. Used only together with workouts:read. GET …/track without this scope returns 403 insufficient_scope.
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.
offlineSystem scope for refresh-token issuance. The server adds it to the app authorization automatically.

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

Revokes the athlete’s app connection. All connection tokens become invalid, and the athlete no longer counts toward the connection limit.

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 contains the summary fields available from the workout source. Requesting a workout outside the available window by identifier returns 404. GPS tracks and metric series are available through 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

Returns the original FIT file or a FIT file generated by PaceTrack for a workout recorded on a phone or Apple Watch. If no file is available, the endpoint returns 404 no_fit.

Bearer · workouts:read

Response

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

<binary FIT>

has_fit is true only when a FIT file is available in storage. For a workout without a FIT file, use GET …/track to retrieve available GPS data and metric series.

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

Returns the workout GPS track and available metric series. The endpoint uses the same rolling window as other workout requests; a request outside that window returns 404. When GPS data is unavailable, the response is 200 with has_gps: false and track: []. The workouts:read and workouts:track scopes are required; insufficient permissions return 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 uses ISO 8601 in UTC (Z). Polar timestamps without a time zone are converted to UTC using start_time_utc_offset in minutes. timezone contains an IANA time zone or null when unavailable. The keys in series depend on the source; arrays in the example are truncated. is_treadmill is determined for running, walking, and hiking workouts without GPS. In v1, source_url is populated only for Strava.

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

The subscription is registered for the app using client credentials. A webhook contains event metadata and a data_url for retrieving the resource. Events are sent only for athletes with an active OAuth grant and within the granted scopes. plan.* events are not sent for drafts. The URL must use HTTPS and cannot be an IP address.

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 plan day was created
plan.updatedA published card or structure changed
plan.publishedA draft was published
plan.deletedA published day was deleted
workout.createdA completed workout was created
workout.updatedCompleted-workout data changed
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

Webhook request body

{
    "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

The receiver must return a 2xx response within 5 seconds. On failure, PaceTrack retries after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours. Actual delivery may be delayed by up to 6 minutes because of the queue-processing interval. After all attempts are exhausted, the subscription status becomes failing.

URL verification

For verification, PaceTrack sends a POST request to the registered URL with the X-PaceTrack-Webhook-Verification: 1 header. The receiver must return HTTP 2xx and JSON containing the original challenge value unchanged. The request is signed in the same way as a regular event.

{
    "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
unauthorized401Bearer token is missing, expired, or revoked
invalid_client401/403Invalid client_secret or inactive app
invalid_grant400Invalid authorization code, PKCE verifier, or refresh token
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