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
| Query | Description |
|---|
| from | YYYY-MM-DD lower bound on plan_date |
| to | YYYY-MM-DD upper bound |
| limit | 1–200, default 50 |
| after | Cursor: last item ptp_… |
| updated_after | DATETIME. Sorts by updated_at ASC, id ASC. |
| created_after | DATETIME 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
| Field | Type | Description |
|---|
| date | YYYY-MM-DD | Day date. Required on POST (alias plan_date). |
| sport | string | Sport code: running, cycling. Default running. |
| title | string | Up to 255 characters. |
| description | HTML | Quill HTML, sanitized. Empty becomes a placeholder paragraph. |
| distance_km | number | Planned distance. |
| duration_s | int | Planned duration, seconds. |
| structured_workout | object|null | Canonical structure JSON. On PUT, null clears it. PUT /structure accepts the object or {structured_workout}. |
| expected_version | int | CAS 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
| Field | Type | Description |
|---|
| date | YYYY-MM-DD | Day date. Required on POST (alias plan_date). |
| sport | string | Sport code: running, cycling. Default running. |
| title | string | Up to 255 characters. |
| description | HTML | Quill HTML, sanitized. Empty becomes a placeholder paragraph. |
| distance_km | number | Planned distance. |
| duration_s | int | Planned duration, seconds. |
| structured_workout | object|null | Canonical structure JSON. On PUT, null clears it. PUT /structure accepts the object or {structured_workout}. |
| expected_version | int | CAS 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
| Query | Description |
|---|
| from | YYYY-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. |
| to | YYYY-MM-DD or DATETIME, upper bound on start_time. A date with no time is inclusive through end of day. |
| limit | 1–200, default 50 |
| after | Cursor ptw_… |
| updated_after | DATETIME 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
| Query | Description |
|---|
| from | YYYY-MM-DD. Default is the 30-day cutoff. |
| to | YYYY-MM-DD |
| limit | 1–400, default 50 |
| after | Cursor ptwl_… |
| updated_after | DATETIME. 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.
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
| Event | When |
|---|
| plan.created | A published day was created (drafts are hidden) |
| plan.updated | A published card or structure changed |
| plan.published | A draft was published |
| plan.deleted | A published day was deleted |
| workout.created | A completed workout appeared |
| workout.updated | Workout changed (reparse, edit, re-sync) |
| workout.deleted | Workout deleted |
| wellness.updated | A wellness row was created or recomputed |
| connection.revoked | Athlete, partner, or admin disconnected the app |
| webhook.verify | Handshake only, not a subscription event |
| webhook.test | Manual 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_…"
}