GET/api/partner/v1/me
Профиль согласившегося атлета. Email не отдаётся.
Bearer · profile
Response
{
"id": "ptu_…",
"name": "Alex Runner",
"locale": "ru",
"sports": [
"running",
"cycling"
]
}sports — коды из опубликованных планов, фактов и предпочтения календаря.
POST/api/partner/v1/connection/revoke
Отзывает подключение атлета к приложению. Все токены подключения становятся недействительными, а атлет перестаёт учитываться в лимите подключений.
Bearer · любой валидный токен
Response
{
"ok": true
}GET/api/partner/v1/plans
Опубликованные дни плана. Черновики не входят. Сортировка по умолчанию: plan_date ASC, id ASC.
Bearer · plans:read
| Query | Описание |
|---|
| from | YYYY-MM-DD, нижняя граница plan_date |
| to | YYYY-MM-DD, верхняя граница |
| limit | 1–200, дефолт 50 |
| after | Курсор: ptp_… последнего элемента |
| updated_after | DATETIME. Меняет сортировку на updated_at ASC, id ASC. |
| created_after | DATETIME, фильтр по 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
Создаёт день всегда как черновик (is_draft: true). HTTP 201.
Bearer · plans:write
Body
| Поле | Тип | Описание |
|---|
| date | YYYY-MM-DD | Дата дня. На POST обязательна (алиас plan_date). |
| sport | string | Код вида из справочника: running, cycling, swimming, xc_ski (лыжные гонки) и другие. Дефолт running. |
| title | string | До 255 символов. |
| description | HTML | Quill HTML, санитайзится. Пустое → заглушка абзаца. |
| distance_km | number | Плановая дистанция. |
| duration_s | int | Плановая длительность, секунды. |
| structured_workout | object|null | Канонический JSON структуры. На PUT null снимает структуру. PUT /structure принимает объект или обёртку {structured_workout}. |
| expected_version | int | CAS, если нет заголовка If-Match. 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-Key (или поле тела). Повтор с тем же ключом в окне 24 ч возвращает тот же план, без дубликата. Публикация только через POST …/publish — поле is_draft в теле игнорируется.
GET/api/partner/v1/plans/{id}
Один опубликованный день, либо свой черновик этого приложения по id из POST. Чужой черновик → 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 — целочисленный CAS. updated_at информационное, с точностью до секунды, не etag. partner_app_id — публичный id приложения-источника или null.
PUT/api/partner/v1/plans/{id}
Заменить карточку и/или структуру. Свой черновик — достаточно write. Опубликованный день — ещё plans:publish.
Bearer · plans:write
Body
| Поле | Тип | Описание |
|---|
| date | YYYY-MM-DD | Дата дня. На POST обязательна (алиас plan_date). |
| sport | string | Код вида из справочника: running, cycling, swimming, xc_ski (лыжные гонки) и другие. Дефолт running. |
| title | string | До 255 символов. |
| description | HTML | Quill HTML, санитайзится. Пустое → заглушка абзаца. |
| distance_km | number | Плановая дистанция. |
| duration_s | int | Плановая длительность, секунды. |
| structured_workout | object|null | Канонический JSON структуры. На PUT null снимает структуру. PUT /structure принимает объект или обёртку {structured_workout}. |
| expected_version | int | CAS, если нет заголовка If-Match. 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_…"
}Оптимистичная блокировка: заголовок If-Match: <version> или тело expected_version. Расхождение → 409 (в теле текущие version и updated_at). Timestamp в If-Match → 400.
DELETE/api/partner/v1/plans/{id}
Удалить свой черновик. Опубликованный день — write + publish. Можно передать If-Match / expected_version.
Bearer · plans:write
Response
{
"ok": true
}PUT/api/partner/v1/plans/{id}/structure
Только структура. Тело — объект structured_workout или {"structured_workout": …}. Ошибки валидатора → 400 invalid_structure со списком 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 — снимает структуру, день остаётся. Те же права и CAS.
POST/api/partner/v1/plans/{id}/publish
Снимает is_draft. Повтор на уже опубликованном дне идемпотентен (тот же объект). Лок → 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_…"
}Запись структуры не пушит тренировку на Garmin/Wahoo. Доставка на часы — действие атлета в PaceTrack.
GET/api/partner/v1/workouts
Завершённые тренировки внутри скользящего окна (дефолт 30 дней от сейчас, таймзона приложения).
Bearer · workouts:read
| Query | Описание |
|---|
| from | YYYY-MM-DD или DATETIME, начало окна по start_time. Дефолт — отсечка 30 дней. Старше окна → 400 lookback_exceeded. Не ISO-формат → 400 invalid_request. |
| to | YYYY-MM-DD или DATETIME, верхняя граница start_time. Для даты без времени — включительно до конца дня. |
| limit | 1–200, дефолт 50 |
| after | Курсор ptw_… |
| updated_after | DATETIME по updated_at (иначе uploaded_at). Сортировка ASC; без него — start_time DESC. |
Response
{
"workouts": [
{
"id": "ptw_…",
"source": "garmin",
"source_activity_id": null,
"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",
"data_edited": false,
"data_edited_at": null,
"data_edit_version": 0
}
],
"has_more": false
}summary содержит сводные поля, доступные у источника тренировки. Запрос тренировки за пределами доступного окна по идентификатору возвращает 404. GPS-трек и ряды метрик доступны через GET …/track.
POST/api/partner/v1/workouts
Создаёт факт завершённой тренировки из JSON-summary партнёра. HTTP 201 при создании, 200 — если запрос оказался повтором (дедуп по external_id или тот же Idempotency-Key).
Bearer · workouts:write
Body
| Поле | Тип | Описание |
|---|
| sport | string | Обязательно. Код вида спорта из справочника (тот же, что у POST /v1/plans). |
| start_time | string | Обязательно. Локальное время старта, YYYY-MM-DDTHH:MM:SS. Часовой пояс в строке игнорируется. |
| duration_s | int | Обязательно, > 0, максимум 259200 (3 суток). |
| moving_time_s | int | Опционально. Не может заметно превышать duration_s. |
| distance_m | number | Опционально, >= 0. Средняя скорость (distance_m/duration_s) не должна превышать 30 м/с — это проверка перепутанных единиц, не спортивного результата. |
| avg_heart_rate / max_heart_rate | int | Опционально, 1..300. max_heart_rate не может быть меньше avg_heart_rate. |
| elevation_gain_m / elevation_loss_m | number | Опционально, >= 0. |
| external_id | string | До 100 символов. Вместе с общим source=partner_api участвует в дедупе — рекомендуем свой namespace-префикс. Настоятельно рекомендуется: без него PaceTrack синтезирует ключ из спорта/времени/длительности/дистанции, и это менее надёжно для отличения похожих тренировок. |
| track | array | Опционально, до 20000 точек. Каждая точка — объект с числовыми полями из фиксированного набора (time_s, moving_time_s, distance_m, lat, lon, altitude, heart_rate, cadence, power, speed_kmh, temperature); прочие поля точки отбрасываются. lat/lon — либо оба и в допустимом диапазоне, либо ни одного. |
| series | object | Опционально, до 20000 значений на ключ. Только известные ключи (time_s, moving_time_s, distance_m, heart_rate, altitude, cadence, power, speed_kmh, pace_sec_per_km, temperature) и только числа/null — неизвестные ключи отбрасываются. |
Response
{
"id": "ptw_…",
"source": "garmin",
"source_activity_id": null,
"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",
"data_edited": false,
"data_edited_at": null,
"data_edit_version": 0
}Принимает только наш JSON — не FIT/TCX/GPX. Не требует workouts:read. Идемпотентность: заголовок Idempotency-Key (окно 24 ч, как у POST /v1/plans) — независимо от него, повторная запись с тем же external_id тоже возвращает уже существующую тренировку, а не дубликат. Ошибки валидации — 400 invalid_request со списком errors: [{field, code}] (коды: required, invalid, unknown, too_long, too_large, too_many_points, in_future, too_old, exceeds_duration, below_avg, implausible_pace). Ваше приложение не получает собственный webhook workout.created о тренировке, которую только что создало этим запросом.
POST/api/partner/v1/workouts/fit-uploads
Ставит FIT-файл в очередь на разбор. HTTP 202 — файл принят, разбор ещё не завершён. Повтор с тем же external_id или Idempotency-Key возвращает ту же задачу вместо новой.
Bearer · workouts:write
Body
| Поле | Тип | Описание |
|---|
| file | multipart | Обязательно. Файл .fit, до 20 МБ. Только FIT — TCX/GPX/ZIP не принимаются. |
| external_id | string | Опционально, form-поле или query, до 100 символов. |
Response
{
"job_id": "ptfu_…",
"status": "pending"
}Разбор асинхронный: HTTP-ответ не ждёт py-gateway — иначе недоступный парсер держал бы воркер PHP-FPM и мог уронить весь сайт, не только партнёрский API. Заберите итог через GET …/fit-uploads/{job_id}. Явный текст/структура через этот эндпоинт не передаются — только сам FIT. Файл больше нашего лимита возвращает 400 size, а не «файл не передан».
GET/api/partner/v1/workouts/fit-uploads/{job_id}
Статус задачи разбора: pending, running, done (с готовой тренировкой) или failed (с кодом ошибки). Чужой или несуществующий job_id — 404.
Bearer · workouts:write
Response
{
"job_id": "ptfu_…",
"status": "done",
"workout": {
"id": "ptw_…",
"source": "garmin",
"source_activity_id": null,
"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",
"data_edited": false,
"data_edited_at": null,
"data_edit_version": 0
}
}
{
"job_id": "ptfu_…",
"status": "failed",
"error": {
"code": "not_fit",
"message": "FIT parse failed"
}
}Обычно результат готов в пределах минуты. Поллинг не тратит бюджет workouts:read — эта ручка требует только workouts:write.
GET/api/partner/v1/workouts/{id}
Один факт в том же JSON, что элемент списка.
Bearer · workouts:read
Response
{
"id": "ptw_…",
"source": "garmin",
"source_activity_id": null,
"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",
"data_edited": false,
"data_edited_at": null,
"data_edit_version": 0
}GPS и ряды метрик — отдельный GET …/track, в карточку не входят.
GET/api/partner/v1/workouts/{id}/fit
Возвращает исходный FIT-файл или FIT-файл, сформированный PaceTrack для тренировки, записанной на телефон или Apple Watch. Если файл недоступен, возвращает 404 no_fit.
Bearer · workouts:read
Response
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="workout.fit"
<binary FIT>
has_fit имеет значение true, только если FIT-файл доступен в хранилище. Для тренировки без FIT-файла используйте GET …/track, чтобы получить доступные GPS-данные и ряды метрик.
Файл всегда исходный: если атлет или тренер исправили данные в PaceTrack (data_edited = true), исправления видны в карточке тренировки и в GET …/track, но не в FIT-файле. data_edit_version растёт при каждой правке и её отмене.
GET/api/partner/v1/workouts/{id}/track
Возвращает GPS-трек и доступные ряды метрик тренировки. Эндпоинт использует то же скользящее окно, что и остальные запросы тренировок; запрос за его пределами возвращает 404. При отсутствии GPS ответ имеет статус 200, has_gps: false и track: []. Требуются scopes workouts:read и workouts:track; при недостаточных правах возвращается 403 insufficient_scope.
Bearer · workouts:read + workouts:track
Response
{
"id": "ptw_…",
"source": "garmin",
"source_activity_id": "17654321098",
"source_url": null,
"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 имеет формат ISO 8601 в UTC (Z). Время Polar без указанного часового пояса преобразуется в UTC с помощью start_time_utc_offset, заданного в минутах. timezone содержит часовой пояс IANA или null, если он не определён. Набор ключей series зависит от источника; массивы в примере сокращены. is_treadmill определяется для бега, ходьбы и хайкинга без GPS. В v1 поле source_url может быть null.
GET/api/partner/v1/wellness
Дневные строки wellness. Несколько источников на одну дату возможны (разные source).
Bearer · wellness:read
| Query | Описание |
|---|
| from | YYYY-MM-DD. Дефолт — отсечка 30 дней. |
| to | YYYY-MM-DD |
| limit | 1–400, дефолт 50 |
| after | Курсор ptwl_… |
| updated_after | DATETIME. Сортировка updated_at ASC; без него — 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
}Числовые поля могут быть null, если источник их не отдал. source — garmin, polar, whoop, intervals и т.д.
GET/api/partner/v1/wellbeing
Дневник самочувствия, который ведёт сам атлет. Это не wellness с часов. Область отдельная: атлет отмечает её галочкой «данные о здоровье».
Bearer · wellbeing:read
| Query | Описание |
|---|
| from | YYYY-MM-DD. Дефолт — отсечка истории приложения. |
| to | YYYY-MM-DD |
| limit | 1–120, дефолт 50 |
Response
{
"wellbeing": [
{
"id": "ptwb_example",
"date": "2026-10-02",
"sleep_quality": 4,
"sleep_min": 450,
"feeling": 3,
"stress": 2,
"soreness": 2,
"mood": null,
"nutrition": null,
"weight_kg": null,
"is_sick": false,
"is_injured": false,
"note": null,
"origin": "athlete"
}
],
"has_more": false
}Шкалы 1–5. origin=intervals — день заполнен из Intervals.icu, атлет сам его не сохранял; такие значения меняются вместе с Intervals.icu. Вебхук wellbeing.updated не содержит значений, только ссылку на этот ресурс.
Webhooks
Подписка регистрируется для приложения с помощью client credentials. Webhook содержит метаданные события и data_url для получения ресурса. События отправляются только для атлетов с активной OAuth-авторизацией и в пределах предоставленных scopes. События plan.* для черновиков не отправляются. URL должен использовать HTTPS и не может быть IP-адресом.
PUT/api/partner/v1/apps/webhook
Зарегистрировать или сменить URL и список событий. Сразу запускает challenge-response.
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: webhook_url. Если events нет — подписка на полный список v1. POST на тот же путь эквивалентен PUT. Неуспешная проверка → 400, события не идут.
GET/api/partner/v1/apps/webhook
Текущая подписка.
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
Снять подписку.
HTTP Basic
Response
{
"ok": true
}
POST/api/partner/v1/apps/webhook/verify
Повторно проверить уже сохранённый URL.
HTTP Basic
Response
{
"ok": true,
"verified": true
}
POST/api/partner/v1/apps/webhook/test
Тестовый ping только если URL уже verified. Иначе 400 webhook_unverified.
HTTP Basic
Response
{
"ok": true
}
События v1
| Событие | Когда |
|---|
| plan.created | Создан опубликованный день плана |
| plan.updated | Опубликованные карточка или структура изменились |
| plan.published | Черновик опубликован |
| plan.deleted | Удалён опубликованный день |
| workout.created | Создана завершённая тренировка |
| workout.updated | Данные завершённой тренировки изменены |
| workout.deleted | Факт удалён |
| wellness.updated | Строка wellness создана или пересчитана |
| wellbeing.updated | Запись дневника самочувствия создана, изменена или удалена. В теле нет значений — только data_url. Нужна область wellbeing:read. |
| connection.revoked | Атлет, партнёр или админ отключили приложение |
| webhook.verify | Только handshake, не подписка |
| webhook.test | Ручной test ping |
Тело webhook-запроса
{
"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 сырого тела на webhook_signing_secret
X-PaceTrack-Delivery-Id — id попытки (дедуп)
User-Agent: PaceTrack-Webhook/1.0
Получатель должен вернуть ответ 2xx в течение 5 секунд. При ошибке PaceTrack повторяет доставку через 1 минуту, 5 минут, 30 минут, 2 часа и 12 часов. Фактическое время отправки может отличаться до 6 минут из-за интервала обработки очереди. После исчерпания попыток подписка получает статус failing.
Проверка URL
Для проверки PaceTrack отправляет POST-запрос на зарегистрированный URL с заголовком X-PaceTrack-Webhook-Verification: 1. Получатель должен вернуть HTTP 2xx и JSON с исходным значением challenge без изменений. Запрос подписывается так же, как обычное событие.
{
"spec_version": "1",
"event": "webhook.verify",
"challenge": "<random>",
"app_id": "ptapp_…"
}