# API для горнолыжных школ

> Подключите программу, в которой уже работает ваша школа: доступное время передаётся нам, бронирования приходят к вам подписанным вебхуком, а ответить на них можно, не открывая кабинет.

Страница: https://alpinepal.com/ru/api-dlya-gornolyzhnyh-shkol

Аккаунт школы на Alpine Pal собирает в одном кабинете команду инструкторов, их календари и запросы на бронирование. API — это тот же кабинет, только для программ: менеджер каналов, CRM или ваш собственный скрипт может читать и записывать доступное время ваших инструкторов, узнавать о каждом оплаченном бронировании в момент оплаты, а также принимать его, отклонять или предлагать другое время.

API намеренно ограничен. Клиенты платят на Alpine Pal, поэтому через API бронирования не создаются; он лишь регистрирует занятия, проданные в других местах, как блоки, чтобы календарь оставался точным. Решения о деньгах, которые зависят от того, что произошло на склоне, — отметка о неявке, закрытие спора — остаются за человеком в кабинете.

## Как получить ключ

Ключи принадлежат школе, а не человеку, и создавать или отзывать их может только владелец школы — на вкладке «Подключения» в кабинете школы. Ключ показывается один раз, при создании; мы храним его хеш и показать ключ ещё раз не можем. Боевых ключей у школы может быть до 5, так что новой интеграции можно выдать отдельный ключ, а старый отозвать, не затрагивая остальные. Боевые ключи начинаются с ap_live_; ключи песочницы начинаются с ap_test_ и считаются отдельно.

Ключ перестаёт работать в момент отзыва, а если школа закрыта или не одобрена, любой ключ получает отказ 403 school_not_active. Школа, которая только приостановлена — скрыта на сайте, её страницы нет, — сохраняет ключи: её бронирования продолжают действовать, а вместе с ними и API. Через сам API управлять ключами нельзя. Чтобы заменить ключ, создайте новый, переключите на него свою систему, затем отзовите старый: в промежутке действуют оба. В кабинете видно, когда каждый ключ использовался в последний раз, с точностью до 5 мин. Ключи предназначены для серверов: никогда не встраивайте их в браузер или мобильное приложение.

## Аутентификация, заголовки и тело запроса

Каждый запрос передаёт ключ как bearer-токен. Базовый URL — https://alpinepal.com/api/v1; ответы приходят в JSON, никогда не кешируются, и в каждом есть X-Request-Id, на который можно сослаться в обращении к нам. Время суток указывается по часам курорта в формате HH:mm, даты — YYYY-MM-DD, а моменты времени — ISO 8601 в UTC с миллисекундами.

Тело запроса должно быть в JSON с заголовком Content-Type: application/json (иначе 415), не больше 256 КБ (иначе 413), и проверяется строго: неизвестное поле даёт 400 validation_error со списком проблемных путей. Пустое тело считается пустым объектом. На метод, который путь не поддерживает, приходит 405 с заголовком Allow; на путь с косой чертой в конце — 404.

| Заголовок | Где |
| --- | --- |
| Authorization: Bearer <key> | Каждый запрос |
| Content-Type: application/json | Каждый запрос с телом |
| X-Request-Id | Каждый ответ, включая ошибки; указывайте его, когда пишете нам |
| Cache-Control: no-store, Vary: Authorization | Каждый ответ |
| WWW-Authenticate: Bearer realm="alpinepal-api" | Ответы 401 |
| Allow | Ответы 405 |
| Retry-After (секунды) | Ответы 429; в теле то же значение повторяется как details.retryAfterSeconds |

**curl**

```bash
curl https://alpinepal.com/api/v1/me \
  -H "Authorization: Bearer ap_live_…"
```

CORS нет: API работает между серверами, и запрос из браузера не пройдёт. Так и задумано: ключ в браузере может прочитать кто угодно.

## Лимиты запросов

На каждый ключ, в фиксированных минутных окнах: чтение — 600 (GET) и запись — 120 (всё остальное, включая DELETE) в минуту. Сверх этого приходит ответ 429 rate_limited с заголовком Retry-After в секундах. У тестового ключа лимит свой. Неудачная аутентификация ограничена по IP-адресу: 20 за 10 мин; после этого на любую новую попытку приходит 429 ещё до поиска ключа. Для тестов и повторных отправок вебхуков действует отдельный общий лимит — 10 за 10 мин на ключ, потому что каждая такая операция — это запрос к чужому серверу.

## Ошибки

Все ошибки приходят в одном конверте: HTTP-статус, стабильный code, по которому можно ветвить логику, сообщение для человека и идентификатор запроса. Пять кодов содержат details: validation_error перечисляет проблемные поля как [{path, message}] (не более 10); booking_state сообщает текущее состояние бронирования ({bookingStatus}); booking_conflict называет мешающие часы и, если то бронирование оплачено, его id и номер; rate_limited повторяет время ожидания как {retryAfterSeconds}; inside_notice_window указывает минимальный срок бронирования у инструктора в часах ({hours}).

| Статус | Коды | Когда |
| --- | --- | --- |
| 400 | validation_error, invalid_cursor, invalid_json | Ошибка в самом запросе |
| 401 | unauthenticated, invalid_api_key | Ключа нет, либо он неверного формата, неизвестен, отозван или относится к другой среде |
| 403 | forbidden, school_not_active, managed_by_school, not_managed_by_school, not_sandbox | Ключ в порядке, но это действие вам недоступно; not_sandbox — боевой ключ на эндпоинте песочницы |
| 404 | not_found | Объекта нет, или это не ваш инструктор. Мы никогда не подтверждаем существование чего-либо за пределами вашей школы |
| 405 | method_not_allowed | Неверный метод для этого пути; в Allow перечислены допустимые |
| 409 | booking_state, pack_pending, slot_conflict, booking_conflict, external_ref_exists, concurrent_update, not_unassigned, not_on_sale, cancel_window_closed, lesson_started, lesson_ended, pack_day_fixed, format_fixed_time, format_price_differs, group_lesson_fixed_time, group_lesson_not_assignable, group_lesson_full, group_lessons_disabled | Настоящий конфликт состояний; прочитайте текущее состояние и решите заново |
| 413, 415 | payload_too_large, unsupported_media_type | Тело запроса должно быть в JSON и не больше 256 КБ |
| 422 | outside_availability, in_the_past, too_far_ahead, outside_ski_day, invalid_availability, api_blocks_read_only, limit_reached, invalid_webhook_url, unknown_event, range_too_long, unknown_resort, resort_required, price_below_minimum, invalid_price, no_resorts, invalid_max_students, currency_mismatch, invalid_duration, too_many_students, instructor_not_verified, wrong_discipline, invalid_duration_prices, invalid_season_prices, invalid_included_students, invalid_lesson_length, invalid_notice, invalid_disciplines, invalid_lesson_formats, formats_not_sold, format_required, unknown_format, format_not_on_date, format_wrong_time, invalid_group_lesson, group_lesson_wrong_date, inside_notice_window | Запрос корректен, но нарушает правило календаря, занятия или школы |
| 429 | rate_limited | См. Retry-After |
| 500 | internal | Ошибка на нашей стороне; идентификатор запроса поможет нам её найти |

**409 Conflict**

```json
{
  "error": {
    "code": "booking_state",
    "message": "Invalid booking state",
    "requestId": "req_929e0418a4b7",
    "details": { "bookingStatus": "confirmed" }
  }
}
```

## Как читать данные

Идентификаторы. У инструкторов это id аккаунта (user_…) или unclaimed_…, пока права на профиль, добавленный вашей школой без аккаунта, не подтверждены; пул занятий без назначенного инструктора — pool_<schoolId>; инструкторы песочницы — sandbox_ia_… и sandbox_ib_…. Бронирования — b_…, события — evt_…, доставки — whd_…, эндпоинты — wh_…, запросы — req_…. Номера бронирований: AP-<год>-<5 цифр> у настоящих и SB<6 hex> в песочнице.

Время. date, startTime и endTime — это местное время по часам resortTimeZone, никогда не UTC; моменты времени (createdAt, updatedAt, paidAt, instructorResponseDeadline…) — в UTC. Занятие в ночь перевода часов приходится на первое из двух вхождений неоднозначного часа, а час, которого в эту ночь не существует, сдвигается вперёд. endTime может быть 24:00. Прошло ли время, определяется по часам курорта.

Деньги. subtotal = (hourlyPrice + extraStudentPrice × max(0, numberOfStudents − includedStudents)) × durationHours, с округлением по валюте; это сумма, которую платит клиент. hourlyPrice — цена на дату занятия: цена сезона (seasonPrices), если дата попадает в сезон, иначе собственная цена инструктора или пула; includedStudents — сколько учеников она покрывает: 1, если школа не задала больше. platformFee — наша комиссия: 10% или более высокая, о которой ваша школа договорилась с нами письменно; каждое бронирование сохраняет ставку, по которой было продано. netAmount — остаток, subtotal − platformFee; он зачисляется на баланс вашей школы, когда занятие завершено, кто бы его ни провёл и кто бы ни управлял этим инструктором, и аннулируется, если за бронирование возвращены деньги. Все инструкторы вашей команды работают в валюте вашей школы; у цены за час есть минимум для каждой валюты (10 EUR, 11 USD, 10 CHF), а extraStudentPrice равен 0 или больше. Снаряжение, которое клиент берёт напрокат в вашей школе вместе с занятием, тоже входит в subtotal (equipment.amount) и учитывается так же, как занятие.

| Поле | Значения |
| --- | --- |
| bookingStatus | См. таблицу в разделе «Бронирования» |
| paymentStatus | pending, processing, succeeded, failed, authentication_required, refund_pending, refunded — в API попадают только succeeded и два статуса возврата |
| acceptedVia | instructor, school, auto, customer или null, пока запрос не принят |
| studentLevels | Одна запись на ученика: { level: beginner \| intermediate \| advanced, age: adult \| child }; в песочнице пусто |
| discipline | ski или snowboard — дисциплина занятия; в дисциплинах инструктора может стоять и both |
| language / customer.language | Язык занятия двухбуквенным кодом и язык, на котором клиент пользуется сайтом (en, es, it, fr, de, pl, nl, ru) |
| meetingPoint | Статус not_agreed, proposed или confirmed, плюс название, когда оно появится |
| verificationStatus (instructor) | draft, pending_review, verified, rejected |

## Ваша школа, курорты и инструкторы

| Эндпоинт | Возвращает |
| --- | --- |
| GET / | Описание API: название, версия, ссылки на эту страницу и на спецификацию (ключ не нужен) |
| GET /me | Ваша школа: id, slug, name, currency, country, freeCancellationHours (её правила отмены, в часах) и sandbox (true при тестовом ключе) |
| GET /resorts | Все курорты, где ведёт занятия кто-то из ваших инструкторов или работает ваш пул занятий без назначенного инструктора, с часовым поясом и валютой |
| GET /instructors | Ваша команда, активные и неактивные инструкторы; ушедших из школы в ней больше нет, а пула занятий без назначенного инструктора в ней не бывает |
| GET /instructors/{id} | Один инструктор: цены, длительность занятий, буфер, минимальный срок бронирования, дисциплины, языки, verificationStatus, managedBy, autoAcceptBookings |
| PATCH /instructors/{id} | Цены — hourlyPrice, extraStudentPrice, extraStudentPer, durationPrices, seasonPrices, includedStudents, groupClassesEnabled и groupPricePerStudent — для любого инструктора команды (у цен за час и за место есть минимум для каждой валюты, отрицательный extraStudentPrice даёт 422 invalid_price, а для включения групповых занятий нужна цена за место); новый groupPricePerStudent также обновляет цену в активных групповых занятиях. autoAcceptBookings — только для инструктора, которым управляет школа. Всё проверяется заранее и записывается вместе: если тело запроса отклонено из-за одного поля, ничего не меняется |

managedBy — это правило, определяющее, кто отвечает: при значении instructor календарь и запросы принадлежат инструктору, а API может их читать, но не записывать; при значении school календарь ведёт и на запросы отвечает школа, а значит и API, инструктор же только наблюдает. Значение задаёт человек в кабинете, в разделе команды; через API его изменить нельзя. Когда календарь возвращается инструктору, внешние блоки, записанные в него вашей системой, удаляются.

Когда инструктор уходит из школы, его бронирования исчезают из GET /bookings, а события по нему прекращаются; то, что он уже заработал, остаётся на балансе вашей школы. Ни это, ни смена managedBy не порождают событий: сравнивайте GET /instructors при каждой синхронизации.

unclaimed отмечает инструктора, которого школа добавила в кабинете без аккаунта: никто не входит под его именем, поэтому у него всегда managedBy school, и школа отвечает за него во всём, включая чат. Когда этот человек подтверждает права на профиль своим аккаунтом, unclaimed становится false, а id инструктора меняется на постоянный: все пути и фильтр instructorId по-прежнему принимают старый id, у инструктора он указан как previousId, а события с этого момента называют новый. Никакое событие об этом не сообщает: сравнивайте GET /instructors при каждой синхронизации и сопоставляйте по previousId.

## Доступное время

Доступное время инструктора — это список блоков. Блок бывает available, blocked или vacation, относится к курорту, где инструктор ведёт занятия, длится от startTime до endTime (endTime может быть 24:00) и привязан либо к дню недели (dayOfWeek 0–6, начиная с воскресенья, по желанию с since и until как первой и последней датой действия), либо к одной дате — строго к чему-то одному. Новые доступные часы должны лежать между 07:00 и 21:00 по часам курорта (иначе 422 outside_ski_day); блоки blocked и vacation могут занимать любое время. Клиенты могут бронировать только внутри блоков available, которые больше ничем не перекрыты.

PUT /instructors/{id}/availability заменяет всё расписание — все курорты, все блоки, не более 400 — тем, что вы отправили, точно так же, как сохранение календаря в кабинете. Поле id необязательно: сервер присваивает id блоку без него, сохраняет присланные и отклоняет дубликаты или префикс внешних блоков xb_. Внешние блоки (см. ниже) PUT никогда не затрагивает, и в нём их быть не должно. Некорректный блок, неизвестный курорт, обе привязки сразу или since позже until блока дают 422 invalid_availability. Блоки, которые уже не могут действовать (дата прошла, у еженедельного блока прошёл until), отбрасываются: PUT их не сохраняет, а GET не возвращает. Дата считается прошедшей, когда она закончилась везде, — в 12:00 UTC следующего дня.

**GET /instructors/{id}/availability**

```json
{
  "blocks": [
    { "id": "ab_1", "resortId": "r_baqueira", "type": "available",
      "dayOfWeek": 6, "startTime": "09:00", "endTime": "13:00",
      "since": "2026-12-19", "until": "2027-04-15" },
    { "id": "ab_2", "resortId": "r_baqueira", "type": "vacation",
      "date": "2026-12-25", "startTime": "07:00", "endTime": "21:00" },
    { "id": "xb_7c1d", "resortId": "r_baqueira", "type": "blocked",
      "date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
      "source": "api", "externalRef": "lueira:LS-4471" }
  ]
}
```

GET …/availability/expanded делает расчёт за вас: для одного курорта и диапазона до 62 дн. он возвращает по дням свободные интервалы, которые клиент может забронировать, и занятые, причём каждый занятый интервал помечен как booking (подтверждённое занятие), pending (оплаченный запрос, ожидающий ответа) или external (блок из API). Он учитывает буфер между занятиями и минимальный срок бронирования, заданные инструктором, но не отбрасывает промежутки короче минимальной длительности занятия, так что промежуток в тридцать минут показывается как свободный.

## Развёрнутое представление

**GET /instructors/{id}/availability/expanded?resortId=r_baqueira&from=2027-01-10&to=2027-01-11**

```json
{
  "instructorId": "user_2Zk9qX1aR7",
  "resortId": "r_baqueira",
  "timeZone": "Europe/Madrid",
  "days": [
    { "date": "2027-01-10",
      "free": [{ "start": "09:00", "end": "10:00" }, { "start": "12:00", "end": "13:00" }],
      "busy": [{ "start": "10:00", "end": "12:00", "kind": "external" }] },
    { "date": "2027-01-11",
      "free": [{ "start": "09:00", "end": "13:00" }],
      "busy": [] }
  ]
}
```

## Внешние блоки

Занятие, проданное вне Alpine Pal, занимает инструктора. Зарегистрируйте его как внешний блок, и этот слот исчезнет из поиска на всех курортах, где инструктор ведёт занятия: человек не может быть в двух долинах одновременно. Для блоков нужно, чтобы инструктором управляла школа (managedBy = school).

У каждого блока есть ваша собственная ссылка externalRef. Повторная отправка той же ссылки с той же датой и часами идемпотентна и возвращает 200 с сохранённым блоком, какой бы resortId вы ни передали; та же ссылка с другими часами даёт 409 external_ref_exists — удалите блок и создайте заново. Блок, пересекающийся с действующим бронированием, даёт 409 booking_conflict с мешающими часами и, если это бронирование оплачено, его id и номером: у нас это занятие уже продано.

У инструктора может быть не больше 400 внешних блоков начиная с сегодняшнего дня; следующий даёт 422 limit_reached. Блок с прошедшей датой (в 12:00 UTC следующего дня) исчезает сам: GET /blocks его больше не показывает, DELETE на него даёт 404, и он перестаёт учитываться в лимите 400.

| Эндпоинт | Примечания |
| --- | --- |
| GET /instructors/{id}/blocks | Необязательные фильтры from, to, externalRef |
| POST /instructors/{id}/blocks | date, startTime, endTime, externalRef (символов: 1–120), необязательный resortId (курорт, где инструктор ведёт занятия; по умолчанию первый из них); 07:00–21:00, не в прошлом по часам этого курорта, не дальше чем на 30 дн. за горизонтом бронирования (182 дн.) |
| DELETE /instructors/{id}/blocks/{blockId} | 204; на блок расписания (не из API) здесь приходит 404 |

**POST /instructors/{id}/blocks**

```bash
curl -X POST https://alpinepal.com/api/v1/instructors/user_2Zk9qX1aR7/blocks \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "date": "2027-01-10", "startTime": "10:00", "endTime": "12:00",
        "externalRef": "lueira:LS-4471" }'
```

При принятии запроса на бронирование слот проверяется ещё раз: внешний блок на эти часы, как и действующее бронирование или групповое занятие в это время, приводит к ответу 409 slot_conflict, и школа должна отклонить запрос или предложить другое время. В пуле занятий без назначенного инструктора при принятии ничего заново не проверяется: места были учтены, когда клиент заплатил. Как только на ваш блок пришёл ответ 201, этот час ваш: если на него уже шла оплата, деньги вернутся клиенту в момент, когда платёж попытается пройти, а к вам это придёт как booking.expired.

## Занятия без назначенного инструктора

Школа может продавать часы, не называя, кто проведёт занятие: клиент бронирует и платит, а школа назначает инструктора, когда сочтёт нужным, — через API, в кабинете или прямо на месте встречи, выбрав того, кто свободен. В API это собственный пул школы: у него свой календарь, своя цена и свой переключатель мгновенного подтверждения, его нет в списке /instructors, а его бронирования приходят с unassigned: true и значением instructorId, равным идентификатору пула (pool_…). Он продаёт одновременно столько занятий, сколько в его календаре мест: блок available может содержать capacity (до 50; если значения нет, то 1), и места считаются на курорте блока поминутно: блоки конкретного дня заменяют еженедельные на те минуты, которые они покрывают, а внутри каждого из этих видов пересекающиеся блоки суммируются. Занятию нужно место на всю его длительность. Групповые занятия пула мест не занимают: они идут параллельно с индивидуальными и друг с другом.

GET /unassigned показывает настройки; PATCH /unassigned задаёт любые из полей hourlyPrice, extraStudentPrice (0 или больше; отрицательное значение даёт 422 invalid_price), extraStudentPer, durationPrices, seasonPrices, includedStudents, autoAcceptBookings и maximumStudents. Первый PATCH создаёт пул; до этого эндпоинты его календаря и блоков отвечают 404. Переключателя нет: active показывает, в продаже ли пул, а он в продаже, как только его цена за час выше минимальной (или, при продаже форматов, заполнены его форматы и сезоны); продаёт он те часы, которые открыты в его календаре, поэтому школа, которой такие занятия не нужны, просто не открывает часов. Поле active в PATCH игнорируется. В остальном календарь пула работает как календарь инструктора, на курортах школы.

Фиксированные форматы. Вместо часов школа может продавать индивидуальные занятия пула как фиксированные форматы, настроенные в кабинете: до 6 (утро, вторая половина дня, полный день или другой, у каждого свои начало и конец) и сезоны — диапазоны дат с ценой за формат, за занятие на всю группу. saleMode в GET /unassigned показывает, что именно продаётся, а lessonFormats перечисляет форматы и сезоны (в API только чтение). Пока пул продаёт форматы, каждое индивидуальное занятие в нём — один из них: формат без цены в сезоне этой даты в этот день не продаётся, а на дату вне всех сезонов не продаётся ничего. Формату нужно место, свободное на все его часы, как и любому занятию пула, и на него не распространяется предел в 6 ч для занятий с почасовой продажей. Такой пул в продаже, как только заполнены его форматы и сезоны, какой бы ни была цена за час. Его бронирования содержат format (id и kind), subtotal — это цена формата в этот день, а propose-time переносит такое бронирование только на те же часы другого дня, когда формат продаётся по уплаченной цене (иначе 409 format_fixed_time или format_price_differs). В песочнице POST /sandbox/bookings для пула принимает formatId и использует форматы вашей школы.

| Эндпоинт | То же, что |
| --- | --- |
| GET, PUT /unassigned/availability | GET, PUT /instructors/{id}/availability |
| GET /unassigned/availability/expanded | Развёрнутое представление для одного из курортов школы; для каждого свободного интервала также указано, сколько осталось мест (places) |
| GET, POST /unassigned/blocks · DELETE /unassigned/blocks/{blockId} | Внешние блоки пула: каждый занимает одно место на своём курорте (если курортов несколько, resortId обязателен: 422 resort_required) и отклоняется (409 booking_conflict) только там, где занятий оказалось бы больше, чем мест, и среди них было бы одно из наших |
| GET /bookings?instructorId=pool_<schoolId> | Только занятия без назначенного инструктора |
| POST /bookings/{id}/assign | instructorId в теле: передаёт занятие без назначенного инструктора кому-то из вашей команды — см. раздел «Ответ на запрос» |

**GET /unassigned**

```json
{
  "instructorId": "pool_sch_2a71",
  "configured": true,
  "active": true,
  "hourlyPrice": 55,
  "extraStudentPrice": 15,
  "autoAcceptBookings": false,
  "maximumStudents": 6,
  "currency": "EUR",
  "resorts": ["r_baqueira", "r_grandvalira"],
  "saleMode": "formats",
  "lessonFormats": {
    "formats": [
      { "id": "fmt_3b9c1e7a20", "kind": "morning", "startTime": "09:00", "endTime": "13:00", "durationHours": 4 },
      { "id": "fmt_8f02d6c4e1", "kind": "full_day", "startTime": "09:00", "endTime": "17:00", "durationHours": 8 }
    ],
    "seasons": [
      {
        "id": "sea_8d41f0c2b9",
        "name": "Low season",
        "ranges": [{ "from": "2026-11-29", "to": "2026-12-19" }, { "from": "2027-01-03", "to": "2027-02-06" }],
        "prices": [{ "formatId": "fmt_3b9c1e7a20", "price": 450 }, { "formatId": "fmt_8f02d6c4e1", "price": 700 }]
      }
    ]
  }
}
```

Пока занятие не назначено, роль инструктора в нём выполняет школа: она отвечает на запрос, переписывается с клиентом и согласует место встречи. После назначения это такое же бронирование инструктора, как любое другое, а то, что школа ещё может с ним делать, определяется значением managedBy.

## Групповые занятия

Групповое занятие — это событие с местами, которые независимые клиенты покупают по отдельности: разовое (date) или еженедельная серия (dayOfWeek, по желанию с since и until), с вместимостью, ценой за место на всё занятие, уровнем, аудиторией (audience: all, children или adults; для детского занятия можно задать minAge и maxAge, и каждый ученик, бронирующий место, должен им соответствовать), фиксированным местом встречи и минимальным числом участников для проведения. Занятия инструктора берут цену за место из его собственных настроек (groupClassesEnabled и groupPricePerStudent, доступны для записи через PATCH /instructors/{id}); только пул указывает цену при создании, а изменение цены обновляет активные занятия. Занятия публикуются, передаются инструктору и отменяются в кабинете — в API v1 они только для чтения — и относятся к инструктору команды или к пулу занятий без назначенного инструктора.

GET /instructors/{id}/classes (и GET /unassigned/classes) выводят активные занятия. GET …/classes/expanded?from&to разворачивает их в конкретные даты с занятыми местами, включая оплаты, которые держат место (10 мин), — не более 62 дн. за один вызов. В календаре инструктора часы занятия также показываются как занятые (busy) в развёрнутом представлении доступного времени, а внешний блок на них отклоняется с ответом 409 booking_conflict: продавать эти часы где-то ещё нельзя. Пул — это команда: его групповые занятия ничего не отнимают у мест для индивидуальных занятий, идут параллельно с ними и друг с другом, а внешний блок с ними никогда не конфликтует.

**GET /instructors/{id}/classes**

```json
{
  "data": [
    {
      "id": "gc_8c4d21f0a9b3",
      "instructorId": "user_2NcT4jQ0uW",
      "unassigned": false,
      "resortId": "r_grandvalira",
      "discipline": "ski",
      "language": "en",
      "dayOfWeek": 3,
      "since": "2026-12-23",
      "until": "2027-03-31",
      "startTime": "10:00",
      "endTime": "12:00",
      "capacity": 8,
      "minStudents": 3,
      "pricePerStudent": 35,
      "currency": "EUR",
      "level": "beginner",
      "meetingPoint": { "title": "Top of the gondola" },
      "cancelledDates": ["2027-01-20"],
      "createdAt": "2026-09-22T09:00:00.000Z",
      "updatedAt": "2026-09-22T09:00:00.000Z"
    }
  ]
}
```

**GET /instructors/{id}/classes/expanded?from=2027-01-11&to=2027-01-17**

```json
{
  "data": [
    {
      "classId": "gc_8c4d21f0a9b3",
      "date": "2027-01-13",
      "startTime": "10:00",
      "endTime": "12:00",
      "capacity": 8,
      "seatsTaken": 5
    }
  ]
}
```

Каждое проданное место приходит через /bookings как обычное бронирование с groupClassId, подтверждённое в момент оплаты (acceptedVia auto). propose-time и assign для места не работают: время принадлежит занятию и всем остальным участникам, а занятие передаётся инструктору целиком, в кабинете. Отмена занятия (или одной даты серии) полностью возвращает деньги за каждое место и отправляет по одному booking.cancelled на место; занятие, в котором за 24 ч до начала всё ещё меньше участников, чем minStudents, отменяется таким же образом, с cancelledBy platform.

## Бронирования

В API попадают только оплаченные бронирования: для вас бронирование существует с момента поступления оплаты (paidAt), а бронирование с возвратом средств остаётся в списке со своим paymentStatus. Клиент выбирает инструктора и слот, платит, и запрос попадает к инструктору или в школу. Дальше на него либо отвечают, либо срок истекает без ответа, и клиенту возвращаются деньги.

Сроки: крайний срок ответа — 48 ч с момента оплаты, но не позже начала занятия; предложение другого времени или отказ от него запускают срок заново (но не дальше более раннего из двух начал — исходного и предложенного); раз в час фоновая задача закрывает просроченное, так что истечение срока может наступить с опозданием до часа. Подтверждённое занятие переходит в upcoming за 24 ч до начала и в in_progress на время проведения. Оно завершается, когда клиент это подтвердит, либо само через 48 ч после окончания; завершение высвобождает вашу долю. Клиент может бесплатно отменить бронирование не позднее чем за freeCancellationHours ч до начала: это собственный срок бронирования, действовавший по правилам отмены вашей школы на момент оформления (по умолчанию 48 ч), и он сохраняется, как бы правила ни менялись потом.

| bookingStatus | Значение |
| --- | --- |
| pending_instructor_approval | Оплачено, ожидает ответа до instructorResponseDeadline |
| change_proposed | Предложено другое время; клиент принимает его, отклоняет или даёт сроку истечь. Исходный слот остаётся занятым; предложенный не резервируется и при согласии клиента проверяется заново |
| confirmed | Принято (acceptedVia показывает кем: instructor, school, auto, customer) |
| upcoming, in_progress | Подтверждено; занятие скоро начнётся или уже идёт |
| completed | Завершено; ваша доля денег высвобождена |
| rejected_by_instructor, expired | Деньги возвращены полностью |
| cancelled_by_customer, cancelled_by_instructor | Отменено; возврат средств по правилам отмены |
| no_show_customer, no_show_instructor, disputed, refunded | Устанавливается в кабинете или нами |

**A booking**

```json
{
  "id": "b_3e8c1f",
  "reference": "AP-2027-51234",
  "instructorId": "user_2Zk9qX1aR7",
  "unassigned": false,
  "resortId": "r_baqueira",
  "resortTimeZone": "Europe/Madrid",
  "date": "2027-01-12",
  "startTime": "10:00",
  "endTime": "12:00",
  "durationHours": 2,
  "discipline": "ski",
  "language": "en",
  "numberOfStudents": 2,
  "studentLevels": [{ "level": "beginner", "age": "adult" }, { "level": "beginner", "age": "adult" }],
  "groupClassId": null,
  "format": null,
  "pack": null,
  "equipment": null,
  "customerMessage": "Two adults, first time on skis.",
  "customer": { "name": "Santi P.", "language": "es" },
  "meetingPoint": { "status": "not_agreed" },
  "subtotal": 130,
  "platformFee": 13,
  "netAmount": 117,
  "currency": "EUR",
  "bookingStatus": "pending_instructor_approval",
  "paymentStatus": "succeeded",
  "acceptedVia": null,
  "instructorResponseDeadline": "2027-01-06T10:00:00.000Z",
  "freeCancellationHours": 48,
  "proposed": null,
  "createdAt": "2027-01-04T09:12:41.000Z",
  "updatedAt": "2027-01-04T09:13:02.000Z",
  "paidAt": "2027-01-04T09:13:02.000Z",
  "completedAt": null,
  "refundedAt": null,
  "url": "https://alpinepal.com/en/booking/b_3e8c1f"
}
```

О клиенте вы получаете краткое имя (имя и инициал), предпочитаемый язык и его сообщение: этого достаточно, чтобы провести занятие, и именно это обещает клиенту наша политика конфиденциальности. Электронная почта, телефон и любые идентификаторы аккаунта никогда не передаются; общение с клиентом остаётся в чате на Alpine Pal, где находится инструктор.

## Списки и синхронизация

GET /bookings выводит оплаченные бронирования ваших инструкторов, начиная с самых старых изменений, с фильтрами по status (список через запятую), instructorId, from и to (даты занятий) и updatedSince (любой момент в ISO 8601, включительно). Постраничный вывод основан на ключе (updatedAt, id): следуйте за nextCursor, пока он не станет null. Курсор непрозрачен и не истекает, но привязан к фильтрам, с которыми создан: поменяли фильтры — начинайте заново; выдуманный курсор даёт 400.

Схема для ночной или ежечасной синхронизации одна: запоминайте самое новое значение updatedAt, которое видели, и запрашивайте всё начиная с него, в точности в том виде, в каком его получили. Вебхуки сообщают об изменении сразу; этот эндпоинт нужен, чтобы наверстать упущенное после простоя. updatedAt также меняется при изменениях, которые не отправляют событие (занятие перешло в upcoming или in_progress, отправлено напоминание), поэтому запись может попасть в синхронизацию без видимых отличий.

**Everything that changed since your last sync**

```bash
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100" \
  -H "Authorization: Bearer ap_live_…"
# → { "data": [ … ], "nextCursor": "eyJ1IjoiMjAyNy0…" }
# then, while nextCursor is not null:
curl "https://alpinepal.com/api/v1/bookings?updatedSince=2027-01-04T09:00:00Z&limit=100&cursor=eyJ1IjoiMjAyNy0…" \
  -H "Authorization: Bearer ap_live_…"
```

## Ответ на запрос

Действия доступны школе для инструкторов, которыми она управляет, и для её собственного пула занятий без назначенного инструктора; для инструктора, который управляет собой сам, ответ — 403 not_managed_by_school. Каждое действие — это условное обновление: если бронирование уже не в том состоянии, которого ожидает действие, приходит 409 booking_state с текущим состоянием в details, и ничего не меняется. Поэтому повторять действие безопасно, а узнать, что произошло, можно через GET /bookings/{id}.

| Эндпоинт | Правило |
| --- | --- |
| POST /bookings/{id}/accept | Из состояния pending; занятие не должно было начаться. Слот проверяется ещё раз: действующее бронирование или групповое занятие, пересекающееся с ним (с учётом буфера инструктора и оплаченных запросов), либо внешний блок на эти часы дают 409 slot_conflict. В пуле занятий без назначенного инструктора ничего заново не проверяется: места учтены при оплате |
| POST /bookings/{id}/reject | Из состояния pending или change_proposed; клиенту полностью возвращаются деньги |
| POST /bookings/{id}/propose-time | date, startTime, необязательный message; только из pending, по одному предложению за раз. Клиент принимает его, отклоняет или даёт сроку истечь; исходный слот остаётся занятым, а предложенный не резервируется |
| POST /bookings/{id}/cancel | Подтверждённое, предстоящее или идущее занятие, которое ещё не закончилось; клиенту полностью возвращаются деньги |
| POST /bookings/{id}/assign | instructorId в теле; только занятие с unassigned: true, из состояния pending, confirmed или upcoming. Инструктор должен быть в вашей команде, быть активным и проверенным, вести эту дисциплину на этом курорте, работать в этой валюте, допускать такое число учеников и такую длительность и быть свободным в это время; его опубликованные часы не проверяются — это ваше решение. Крайний срок не сдвигается; дальше отвечает тот, кто управляет инструктором. Клиент и инструктор получают уведомление, а затем следует booking.updated с reason instructor_assigned |

**POST /bookings/{id}/propose-time**

```bash
curl -X POST https://alpinepal.com/api/v1/bookings/b_3e8c1f/propose-time \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "date": "2027-01-12", "startTime": "14:00",
        "message": "The morning is taken; would 14:00 work?" }'
```

Двух вещей здесь нет намеренно: отметки о неявке и завершения занятия. От обоих зависит движение денег, а оно определяется тем, что произошло на склоне, поэтому они остаются за человеком в кабинете.

## Вебхуки

Зарегистрируйте https-URL, и мы будем отправлять на него POST с каждым событием бронирования: тот же JSON, что возвращают эндпоинты бронирований, обёрнутый в событие. Эндпоинтов на школу: до 10, каждый подписан на отдельные события или на все сразу через "*". Регистрируйте их в кабинете или через API; журнал доставок в кабинете виден в обоих случаях. URL должен быть https, с именем хоста, содержащим точку, без учётных данных и фрагмента, и не должен быть частным адресом или нашим.

| Эндпоинт | Что делает |
| --- | --- |
| GET /webhooks | Ваши эндпоинты, включая секреты |
| POST /webhooks | url, events (1–20 или ["*"]), необязательный description; 201 с секретом |
| PATCH /webhooks/{id} | Любые из url, events, description, active; повторное включение обнуляет счётчик сбоев |
| DELETE /webhooks/{id} | Удаляет эндпоинт и его журнал доставок |
| POST /webhooks/{id}/rotate-secret | Новый секрет; старый сразу перестаёт действовать |
| POST /webhooks/{id}/test | Ping: ставится в очередь как обычная доставка и выполняется сразу; сообщает, чем закончилась эта попытка, а неудачная повторяется как любая другая |
| GET /webhooks/{id}/deliveries | Сначала новые, без тел: по умолчанию 50, с ?limit= до 100 |
| POST /webhooks/{id}/deliveries/{deliveryId}/replay | То же событие (тот же id, те же данные) ещё раз как новая самостоятельная доставка: выполняется сразу и повторяется как любая другая |

**POST /webhooks**

```bash
curl -X POST https://alpinepal.com/api/v1/webhooks \
  -H "Authorization: Bearer ap_live_…" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://crm.example.com/alpinepal", "events": ["*"] }'
# → 201 { "id": "wh_…", "secret": "whsec_…", … }  — keep the secret
```

**What a delivery carries**

```json
{
  "id": "evt_4b2c9e17a0d3",
  "type": "booking.confirmed",
  "apiVersion": "v1",
  "createdAt": "2027-01-04T10:02:17.000Z",
  "schoolId": "sch_2a71",
  "sandbox": false,
  "data": {
    "booking": { "id": "b_3e8c1f", "bookingStatus": "confirmed", "acceptedVia": "school", "…": "…" },
    "previousStatus": "pending_instructor_approval"
  }
}
```

**Request headers on every delivery**

```http
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: AlpinePal-Webhooks/1.0
X-AlpinePal-Event: booking.confirmed
X-AlpinePal-Event-Id: evt_4b2c9e17a0d3
X-AlpinePal-Delivery: whd_00dd9d2f0cdd
X-AlpinePal-Signature: t=1799999999,v1=5f1c…e2a9
```

События. booking.requested — поступил оплаченный запрос. booking.confirmed — бронирование принято инструктором или школой, подтверждено автоматически, либо клиент принял предложенное время: acceptedVia показывает, что именно произошло, а в последнем случае previousStatus равен change_proposed. booking.time_proposed — клиенту предложено другое время. booking.rejected. booking.cancelled — cancelledBy равен customer, instructor, school или platform (аккаунт, стоявший за бронированием, удалён или групповое занятие отменено из-за нехватки участников). booking.expired — вовремя никто не ответил. booking.completed — занятие закончилось, включая неявки. booking.updated — всё остальное, о чём стоит сообщить, с data.reason: proposal_declined, meeting_point_proposed, meeting_point_confirmed, dispute_opened, dispute_resolved, refund_settled (деньги вернулись клиенту), refund_issued (возврат оформлен нами) или instructor_assigned (previousInstructorId указывает пул, где было занятие). ping — тестовое событие с пустым data; на него нельзя подписаться. Для upcoming, in_progress, изменений команды и managedBy события не отправляются.

Чего ожидать. Доставка выполняется как минимум один раз: ответ, пришедший после нашего таймаута (10 с), считается сбоем, и событие придёт снова, поэтому используйте id события как ключ и игнорируйте повторы. Порядок не гарантируется: повторные попытки и фоновая задача меняют порядок событий, поэтому упорядочивайте по data.booking.updatedAt. Не каждое бронирование начинается с booking.requested: бронирование инструктора с включённым мгновенным подтверждением (autoAcceptBookings) приходит сразу как booking.confirmed, с previousStatus pending_instructor_approval. Поэтому создавайте бронирование в своей системе по первому событию с его id, какого бы типа оно ни было. Содержимое фиксируется при создании события: повторная попытка несёт то, что было верно тогда, а более позднее событие — более новое состояние. Событие может потеряться, если наша попытка его записать не удастся; GET /bookings?updatedSince служит страховкой. Каждое событие содержит sandbox: true или false. Журнал хранит доставки 30 дн.; replay отправляет прошлую доставку ещё раз.

## Проверка доставок

Каждая доставка подписана секретом эндпоинта: заголовок X-AlpinePal-Signature содержит метку времени и HMAC-SHA256 от метки времени, точки и необработанного тела. Проверяйте подпись, прежде чем чему-либо доверять, сравнивайте за постоянное время и отклоняйте метки времени, отстоящие от ваших часов больше чем на 5 мин. Секрет возвращается при создании эндпоинта и в GET /webhooks; заменить его можно в кабинете или через POST /webhooks/{id}/rotate-secret, старый сразу перестаёт действовать.

**Verifying the signature (Node)**

```js
import { createHmac, timingSafeEqual } from "node:crypto";

// rawBody must be the exact bytes received, before any JSON parsing.
export function verifyAlpinePal(secret, rawBody, signatureHeader, toleranceSeconds = 300) {
  const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === 32 && timingSafeEqual(given, Buffer.from(expected, "hex"));
}
```

Ответьте любым кодом 2xx в течение 10 с. Всё остальное, включая 3xx (редиректы мы не выполняем), считается сбоем, и мы повторяем попытку с такими интервалами: 5 минут, 15 минут, 1 час, 4 часа, 12 часов и 24 часа (всего около 42 ч), каждый раз с тем же id события, чтобы вы могли отсеять дубли. Эндпоинт отключается, а администраторы школы получают уведомление, только когда одна из доставок исчерпала все повторные попытки и за это время на него не прошла ни одна доставка; короткий сбой его не отключает, сколько бы событий ни было в пути. Пока он отключён, новые события для него в очередь не ставятся (наверстать их поможет GET /bookings?updatedSince), а ожидающая доставка, время попытки которой наступает в этот период, помечается как неудачная без отправки; повторные попытки, время которых наступает после повторного включения, уходят как обычно. Неудачные доставки отправьте заново через replay. POST /webhooks/{id}/test ставит ping в очередь как обычную доставку, сразу выполняет попытку и сообщает, чем она закончилась: неудачный ping повторяется так же, учитывается при отключении эндпоинта как любая доставка, а на отключённом эндпоинте помечается как неудачный без отправки. GET /webhooks/{id}/deliveries выводит доставки начиная с новых, по умолчанию 50, с ?limit= до 100, с числом попыток и без тел: у события одна доставка на эндпоинт и ещё по одной на каждый replay, с тем же id события.

## Песочница

Каждая школа получает двойника в песочнице в тот момент, когда её владелец создаёт тестовый ключ в кабинете (Подключения → Песочница): копию школы с двумя вымышленными инструкторами — sandbox_ia_…, который отвечает на запросы вручную, и sandbox_ib_… с включённым мгновенным подтверждением — и пулом занятий без назначенного инструктора; у каждого из них календарь 09:00–17:00 на каждый день на курортах школы, длительность занятия от 1 до 6 ч, учеников — до 6, без буфера и без минимального срока бронирования. Тестовые ключи начинаются с ap_test_ и работают только с двойником; боевые ключи с ним никогда не работают. Всё остальное — тот же API: получайте список инструкторов двойника, записывайте их календари, регистрируйте вебхуки, отвечайте на запросы, назначайте занятия.

То, что клиент делает на сайте, за вас делает POST /sandbox/bookings: создаёт оплаченное бронирование у указанного вами инструктора, с теми же проверками, что и настоящее (опубликованное доступное время, отсутствие пересечений), и без всяких денег. Дальше работает настоящий механизм, а действия из таблицы ниже заменяют клиента и часы, так что любое событие можно увидеть за несколько минут. Из песочницы не уходит ни одно письмо, и ничто в ней не становится публичным; каждое её событие содержит sandbox: true, как и GET /me. POST /sandbox/reset очищает её, чтобы набор тестов мог начать с чистого листа.

| Эндпоинт | Примечания |
| --- | --- |
| POST /sandbox/bookings | instructorId (инструктор двойника или его пул), date, startTime, durationHours (в пределах лимитов инструктора); необязательные numberOfStudents, resortId, discipline, language, customerMessage. 201 с бронированием; 403 not_sandbox при боевом ключе |
| POST /sandbox/classes | Публикует групповое занятие у инструктора песочницы или в пуле, по правилам боевой среды; места бронируются через POST /sandbox/bookings с groupClassId |
| POST /sandbox/bookings/{id}/accept-proposal · decline-proposal | Ответ клиента на предложенное время |
| POST /sandbox/bookings/{id}/cancel | Клиент отменяет бронирование по тем же правилам, что и на сайте |
| POST /sandbox/bookings/{id}/complete | То, что делает подтверждение клиента или фоновая задача через 48 ч после занятия: completed, и ваша доля высвобождена |
| POST /sandbox/bookings/{id}/expire | То, что делает фоновая задача после крайнего срока: expired и возврат средств |
| POST /sandbox/reset | Удаляет бронирования, чаты, записи о начислениях и групповые занятия двойника; ключи, вебхуки, календари и журнал доставок остаются |

**POST /sandbox/bookings**

```bash
curl -X POST https://alpinepal.com/api/v1/sandbox/bookings \
  -H "Authorization: Bearer ap_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "instructorId": "sandbox_ia_3f9a", "date": "2027-01-10",
        "startTime": "10:00", "durationHours": 2, "numberOfStudents": 2 }'
```

В песочнице по-прежнему недоступны: споры, неявки, возврат средств, оформленный нами, отмена с cancelledBy platform и место встречи (его события приходят с сайта). У бронирований песочницы пустой studentLevels, а клиент по имени Sandbox C. пользуется сайтом на английском.

## OpenAPI

Машиночитаемое описание находится по адресу https://alpinepal.com/api/v1/openapi.json: все пути, методы, параметры и схемы, перечисленные выше, в формате OpenAPI 3.1. Подключите к нему генератор, чтобы получить типизированный клиент, или импортируйте его в свой HTTP-инструмент для просмотра. Оно отдаётся без аутентификации и кешируется на час.

## Версии и изменения

Это v1. В её рамках мы только добавляем: новые поля, новые необязательные параметры, новые типы событий, новые эндпоинты. Ваша интеграция должна игнорировать поля и события, которых не знает. Всё, что может сломать существующий клиент, уходит в v2 с новым базовым URL, а v1 продолжает работать, пока вы переходите.

- 2026-09 — v1: школа, курорты, инструкторы, доступное время и развёрнутое представление, внешние блоки, бронирования с постраничным выводом по ключу, accept / reject / propose-time / cancel, подписанные вебхуки с повторными попытками.
- 2026-09-18 — hourlyPrice и extraStudentPrice в PATCH /instructors/{id}; пул занятий без назначенного инструктора в /unassigned и POST /bookings/{id}/assign; reason и previousInstructorId в booking.updated; песочница, ключи ap_test_ и sandbox в каждом событии.
- 2026-09-18 — ревизия: booking_state содержит текущее состояние; updatedSince принимает любой момент ISO; возврат средств за бронирование без платежа тоже выдаёт refund_settled; один booking.completed на занятие; assign проверяет лимиты инструктора; meeting_point_proposed; POST /webhooks/{id}/deliveries/{deliveryId}/replay; действия клиента и часов в песочнице; эта страница переписана по тому, что реально делает код.
- 2026-09-20 — PATCH /instructors/{id} записывает свои поля все вместе или не записывает ничего; блок с ответом 201 теперь имеет приоритет над оплатой, которая уже шла на тот же час (клиенту возвращаются деньги, вы получаете booking.expired).
- 2026-09-22 — групповые занятия: GroupClass и его даты в /instructors/{id}/classes и /unassigned/classes (только чтение), groupClassId в каждом бронировании, cancelledBy platform, когда занятие не набирает минимум, POST /sandbox/classes и groupClassId в POST /sandbox/bookings.
- 2026-09-23 — цены групповых занятий у инструктора: groupClassesEnabled и groupPricePerStudent в Instructor и в PATCH /instructors/{id}; новая цена за место обновляет активные занятия; минимальная цена за место — 10 EUR / 10 USD.
- 2026-09-24 — уточнение в документации: бронирование у инструктора с мгновенным подтверждением приходит только как booking.confirmed, никогда как booking.requested; создавайте бронирования по первому событию с их id. Поведение не изменилось.
- 2026-09-27 — уточнение в документации: предложение места встречи отправляет booking.updated с reason meeting_point_proposed; в разделе о синхронизации говорилось, что события нет. Поведение не изменилось.
- 2026-09-27 — блоки с прошедшей датой отбрасываются: PUT /instructors/{id}/availability их больше не сохраняет и не возвращает, а внешний блок исчезает из GET /blocks, как только его дата прошла, и перестаёт учитываться в лимите внешних блоков, который теперь считается начиная с сегодняшнего дня. До этого оба вида накапливались, пока инструктор не упирался в лимит.
- 2026-10-05 — инструкторы, которых школа добавляет без аккаунта: unclaimed и previousId в Instructor. Когда человек подтверждает права на профиль, его id меняется; все пути и фильтр instructorId по-прежнему принимают старый.
- 2026-10-06 — места в пуле занятий без назначенного инструктора: capacity в его блоках available (несколько занятий одновременно, с подсчётом по курортам), places в каждом свободном интервале его развёрнутого представления, а его внешние блоки занимают по одному месту вместо закрытия часов. Его групповые занятия теперь конфликтуют только на своём курорте. Календари инструкторов не меняются: capacity в них даёт 422 invalid_availability.
- 2026-10-06 — пул проводит групповые занятия параллельно с индивидуальными и друг с другом: занятие больше не занимает часы (они остаются в free со своими местами), а внешний блок с ним больше не конфликтует. GroupClass получает audience (all, children, adults) с необязательными minAge и maxAge; место в детском занятии продаётся только ученикам, забронированным как дети, во взрослом — только взрослым. POST /sandbox/classes принимает те же поля.
- 2026-10-06 — еженедельные блоки и серии групповых занятий могут начинаться позже: since, их первая дата (включительно), позволяет школе загрузить сезон до открытия, не продавая эти часы уже сейчас. PUT /instructors/{id}/availability принимает его в блоках с dayOfWeek (since позже until блока даёт 422 invalid_availability), GET его возвращает, GroupClass его содержит, а POST /sandbox/classes принимает. Без since серия действует начиная с настоящего момента, как и раньше.
- 2026-10-08 — эндпоинт отключается только тогда, когда одна из доставок исчерпала все повторные попытки и за это время на него не прошла ни одна доставка. Раньше он отключался после десяти неудачных попыток подряд, считая по всем доставкам, так что короткий сбой при большом числе событий в пути мог отключить его и сбросить их повторные попытки.
- 2026-10-16 — учебный день длится с 07:00 до 21:00 по часам курорта (было с 06:00 до 23:00): внешние блоки, предложенное время, а также бронирования и групповые занятия в песочнице вне этих часов дают 422 outside_ski_day, как и новые доступные часы вне этого интервала в PUT /instructors/{id}/availability и PUT /unassigned/availability. Ранее сохранённые часы остаются, блоки blocked и vacation могут занимать любое время, а групповое занятие, опубликованное раньше, сохраняет своё время.
- 2026-10-16 — фиксированные форматы занятий в пуле (API 1.8.0): GET /unassigned получает saleMode и lessonFormats (форматы и сезоны с ценой за формат, только чтение), Booking получает format (null у занятий с почасовой продажей), POST /sandbox/bookings принимает formatId, а propose-time для занятия с форматом сохраняет его часы и цену (409 format_fixed_time, format_price_differs). Для пула с почасовой продажей ничего не меняется.
- 2026-10-16 — занятия без назначенного инструктора теперь без переключателя (API 1.8.1): active в GET /unassigned показывает, в продаже ли пул, а он в продаже, как только его цена за час выше минимальной (или заполнены его форматы и сезоны), и PATCH это поле игнорирует; школа, которой такие занятия не нужны, не открывает для них часов.
- 2026-10-20 — цены за несколько часов подряд (API 1.10.0): Instructor и UnassignedLessons получают `durationPrices` — необязательные фиксированные цены за всё занятие длительностью 2, 3 или 4 часа для одного ученика. Цена за 4 часа действует и для более длинных занятий по своей ставке за час, длительность без собственной цены получает ставку самой длинной из более коротких длительностей с ценой, а за дополнительных учеников по-прежнему платят почасово. Без этих цен всё остаётся почасовым. PATCH /instructors/{id} и PATCH /unassigned их принимают (null очищает); цены, которые за час дороже более короткого занятия или в сумме дешевле него, дают 422 `invalid_duration_prices`.
- 2026-10-19 — многодневные пакеты (API 1.9.0): клиент может купить одним платежом несколько дней курса групповых занятий (GroupClass.packs, цена на человека по возрастным группам, с courseId) или несколько дней подряд фиксированного формата (UnassignedLessons: packDays, а по сезонам minDays и packPrices). Каждый день — отдельное бронирование со своей долей цены и полем `pack`, указывающим пакет и день; studentLevels может содержать `band` каждого ученика.
- 2026-11-02 — условия по курортам (API 1.22.0): в UnassignedLessons появилось поле `resortTerms`: по id курорта — каждый курорт, где продажи идут иначе, со всеми ценами и условиями пула, которые там действуют (те же поля, что и на верхнем уровне: `hourlyPrice`, `durationPrices`, `seasonPrices`, `saleMode`, `lessonFormats` и остальные). Если у пула один набор условий, это `{}`, а курорт, которого нет в списке, продаётся по значениям верхнего уровня. PATCH /unassigned принимает `resortId`, чтобы записать условия этого курорта, не меняя остальные; на курорт, где школа не работает, приходит ответ 422 `unknown_resort`. Без `resortId` запись идёт на верхний уровень, который курорт из `resortTerms` не читает. `active` равно true, пока пул продаётся хотя бы на одном курорте. Изменение только добавляет новое: пул без условий по курортам читается и продаётся как раньше.
- 2026-10-29 — часы, занятые в собственном Google Calendar инструктора (API 1.21.1): теперь у инструктора можно закрыть на Alpine Pal часы, отмеченные как занятые в его Google Calendar. Они не входят в расписание: GET /instructors/{id}/availability их не показывает, PUT их не видит и не меняет, а /availability/expanded исключает эти часы из `free`, как и всё, что не продаётся. Больше ничего не меняется.
- 2026-10-27 — цены по сезонам и включённые ученики (API 1.21.0): Instructor и UnassignedLessons получают `seasonPrices` и `includedStudents`, оба задаются через PATCH /instructors/{id} и PATCH /unassigned. `seasonPrices` содержит до 4 сезонов, у каждого есть `id`, `name` для кабинета (null, если названия нет), от 1 до 10 диапазонов дат (`from` и `to`, оба включительно) и собственные `hourlyPrice` и `durationPrices`: занятие на дату внутри сезона оценивается по ценам этого сезона, а на дату вне сезонов — по собственным ценам инструктора или пула, как и раньше. Диапазоны никогда не пересекаются ни внутри сезона, ни между сезонами, а цены каждого сезона подчиняются тем же правилам, что и собственные цены инструктора или пула. PATCH отправляет весь список (null или пустой список очищает его): сохраняйте id, полученные из GET, и не указывайте id для нового сезона. Если сезонов нет, GET возвращает null. `includedStudents` (от 1 до 20, по умолчанию 1) — это сколько учеников покрывает цена: `extraStudentPrice` взимается только за каждого ученика сверх этого числа. Сезоны, нарушающие правило, дают 422 `invalid_season_prices`, а значение вне диапазона — 422 `invalid_included_students`. Изменение только добавляет новое: без сезонов и с одним включённым учеником все цены остаются прежними.
- 2026-10-26 — цена 0 или ниже отклоняется (API 1.20.4): в `durationPrices` длительность, которую продаёт инструктор или пул, с ценой 0 или отрицательной теперь даёт 422 `invalid_duration_prices`. Раньше такая цена молча отбрасывалась, запрос получал ответ 200, и эта длительность продавалась почасово. Чтобы оставить длительность без собственной цены, не указывайте её ключ (или отправьте null для всего поля).
- 2026-10-26 — исправления документации (API 1.20.3): эта страница и спецификация теперь говорят то, что API уже делает. При принятии запроса слот проверяется заново (действующее бронирование, групповое занятие или внешний блок на нём дают 409 `slot_conflict`; в пуле занятий без назначенного инструктора ничего заново не проверяется). Ключи перестают работать, с ответом 403 `school_not_active`, только когда школа закрыта или не одобрена; приостановленная школа сохраняет API. Пока эндпоинт отключён, ожидающая доставка помечается как неудачная только тогда, когда наступает время её попытки; повторные попытки, время которых наступает после повторного включения, отправляются. Неизвестный `extraStudentPer` даёт 400 `validation_error`, а не 422 `invalid_price`, как говорилось в записи 1.13.0; `invalid_price` — это отрицательный `extraStudentPrice`. `platformFee` равен 10% или более высокой комиссии, о которой школа договорилась с нами письменно, по условиям на момент продажи бронирования. Пример бронирования получил `unassigned`, `groupClassId`, `format`, `pack`, `equipment` и `freeCancellationHours`, а его место встречи теперь имеет статус `not_agreed`. В таблице ошибок перечислены все коды, среди них `managed_by_school`, коды пакетов, форматов и групповых занятий и `inside_notice_window` (с `hours`). Доставки выводятся по умолчанию по 50 и до 100 с `limit`, а повторная отправка — это отдельная доставка с тем же id события. Ping — обычная доставка: повторяется при сбое, учитывается при отключении эндпоинта и помечается как неудачный без отправки, пока эндпоинт отключён. Поведение не изменилось.
- 2026-10-26 — два исправления (API 1.20.2): PATCH /instructors/{id} теперь оценивает `durationPrices` по собственным длительностям занятий инструктора, как и кабинет, поэтому цены инструктора, чьи занятия начинаются от 2 часов, больше не отклоняются как более дорогие, чем часовое занятие, которого он не продаёт. А при ключе песочницы GET и PATCH /unassigned описывают форматы, которые продаёт песочница (форматы настоящей школы, которые уже использовал POST /sandbox/bookings): `saleMode` и `lessonFormats` больше не показывают `hours` и пустой список для пула, продающего форматы.
- 2026-10-26 — назначение дня пакета (API 1.20.1): POST /bookings/{id}/assign отвечает 409 `pack_pending` для дня многодневного пакета, который всё ещё ждёт ответа школы, если инструктор сам управляет своими бронированиями (`managedBy: instructor`). На пакет отвечают целиком, а этот инструктор не смог бы ответить за часть школы. Сначала примите пакет или назначьте день инструктору, которым управляет школа; уже подтверждённые дни назначаются по одному, как и раньше.
- 2026-10-25 — закрытие одного формата у инструктора (API 1.20.0): блок blocked с `formatId` теперь принимается в календаре инструктора, продающего форматы, как и в пуле с версии 1.15.0, и закрывает только этот формат на свою дату или дни недели; другие форматы в эти часы остаются в продаже. Инструктор, следующий часам школы (`followsSchoolHours`), теперь получает и закрытия одного формата из пула. Бронирования уже соблюдали эти закрытия в любом календаре.
- 2026-10-25 — открытые часы школы для её инструкторов (API 1.19.0): Instructor получает `followsSchoolHours`. Инструктор, присоединившийся по приглашению школы или добавленный ею без аккаунта, теперь начинает с `managedBy: school` и включённым параметром: его календарь — копия открытых часов пула на курортах, где он ведёт занятия, по одному занятию за раз, и каждое изменение расписания пула попадает в него. Школа переключает это в кабинете (Индивидуальные занятия). PUT /instructors/{id}/availability с этого момента даёт инструктору собственные часы: `followsSchoolHours` становится false. Для инструкторов, уже входящих в команду, ничего не меняется.
- 2026-10-23 — инструкторы с фиксированными форматами (API 1.18.0): Instructor получает `saleMode`: `hours` или `formats`. Теперь школа может продавать занятия любого своего инструктора как фиксированные форматы с ценой по сезонам, как и в своём пуле (настраивается в кабинете, в разделе «Индивидуальные занятия»); тогда его индивидуальные бронирования содержат `format`, как и у пула, а почасовые цены ничего не продают. Booking.format больше не относится только к пулу. Ответ 422 `format_required` теперь звучит так: «Choose one of the lesson formats». Изменение только добавляет новое: каждый инструктор продаёт почасово, пока школа этого не изменит.
- 2026-10-23 — швейцарские франки (API 1.17.0): School.currency может быть `CHF`. Швейцарские школы и их инструкторы назначают цены и получают выплаты во франках, поэтому все их суммы (цены, subtotal, выплаты) указаны в CHF, со своим минимумом цены за час.
- 2026-10-23 — правила отмены у каждой школы свои (API 1.16.0): Booking получает `freeCancellationHours`, также в data.booking каждого события бронирования: за сколько часов до начала клиент может отменить бронирование с полным возвратом средств, то есть срок, с которым оно было продано: 48, 72, 168, 336, 672 или 1008. GET /me возвращает текущий `freeCancellationHours` школы. Школа задаёт свои правила в кабинете (Профиль → Правила отмены); они действуют на всё, что продаёт школа (индивидуальные и групповые занятия её инструкторов и её пул), а бронирование сохраняет срок, с которым оно оформлено, как бы ни менялись правила. Через API только чтение. Изменение только добавляет новое: у оформленных ранее бронирований значение 48.
- 2026-10-21 — закрытие одного формата (API 1.15.0): блок blocked в пуле может содержать `formatId` и тогда закрывает только этот формат на свою дату или дни недели. Места этих часов остаются для других форматов, тогда как закрытие часов закрывало каждый формат, имевший с ними хотя бы одну общую минуту. Закрыть весь день — по-прежнему значит закрыть его часы. GET возвращает это поле; PUT без него снимает закрытие.
- 2026-10-21 — форматы для курорта Westendorf (API 1.14.0): начало и конец формата теперь могут приходиться на четверть часа (08:45, 13:15), а не только на целый час или половину часа. `lessonFormats` в UnassignedLessons получает `extraStudentPrice`: сколько добавляет к занятию с форматом каждый ученик сверх первого, один раз за занятие (за день пакета), отдельно от почасовой доплаты пула; 0 оставляет цену на всю группу. Настраивается в кабинете.
- 2026-10-20 — доплата за ученика за занятие (API 1.13.0): Instructor и UnassignedLessons получают `extraStudentPer`: `hour` (как было) или `lesson` — взимается ли `extraStudentPrice` за каждый час занятия или один раз за всё занятие. Задаётся через PATCH /instructors/{id} и PATCH /unassigned; любое другое значение даёт 422 `invalid_price`.
- 2026-10-20 — условия школ на курортах Courchevel и Westendorf (API 1.12.0): у каждого формата сезона свой минимум дней подряд (`prices[].minDays`; `minDays` сезона теперь равен наименьшему из них и считается устаревшим), так что утренние занятия можно продавать от пяти дней, а занятия во второй половине дня — по одному в те же недели. UnassignedLessons получает `minBookingNoticeHours` (теперь также два и три дня) и `disciplines`, оба задаются через PATCH /unassigned. Пакет фиксированных форматов в пуле без мгновенного подтверждения теперь ждёт школу, как любой другой запрос; принятие или отклонение любого его дня даёт ответ на весь пакет. Пул также получает `minimumLessonDuration` и `maximumLessonDuration` (целые часы, PATCH /unassigned; 422 `invalid_lesson_length`): Snowsports Westendorf продаёт два и три часа, никогда один. Когда ни одно занятие пула не продаётся почасово, `hourlyPrice` может быть 0, и пул всё равно в продаже. `durationPrices` принимает ключ для каждой длительности от двух до шести часов; длительность без цены идёт по ставке за час последней длительности с ценой перед ней. И `singleLevelBookings` (тоже PATCH /unassigned): если параметр включён, индивидуальное занятие, в котором смешаны уровни учеников или взрослые и дети, отклоняется, и клиент оформляет по одному бронированию на группу: школа на курорте Courchevel ставит отдельного инструктора на каждый уровень и возраст.
- 2026-10-20 — прокат снаряжения (API 1.11.0): школа может сдавать снаряжение в прокат вместе с занятиями, по цене за день за предмет, заданной в её кабинете. Booking получает `equipment` (null, если ничего не взято напрокат): долю этого бронирования (amount, уже входит в subtotal), весь прокат, его дни и для каждого ученика предметы с их ценой и размерами (размер обуви EU, рост в см). Комиссия и netAmount учитывают его как занятие, а при возврате средств он возвращается вместе с занятием.
