Разработчикам
Публичный API организатора
Содержание
https://valeravezet.ru/api/organizer/v1/
Для организаторов, у которых есть своя система: заказы, цены и остатки мест живут в ней, а площадка — ещё один канал продаж. API даёт программный доступ к собственному кабинету: каталог, расписание, заказы.
Версия зашита в путь. Пока живёт v1, ломающих изменений в нём не будет: новые поля добавляются, старые не исчезают и смысла не меняют. Незнакомые поля в ответах игнорируйте — они будут появляться.
Ключ#
Ключ заводит владелец кабинета: Кабинет организатора → Интеграции. По умолчанию раздел не видит больше никто: ключ открывает доступ ко всем заказам и всему расписанию, а это уровень договора.
Сотруднику раздел открывают поимённо — разрешением «Интеграции» в «Сотрудниках». Связывает системы обычно приглашённый программист, и выдать ему ровно этот раздел лучше, чем переслать ключ в переписке или отдать свой вход; как это делается — ниже.
Ключ показывается один раз, сразу после выдачи. У нас остаётся только его свёртка, восстановить ключ нельзя — потерянный отзывают и заводят новый.
Ключей может быть несколько. Так меняют ключ, не останавливая обмен: выпустили второй, переключили свою систему, отозвали первый.
Ключ живёт, пока подтверждена анкета организатора. Ушла анкета на доработку — доступ закрывается сам и возвращается вместе с подтверждением.
Не все права выдаёт кабинет: контакты гостя (bookings:contacts) площадка открывает по договору — подробнее.
Как передавать#
Authorization: Bearer vlr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxКлюч равен паролю: не кладите его в репозиторий, не пересылайте в переписке, храните там же, где остальные секреты своей системы.
Права#
Ключ умеет ровно то, что ему открыли при выдаче:
| Право | Что даёт |
|---|---|
catalog:read | читать свои продукты |
schedule:read | читать своё расписание |
schedule:write | менять остатки мест и привязки |
bookings:read | читать свои заказы |
bookings:contacts | получать телефон и почту гостя — отдельное право |
webhooks:manage | управлять адресами уведомлений |
Ручке, которой не хватает права, ответ — 403 с кодом insufficient_scope и списком недостающих прав.
bookings:contacts — исключение: без него фид заказов работает как обычно, просто без телефонов и почт. Отказывать в заказах из-за него мы не будем.
Ваши номера: одно значение, разные имена#
Номер, под которым продукт или слот живёт у вас, ездит по всему API, но поле называется по-разному — так сложилось в первом выпуске, и переименовать его значило бы сломать тех, кто уже читает. Соответствия:
| Что за номер | Где | Как называется |
|---|---|---|
| Продукта | GET /products, GET /products/{id} | external_id |
| Продукта | PUT /products/{id}/external-ref (тело) | external_id |
| Продукта | GET /bookings | product_external_id |
| Продукта | тело вебхука (data) | external_ref |
| Слота | GET /products/{id}/slots | external_id |
| Слота | POST /products/{id}/slots:batch (строка и ответ) | external_ref |
Значение во всех строках одно и то же — то, которое вы прислали. Номер продукта и номер слота живут отдельно и не пересекаются.
Форматы продукта#
Одну и ту же экскурсию можно продавать по-разному: местами в общей группе или целиком своей компанией. На площадке это не две карточки, а одна с переключателем. Карточка отвечает за то, куда едем и что показываем, формат за то, почём и сколько нас. Адрес, отзывы и фотографии у форматов общие.
Форматы приезжают списком options[] в теле продукта, а их номера ездят по остальному API полем option_id:
| Где | Что появилось |
|---|---|
GET /products, GET /products/{id} | options[], все форматы карточки |
GET /products/{id}/slots | option_id у слота и такой же параметр запроса |
POST /products/{id}/slots:batch | option_id в строке, в её результате и в closed_slots |
GET /bookings | option_id заказа |
| Вебхуки | option_id в событии заказа и в schedule.conflict |
У продукта с одним форматом не меняется ничего. Таких карточек большинство, и options[] у них состоит из одного элемента. Поля price, experience_format и max_group_size продукта остаются слепком первого формата и означают то же, что означали.
option_id необязателен всюду, где его можно прислать. Строка пакетной записи без него уходит в формат карточки по умолчанию, то есть ровно туда, куда уходила до появления форматов. Читающие ручки поле просто добавили: разбор, написанный раньше, его не заметит.
Одно место, где формат придётся учесть: выезд принадлежит формату. Групповой и индивидуальный выезд в десять утра одного дня это два разных списка гостей и две разные продажи. Поэтому ключ слота теперь «продукт, формат, дата, время», а не «продукт, дата, время». У карточки с одним форматом разницы нет: у третьей составляющей всего одно значение.
Заводить форматы и править их через API нельзя: формат живёт на карточке, а карточка заводится в кабинете. Цену конкретной даты пакетная запись меняет как и раньше, в том формате, к которому дата привязана.
Слитые карточки и прежние номера#
Пока форматов не было, индивидуальный выезд заводили второй карточкой. Такие карточки площадка сливает в формат родителя: расписание и заказы переезжают в него, отзывы и фотографии — к родителю, сама копия уходит с витрины, а её старый адрес отвечает 301. Прежний номер продукта при этом продолжает работать. Всё, что вы делаете с номером слитой карточки, площадка понимает как «родитель, формат»:
| Ручка со старым номером | Что происходит |
|---|---|
GET /products/{старый} | карточка родителя с id родителя, legacy_product_id — старый номер, в options[] только этот формат |
GET /products/{старый}/slots | выезды только этого формата; у каждого product_id родителя и legacy_product_id |
POST /products/{старый}/slots:batch | строки ложатся в этот формат; replace закрывает даты только внутри него, соседний формат не трогает |
PUT / DELETE …/external-ref | привязка живёт под старым номером, у родителя своя |
В GET /products слитой копии больше нет, зато у родителя есть legacy_product_ids со всеми прежними номерами, а у формата — legacy_product_id. По ним видно, какой формат раньше был какой карточкой.
Заказы и события приходят с двумя номерами: product_id родителя и legacy_product_id формата, если он был отдельной карточкой. Кто ищет продукт по product_id, найдёт родителя; кто хранил связку под старым номером, найдёт её по legacy_product_id. slot_id выездов при слиянии не меняется, так что заказ, найденный по слоту, ложится туда же, куда и раньше.
Переписывать связки не обязательно. Но когда доберётесь: свяжите формат с родителем по option_id и присылайте option_id в строках — старый номер останется работать и после этого.
Быстрый старт#
Сквозной сценарий: от выданного ключа до первого вебхука. Команды идут подряд.
Ключ выдаёт владелец кабинета: Кабинет организатора → Интеграции. Он показывается один раз, сразу после выдачи. Дальше подставляйте его вместо <ваш ключ>, а 42 — номер вашего продукта из второго шага.
Доступ для разработчика#
Если интеграцию пишет не владелец кабинета, пересылать ему ключ не нужно — у него может быть свой вход.
Владелец добавляет разработчика в Кабинет организатора → Сотрудники, указав его телефон, и включает разрешение «Интеграции». По умолчанию оно выключено: ключ открывает все заказы и всё расписание компании, и такое выдаётся поимённо.
С этим разрешением разработчику открывается раздел «Интеграции»: выпуск и отзыв ключей, адреса уведомлений, журнал доставок и кнопка проверки связи. Ключ он заводит себе сам, и в переписке ключ больше не появляется.
Что остаётся за владельцем: реквизиты компании, договор и состав команды. Финансовые итоги кабинета — отдельное разрешение, и по умолчанию оно тоже выключено. Право bookings:contacts таким способом не выдаётся никому: его открывает площадка по договору.
> Входить нужно с того номера, на который пригласили. Приглашение > привязывается к телефону, а не к почте: пока номер не подтверждён входом, > запись сотрудника ждёт именно его. Вход с другого номера в кабинет не приведёт > — вместо этого начнётся регистрация нового организатора, и разработчик > окажется в пустом собственном кабинете вместо чужого рабочего.
Чтобы пройти все шаги подряд, попросите ключ сразу с четырьмя правами: catalog:read (шаги 1–4), schedule:write (шаги 3 и 5), schedule:read (шаг 4) и webhooks:manage (шаг 6). Права ключа видно в ответе первого шага, и если чего-то не хватило — ручка ответит 403 insufficient_scope со списком недостающих, а ключ придётся выпустить заново: права выданного ключа не меняются.
1. Проверить ключ. Чей он и что ему открыто:
curl https://valeravezet.ru/api/organizer/v1/me \
-H 'Authorization: Bearer <ваш ключ>'2. Забрать свои продукты. Номер продукта — в поле id:
curl 'https://valeravezet.ru/api/organizer/v1/products?limit=50' \
-H 'Authorization: Bearer <ваш ключ>'3. Связать продукт со своим номером. Чтобы заказ лёг в вашу систему без ручного сопоставления, а цену дальше держала она же:
curl -X PUT https://valeravezet.ru/api/organizer/v1/products/42/external-ref \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-d '{"external_id": "td_88214"}'4. Посмотреть наше расписание. Сравните с тем, что у вас, — станет видно, что придётся дописать:
curl 'https://valeravezet.ru/api/organizer/v1/products/42/slots?from=2026-09-01&to=2026-09-30' \
-H 'Authorization: Bearer <ваш ключ>'5. Записать расписание на месяц. Одним запросом и с ключом идемпотентности — повтор после оборванной связи не запишет вторую копию:
curl -X POST 'https://valeravezet.ru/api/organizer/v1/products/42/slots:batch' \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: slots-42-2026-09' \
-d '{
"window": {"from": "2026-09-01", "to": "2026-09-30"},
"mode": "merge",
"slots": [
{"date": "2026-09-12", "start_time": "10:00", "capacity": 18,
"external_reserved": 4, "price": "3500.00", "is_open": true}
]
}'Ответ придёт 200, даже если часть строк не принята: решение по каждой дате лежит в своей строке results, и его надо прочитать.
6. Подписаться на события, чтобы не опрашивать фид заказов:
curl -X POST https://valeravezet.ru/api/organizer/v1/webhooks \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.ru/valera/hook"}'Секрет подписи приходит в ответе один раз, полем secret — сохраните его сразу, без него проверять подпись нечем.
7. Убедиться, что обмен доезжает. Эта ручка прав не требует и отвечает даже тогда, когда остальные уже отказывают:
curl https://valeravezet.ru/api/organizer/v1/sync/status \
-H 'Authorization: Bearer <ваш ключ>'state: "ok" по нужным направлениям — подключение готово.
Ответы и ошибки#
Ошибка — это всегда объект с detail (по-русски, человеку) и code (машине).
{"detail": "Ключ отозван.", "code": "api_key_revoked"}| Код HTTP | Когда |
|---|---|
400 | запрос разобран, но принять его нельзя: испорченный курсор, неверный limit, неизвестное право или событие, слишком широкое окно дат, занятый external_id |
401 | ключа нет, ключ не тот, отозван, просрочен, анкета не подтверждена, учётная запись организатора отключена |
403 | канал выключен площадкой (organizer_api_disabled) или ключу не хватает прав |
404 | объект не ваш или не существует — это одно и то же для вашего ключа |
409 | конфликт идемпотентности |
429 | превышен лимит запросов |
Отдельно про organizer_api_disabled: площадка включает канал поимённо. Пока он выключен, ключ не выдаётся, а запрос с ключом получает 403 — это не про ваш ключ, это про канал; напишите нам.
Лимиты#
Считаются по ключу, а не по адресу: соседняя интеграция вашу квоту не съедает.
- чтение — 600 запросов в минуту;
- запись — 60 запросов в минуту.
Перебор — 429. Опрашивать расписание чаще раза в минуту смысла нет, а о заказах лучше вовсе не спрашивать: подпишитесь на вебхуки и узнавайте сразу.
Постраничная выдача#
Списки листаются курсором, а не номером страницы: пока вы читаете вторую страницу, в первую приезжает новая запись, и при нумерации одна из старых уехала бы за границу страницы незамеченной.
GET /products?limit=50
→ {"items": [...], "next_cursor": "<непрозрачная строка>", "has_more": true}
GET /products?limit=50&cursor=<непрозрачная строка>next_cursor непрозрачен: не разбирайте его, передавайте как есть. Испорченный курсор — 400 invalid_cursor, а не тихий возврат к началу.
Версия объекта#
У продуктов и слотов есть revision — целое число, слепок изменяемого состояния. Это не растущий счётчик: сравнивать его можно только на равенство. Совпал с тем, что вы видели в прошлый раз, — ничего не поменялось.
В revision продукта входит и состав форматов. Появился второй формат, сменились его цена, название или размер компании: отметка стала другой. В revision слота входит option_id, потому что выезд, перевешенный на другой формат, это другой выезд. Иначе if_revision принял бы такую правку за «ничего не изменилось».
Ручки#
GET /me#
С неё начинают: чей это ключ и что ему открыто. Ничего не читает и не меняет.
curl https://valeravezet.ru/api/organizer/v1/me \
-H 'Authorization: Bearer <ваш ключ>'{
"api_version": "v1",
"supplier": {"id": 17, "name": "Городские прогулки", "verification_status": "verified"},
"key": {
"name": "Обмен с сайтом",
"prefix": "vlr_live_Ab3",
"scopes": ["catalog:read", "schedule:read", "schedule:write", "webhooks:manage"],
"expires_at": null
},
"server_time": "2026-08-26T14:03:11+03:00"
}GET /sync/status#
Пульс канала: доезжает ли обмен и когда доехал в последний раз. Прав не требует — читается и тогда, когда права ключа сузили и остальные ручки уже отвечают отказом, иначе причину было бы не увидеть.
curl https://valeravezet.ru/api/organizer/v1/sync/status \
-H 'Authorization: Bearer <ваш ключ>'{
"api_version": "v1",
"server_time": "2026-08-26T03:40:11+03:00",
"counters_window": {"from": "2026-08-26", "to": "2026-08-26"},
"state": "failing",
"directions": [
{
"id": "slots_batch",
"title": "Запись расписания",
"scope": "schedule:write",
"state": "failing",
"last_success_at": "2026-08-26T02:10:04+03:00",
"last_error_at": "2026-08-26T03:38:52+03:00",
"requests": 48,
"errors": 3
}
],
"totals": {"requests": 812, "errors": 3},
"recent_errors": [
{
"at": "2026-08-26T03:38:52+03:00",
"scope": "schedule:write",
"method": "POST",
"path": "/api/organizer/v1/products/42/slots:batch",
"status": 400,
"code": "window_too_wide",
"detail": "Окно больше 370 дней. Присылайте расписание частями."
}
]
}Направления (directions) — по одному на каждое право: чтение каталога, чтение расписания, запись расписания, фид заказов, вебхуки.
state направления и канала целиком:
| Значение | Что это значит |
|---|---|
ok | последняя попытка прошла |
failing | последняя попытка кончилась отказом — вот это и надо чинить |
idle | обменов по направлению не было вовсе; это не поломка, а ещё не начатая работа |
requests и errors — за сутки, границы окна названы в counters_window (календарный день по московскому времени). Отметки last_success_at и last_error_at живут без срока: по ним видно обрыв, который начался неделю назад.
recent_errors — последние десять отказов, они хранятся неделю. В журнале только машинный код и первая строка объяснения: тела ответов не храним, в них попадаются чужие имена.
Сюда попадает отказ на запрос целиком. Отказ по строке пакетной записи не попадает: батч отвечает 200, и такая строка живёт в его собственном ответе — смотрите results.
Счёт идёт по организатору, а не по одному ключу: ключи меняют по одному, чтобы продажи не вставали, и вопрос «когда доехало» — про канал, а не про ключ.
Тот же блок владелец кабинета видит в разделе «Интеграции»: обрыв обмена он замечает там же, где выдавал ключ, а не узнаёт о нём от вас.
GET /products#
Ваши продукты. Экскурсии и многодневные туры лежат вместе и различаются полем product_type (excursion / tour).
Параметры: limit (до 200), cursor, product_type.
curl 'https://valeravezet.ru/api/organizer/v1/products?limit=50&product_type=excursion' \
-H 'Authorization: Bearer <ваш ключ>'{
"items": [
{
"id": 42,
"external_id": "td_88214",
"title": "Ночная Москва",
"product_type": "excursion",
"status": "published",
"experience_format": "group",
"max_group_size": 15,
"duration_hours": "3.0",
"booking_cutoff_hours": "1.0",
"price": {"base": "3500.00", "currency": "RUB", "pricing_type": "per_person", "owner": "organizer"},
"options": [
{
"id": 118,
"kind": "group",
"name": "В группе",
"pricing_type": "per_person",
"price": "3500.00",
"group_size_min": 1,
"group_size_max": 15,
"is_active": true
},
{
"id": 119,
"kind": "individual",
"name": "Индивидуально",
"pricing_type": "per_group",
"price": "18000.00",
"group_size_min": 1,
"group_size_max": 4,
"is_active": true
}
],
"city": "Москва",
"url": "https://valeravezet.ru/excursions/nochnaya-moskva/",
"revision": 2841556122,
"updated_at": "2026-08-20T10:00:00+03:00"
}
],
"next_cursor": null,
"has_more": false
}status:
published— карточка опубликована;paused— организатор сам её выключил;not_published— черновик или карточка на проверке.
Опубликованная карточка может временно не продаваться (истёк документ, пропало место встречи). Причину отдаёт карточка продукта.
Срока бесплатной отмены в продукте нет. Он один для всей площадки — 48 часов до начала поездки в поясе места — и карточкой не задаётся. Поле free_cancellation_hours из ответа убрано 06.09.2026; ревизии продуктов при этом поменялись один раз, каталог достаточно перечитать.
options[] — форматы карточки, в том же порядке, в каком гость видит их в переключателе:
| Поле формата | Смысл |
|---|---|
id | номер формата на площадке; им же слот привязывается к формату |
kind | group или individual; название редактируется, вид нет |
name | как формат подписан человеку: «В группе», «Индивидуально» |
pricing_type | чем меряется цена именно этого формата: per_person или per_group |
price | цена формата, строкой |
group_size_min | со скольких человек формат выезжает |
group_size_max | сколько человек он принимает; null значит, что предел задаёт вместимость слота |
is_active | продаётся ли формат сейчас |
legacy_product_id | прежний номер продукта, если формат раньше был отдельной карточкой; иначе null |
У самого продукта тоже два поля про прежние номера: legacy_product_ids — все номера карточек, слитых в эту (обычно пустой список), и legacy_product_id — номер, которым продукт запросили, если это был прежний номер (см. «Слитые карточки и прежние номера»).
Поля price, experience_format и max_group_size самого продукта повторяют первый формат из этого списка. Выключенный формат из списка не пропадает: он приходит с is_active: false, потому что его расписание и заказы остаются на месте, и option_id в них по-прежнему надо понимать.
Создавать и публиковать продукты через API нельзя. Карточка проходит модерацию, и заводить её программно значило бы либо обходить проверку, либо копить черновики, которых никто не увидит. Продукт заводится в кабинете, API его подхватывает.
GET /products/{id}#
То же самое плюс два поля:
curl https://valeravezet.ru/api/organizer/v1/products/42 \
-H 'Authorization: Bearer <ваш ключ>'{"sales_blocked": true, "sales_blocked_reasons": ["Укажите публичный район или ориентир места встречи."]}PUT /products/{id}/external-ref#
Связать наш продукт со своим номером — чтобы заказ лёг в вашу систему без ручного сопоставления. Нужен ключ с catalog:read и schedule:write.
{"external_id": "td_88214"}curl -X PUT https://valeravezet.ru/api/organizer/v1/products/42/external-ref \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-d '{"external_id": "td_88214", "price_owner": "organizer"}'Привязка объявляет, чья цена: по умолчанию продукт переходит к price_owner: "organizer" — цену держит ваша система, наши правки её не затирают. Если цену по-прежнему ведём мы, передайте "price_owner": "platform". Акции площадки — отдельный слой поверх, они работают в обоих случаях и владельца цены не меняют.
Один номер — один продукт. Попытка повесить занятый номер на второй продукт — 400 external_id_taken.
DELETE /products/{id}/external-ref#
Снять привязку. Цена возвращается площадке: держать её за системой, которая больше не знает об этой карточке, значит заморозить цену.
curl -X DELETE https://valeravezet.ru/api/organizer/v1/products/42/external-ref \
-H 'Authorization: Bearer <ваш ключ>'GET /products/{id}/slots#
Слоты расписания за окно дат.
Параметры: from, to (ГГГГ-ММ-ДД; по умолчанию сегодня и 90 дней вперёд, окно не шире 370 дней), option_id, limit (до 500), cursor.
option_id оставляет в выдаче выезды одного формата. Номер берётся из options[] карточки; чужой формат это 400 option_not_found.
curl 'https://valeravezet.ru/api/organizer/v1/products/42/slots?from=2026-09-01&to=2026-11-30' \
-H 'Authorization: Bearer <ваш ключ>'{
"product_id": 42,
"from": "2026-09-01",
"to": "2026-11-30",
"items": [
{
"slot_id": 91043,
"external_id": null,
"product_id": 42,
"option_id": 118,
"date": "2026-09-12",
"time": "10:00",
"capacity": 15,
"external_reserved": 0,
"valera_allotment": null,
"available": 11,
"price": "3500.00",
"currency": "RUB",
"is_open": true,
"revision": 1884930012
}
],
"next_cursor": null,
"has_more": false
}option_id— формат, в котором продаётся этот выезд: номер изoptions[]карточки. Пусто бывает у выездов, заведённых мимо форматов, и читается это как формат карточки по умолчанию;capacity— сколько мест на слоте всего;external_reserved— сколько из них вы продали вне площадки (свои каналы, другие площадки) и о чём сообщили нам;valera_allotment—null, если пул общий: продаём из той же вместимости, и ваши продажи её уменьшают. Число — выделенный площадке кусок, который внешние продажи не подъедают;available— сколько сейчас свободно у нас;is_open— открыт ли слот к продаже.
GET /bookings#
Фид заказов: всё, что у вас купили, и всё, что с этими заказами стало.
Параметры: limit (до 200), cursor, changed_since (дата или время по ISO 8601; знак «плюс» часового пояса в строке запроса надо кодировать как %2B).
Как читать. Первый запрос без курсора отдаёт заказы с начала; дальше вы ходите с next_cursor, и каждый следующий ответ приносит только новое: заказы, которых вы ещё не видели, и изменения по тем, что уже забрали. Курсор возвращается всегда — даже когда список пуст, — сохраняйте последний.
curl 'https://valeravezet.ru/api/organizer/v1/bookings?limit=200&changed_since=2026-08-25T00:00:00%2B03:00' \
-H 'Authorization: Bearer <ваш ключ>'{
"items": [
{
"id": 55210,
"status": "paid",
"seat_held": true,
"product_id": 42,
"product_external_id": "td_88214",
"slot_id": 91043,
"option_id": 118,
"date": "2026-09-12",
"time": "10:00",
"persons_count": 2,
"price": {
"total": "7000.00",
"prepaid_online": "1750.00",
"due_to_organizer": "5250.00",
"platform_commission_amount": "1750.00",
"affiliate_commission_amount": "0.00",
"platform_commission_percent": "25.00",
"organizer_receives": "5250.00",
"commission_settlement": "withheld_online",
"settlement_note": "Сбор площадки удержан из онлайн-оплаты гостя.",
"currency": "RUB",
"per_ticket": [{"id": "adult", "title": "Взрослый", "count": 2, "price": 3500.0}]
},
"traveler": {"name": "Анна", "phone": null, "email": null},
"contacts_released": false,
"contacts_available": false,
"cancel_reason": null,
"created_at": "2026-08-25T12:00:00+03:00",
"updated_at": "2026-08-25T14:03:11+03:00",
"revision": 2841556122
}
],
"next_cursor": "<непрозрачная строка>",
"has_more": true,
"warnings": []
}Про деньги. prepaid_online — сбор площадки, который гость оплачивает онлайн: это не предоплата за услугу. due_to_organizer — то, что он отдаст вам на месте.
Для учёта берите не разницу, а готовые цифры:
| Поле | Что это |
|---|---|
platform_commission_amount | сбор площадки по этому заказу |
affiliate_commission_amount | доля партнёра-аффилиата, если гость пришёл по партнёрской ссылке; обычно 0.00 |
platform_commission_percent | ставка, по которой посчитан сбор, снимком на момент заказа |
organizer_receives | что получаете вы за заказ целиком |
commission_settlement | что со сбором: удержан, возвращён, ждёт счёта или не начисляется |
settlement_note | то же самое словами, готовое к показу человеку |
organizer_receives + platform_commission_amount + affiliate_commission_amount = total. Выручку считайте по organizer_receives, а не вычитанием из total: на заказе со скидкой площадки или с оплатой баллами разница даст не ту сумму.
Ставка снимается с самого заказа, а не с вашей нынешней настройки. Она менялась, и подставить сегодняшний процент в прошлогодний заказ значило бы отдать вашей бухгалтерии неправду. Считается она до того, как часть сбора ушла гостю: если мы дали на карточку витринную скидку или гость расплатился баллами площадки, в проценте останется договорная ставка, а в сумме будет то, что реально осталось у нас. Ваши деньги от этого не меняются ни на копейку, и organizer_receives такой же, как без скидки.
commission_settlement принимает шесть значений:
| Значение | Что значит |
|---|---|
withheld_online | сбор удержан из онлайн-оплаты гостя |
awaiting_online | заказ ещё ждёт оплаты гостем, сбор пока не удержан |
invoice_pending | заказ прошёл без онлайн-оплаты, сбор не удержан и будет выставлен счётом |
refunded | оплата возвращена гостю, сбор по заказу не удержан |
not_due | сбора по этому заказу нет |
unknown | финансовый снимок заказа неполный |
invoice_pending — это заказы, которые завёл наш менеджер по звонку, и заказы, где гость расплатился на месте: сбор по ним ещё у вас, и мы выставим его отдельно. В отчёте по периоду такой заказ учитывайте так же, как оплаченный: ваша сумма по нему та же самая. Не путайте с awaiting_online — там гость просто ещё не заплатил, и счёта по такому заказу не будет.
unknown бывает у заказов старше нынешней модели расчётов: у них финансовый снимок неполный, и мы не станем додумывать за него цифры. У таких заказов platform_commission_amount, platform_commission_percent и organizer_receives приходят как null — именно null, а не ноль: ноль в отчёте сложился бы в неправду, а пустое место видно.
Все суммы приходят строками ("7000.00") — кроме per_ticket[].price, которая приходит числом (3500.0). Так она отдаётся с первого выпуска, и менять тип мы не станем: это сломало бы тех, кто уже её читает. Разбирайте её как число, а округляйте сами.
Про статус. status в фиде — витринный, тот же, что видите вы в кабинете:
| Значение | Что значит |
|---|---|
messaging | заявка на согласовании: гость спросил, решения ещё нет |
confirmation | ждём подтверждения — вашего или нашего менеджера |
pending_payment | подтверждён, гость ещё не заплатил |
paid | оплачен (или уже проведён) |
cancelled | отменён; почему — в cancel_reason |
Про место. seat_held отвечает на единственный вопрос ночной сверки: занимает этот заказ место на слоте или нет. Заказ у нас живёт без срока, и место за ним держит оплата — а у заявки ваше подтверждение, — но не сам факт заказа. Поэтому висящий неоплаченный заказ приезжает с seat_held: false: вычитать его из остатка не нужно, мы этого тоже не делаем. Не путайте с status: заказ бывает и подтверждённым, и неоплаченным одновременно.
Про формат. option_id говорит, каким форматом гость купил: местом в общей группе или всей компанией. Он выводится из выезда, и по нему заказ ложится в тот же список гостей, в котором лежит у нас. У карточки с одним форматом там всегда один и тот же номер.
Про перенос. Если наш менеджер перенёс заказ на другой слот, он приезжает изменением с новыми slot_id, date и time — и отдельным событием booking.changed, если у вас подписан адрес.
Про контакты — ниже отдельным разделом: телефон и почта приезжают не всем ключам.
Отмену со стороны организатора API не принимает: место, снятое чужой системой в обход площадки, оставляет гостя с оплаченным заказом и без услуги. Если слот у вас закрылся, сообщите об этом — мы разберём случай руками.
Контакты гостя#
Телефон и почта гостя приезжают в traveler только ключу с правом bookings:contacts. Без него они всегда null, а имя дополнительно чистится от вписанных в него телефонов и почты.
Почему право отдельное. Фид отдаёт заказы страницами по двести штук, и ключ, который может забрать их все, забирает вместе с ними базу контактов всех ваших покупателей. Обмену это не нужно: сверять заказы, места и деньги можно и без телефонов. Поэтому право на контакты не идёт в комплекте с правом на заказы, а открывается отдельно.
Правило оплаты остаётся. Право не отменяет его, а добавляется к нему: контакты открываются, когда заказ оплачен и подтверждён, и закрываются обратно через сутки после экскурсии. Два поля отвечают на два разных вопроса:
| Поле | Что значит |
|---|---|
contacts_released | правило оплаты открыло контакты по этому заказу |
contacts_available | контакты лежат в этом ответе — у ключа есть право и не исчерпан суточный предел |
contacts_released: true при contacts_available: false — это и есть «заказ оплачен, но вашему ключу контакты не открыты».
Как получить. Из кабинета — никак: галочки для этого права в форме выпуска ключа нет, а запрос с ним отвечает 400 scope_not_self_service. Право открывает площадка по договору с организатором: напишите нам, чем занимается ваша система и зачем ей контакты. Открытое право видно в списке ключей кабинета и в ответе GET /me.
Суточный предел — 500 броней с контактами на ключ. Считаются брони, а не запросы: одна и та же бронь, прочитанная дважды за день, считается дважды. Сверх предела ответ приходит целиком, но контакты в нём null, а в warnings появляется предупреждение:
{
"warnings": [
{
"code": "contacts_daily_limit",
"detail": "За сутки по этому ключу открыто 500 броней с контактами.",
"limit": 500
}
]
}Это не 429: остальное содержимое ответа вам нужно, и обмен из-за предела не встаёт. Счёт обнуляется в полночь по московскому времени. Если ваша работа честно упирается в предел — напишите, поднимем.
Вебхуки контактов не содержат вовсе, независимо от прав ключа: событие уходит на чужой адрес по сети, и телефонам гостей там не место. За контактами приходите в фид.
POST /products/{id}/slots:batch#
Окно дат целиком, одним запросом: сколько мест, сколько из них вы продали мимо нас, почём и открыта ли дата. Право — schedule:write, лимит записи — 60 запросов в минуту, до 500 слотов в теле.
Слот опознаётся по формату, дате и времени внутри продукта. Второго пространства идентификаторов нет: чтобы писать, наши номера хранить не нужно. Слота с таким ключом ещё не было — он заводится, был — правится.
Формат в ключе появился вместе с options[]. Для карточки с одним форматом это по-прежнему просто дата и время: строка без option_id уходит в формат по умолчанию. У карточки с двумя форматами десять утра двенадцатого сентября это две разные строки, и различает их option_id.
{
"window": {"from": "2026-09-01", "to": "2026-09-30"},
"mode": "merge",
"slots": [
{
"date": "2026-09-12",
"start_time": "10:00",
"capacity": 18,
"external_reserved": 4,
"price": "3500.00",
"is_open": true,
"external_ref": "td_88214",
"if_revision": 1884930012
}
]
}curl -X POST 'https://valeravezet.ru/api/organizer/v1/products/42/slots:batch' \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: slots-42-2026-09' \
-d @slots.jsonТело длинное, и в командной строке его удобнее держать файлом: @slots.json отправит его как есть.
| Поле строки | Смысл |
|---|---|
option_id | формат, в который пишется дата. Необязателен: без него строка уходит в формат карточки по умолчанию, тот же, куда она уходила до появления форматов |
date, start_time | обязательны, вместе с форматом это и есть ключ слота. start_time принимается и под именем time — тем самым, под которым слот приходит при чтении |
capacity | вместимость даты. У новой даты обязательна, у существующей — оставляет прежнюю, если не прислана |
external_reserved | сколько мест вы продали вне площадки |
valera_allotment | выделенный площадке кусок вместимости; null — общий пул |
price | цена даты; null снимает её и возвращает к цене продукта |
is_open | открыта ли дата к продаже |
external_ref | ваш номер слота |
if_revision | «менять, только если слот всё ещё такой» |
Продукт с двумя форматами: тот же час, две строки, различает их option_id. У индивидуального формата вместимость считается компаниями, а не людьми: одна бронь занимает одно место.
{
"window": {"from": "2026-09-01", "to": "2026-09-30"},
"mode": "merge",
"slots": [
{"option_id": 118, "date": "2026-09-12", "start_time": "10:00", "capacity": 18},
{"option_id": 119, "date": "2026-09-12", "start_time": "10:00", "capacity": 1}
]
}mode:
merge(по умолчанию) — трогаем только присланные даты;replace— даты окна, которых в теле не было, закрываются (is_openстановитсяfalse). Не удаляются: на дате может быть наш заказ, и удалённая строка расписания унесла бы его с собой. Дата, которую вы назвали, считается вашей, даже если строка не прошла: разовая ошибка не закроет живую дату.
replace накрывает все форматы продукта разом, а названной дата считается внутри своего формата. У карточки с двумя форматами из этого следует одно: присылайте в окне строки обоих форматов, иначе даты второго закроются как непришедшие.
Что закрылось, перечислено в closed_slots: slot_id, option_id, date, start_time и live_bookings — заказы, которые на закрытой дате остаются в силе.
Ответ — 200, даже если часть строк не принята: окно на месяц это тридцать независимых решений, и ронять все из-за одной строки значило бы заставить вас гадать, что доехало. Код HTTP относится к батчу целиком (не разобрали тело, нет такого продукта, окно шире 370 дней), отказ по строке живёт в строке.
{
"product_id": 42,
"window": {"from": "2026-09-01", "to": "2026-09-30"},
"mode": "merge",
"applied": 1,
"created": 1,
"updated": 0,
"skipped": 0,
"failed": 0,
"closed": 0,
"closed_slots": [],
"results": [
{
"external_ref": "td_88214",
"slot_id": 91043,
"option_id": 118,
"date": "2026-09-12",
"start_time": "10:00",
"status": "created",
"revision": 1884930012,
"capacity": 18,
"external_reserved": 4,
"valera_allotment": null,
"valera_held": 2,
"available": 12,
"is_open": true,
"warnings": []
}
]
}status строки: created, updated, skipped (принято, но менять было нечего) или error. У error есть объект error с code и detail. option_id в результате говорит, в какой формат дата легла; у строки, которая не прошла, его нет.
Отказы по строке:
| Код | Когда |
|---|---|
capacity_below_booked | вместимость ниже уже проданного: наши заказы плюс ваш внешний резерв |
revision_conflict | if_revision не совпал с текущим (или слота с таким ключом ещё нет) — прочитайте слот заново |
slot_outside_window | дата строки вне window |
slot_in_past | дата и время уже прошли |
capacity_required | новая дата без capacity |
date_required, start_time_required | в строке нет ключа слота |
option_not_found | формат из option_id не принадлежит этому продукту |
invalid_option_id | option_id не целое число |
option_required | у продукта нет ни одного формата; так бывает только у карточки, которой формат ещё не завели |
slot_time_conflict | в строке есть и start_time, и time, и они разные |
invalid_slot | строка — не объект |
external_ref_too_long | external_ref длиннее 128 символов |
duplicate_slot | пока писали, дата с этим временем появилась — повторите строку |
invalid_date, invalid_time, invalid_price, invalid_capacity, invalid_external_reserved, invalid_valera_allotment, invalid_allotment, invalid_is_open, invalid_if_revision | формат или предел значения (места, внешний резерв и аллотмент — от 0 до 1000, цена — до 99 999 999,99 ₽) |
Предупреждения (warnings) — работа сделана, но что-то стоит знать:
| Код | Что случилось |
|---|---|
live_bookings | дата закрыта для продаж, а оформленные заказы остаются в силе; в booking_ids — какие |
external_reserved_not_monotonic | присланный внешний резерв меньше известного нам, а if_revision не было: приняли за отставший повтор и не применили |
price_ignored_platform_owned | ценой продукта пока распоряжается площадка |
price_ignored_multiday | стоимость многодневного тура задаётся на карточке, а не на дате старта |
promotion_exit_on_price_increase | цена выросла, и продукт выйдет из акции площадки |
external_ref_taken | ваш номер уже занят другим слотом: места записаны, номер — нет |
Как считаются места#
Площадка отдаёт вам available по той же формуле, по которой продаёт:
available = capacity − наши_продажи − external_reservedУ слота одна общая вместимость — capacity. Продажи в неё идут из двух источников: на площадке продаём мы, и свои продажи мы знаем сами; вне площадки продаёте вы, и о них сообщаете полем external_reserved. Свободные места — это вместимость минус наши продажи минус ваш внешний резерв.
Каждая сторона сообщает свои продажи, а не остаток мест.
Вместимость меняется в любую сторону, но не ниже уже проданного. Пример: вместимость 20, площадка продала 6, вы прислали external_reserved 10 — занято 16. Поставить capacity 16 можно, а 15 уже нет: на такую строку API ответит ошибкой capacity_below_booked. Сначала перенесите или отмените лишнюю бронь, потом уменьшайте вместимость — это не запрет менять вместимость, а отказ пообещать место, которого нет.
valera_allotment меняет только левую часть: выделенный площадке кусок ваши внешние продажи не подъедают. Пустое поле — общий пул. Обе формулы целиком:
valera_allotment пустой: available = capacity − valera_held − external_reserved
valera_allotment задан: available = min(valera_allotment, capacity) − valera_heldvalera_held — это и есть «наши продажи»: сколько мест держат оплаченные заказы и подтверждённые брони площадки. Оно приходит в ответе пакетной записи, растёт от продаж у нас и падает от отмен, и вашей записью не меняется — вы сообщаете только capacity, external_reserved и valera_allotment. Вместе с внешним резервом это и есть пол вместимости: ниже valera_held + external_reserved слот не опускается, и попытка отвечает capacity_below_booked.
Порядок сообщений#
Канальный менеджер повторяет запросы и иногда доставляет их не по порядку. От повтора спасает Idempotency-Key, от перестановки — if_revision. Без if_revision уменьшение external_reserved не применяется: вернуть себе место, которое вы уже продали, дороже, чем не отдать своё. Хотите уменьшить — пришлите строку с if_revision, и снижение примут.
Отмен здесь нет#
Закрытая дата — стоп наших продаж, а не отмена наших заказов. Место на проданной дате мы у гостя не отнимаем: расхождение разбирает менеджер площадки, а не молчаливое исчезновение заказа.
Поэтому два случая, кроме ответа на запрос, уезжают вебхуком schedule.conflict: отказ capacity_below_booked и отброшенное уменьшение external_reserved. В обоих ваша система считает место свободным, а у нас на нём заказ, и решение принимает человек. В details приходит слот, запрошенные числа и то, что мы знаем сами.
Дата, записанная через API, помечается источником «канал организатора»: собственное расписание площадки её больше не трогает и остаток мест не переписывает.
Идемпотентность#
Все пишущие ручки принимают заголовок Idempotency-Key — любую строку до 255 символов, свою на каждую операцию:
PUT /products/{id}/external-ref
DELETE /products/{id}/external-ref
POST /products/{id}/slots:batch
POST /webhooks
POST /webhooks/{id}
DELETE /webhooks/{id}- повтор с тем же ключом и тем же телом вернёт тот самый ответ и ничего не сделает второй раз (в ответе будет
Idempotent-Replay: true); - тот же ключ с другим телом —
409 idempotency_key_reused; - повтор, пока первый запрос ещё выполняется, —
409 idempotency_in_progress, повторите чуть позже.
Ключ помнится сутки, считается вместе с адресом запроса: один и тот же ключ, присланный на два разных продукта или адреса уведомлений, повтором не считается и выполнится для каждого. Своя строка на операцию всё равно надёжнее.
Заголовок необязателен. Без него ручка работает как обычно, и повтор выполняется заново. Для большинства ручек это безобидно: привязка номера, пакетная запись окна и удаление адреса приводят к тому же состоянию, сколько раз их ни повтори. Разница видна там, где повтор создаёт или меняет: без ключа второй POST /webhooks с тем же адресом получит 400 webhook_url_taken, а второй POST /webhooks/{id} сменит секрет ещё раз. Присылайте ключ, если между запросом и ответом у вас бывают обрывы.
Схема#
Машинное описание: https://valeravezet.ru/api/organizer/schema/. В нём ровно эти тринадцать ручек, у каждой перечислены параметры, тело запроса и ответ, а адрес сервера полный.
Это обычный OpenAPI: файл скармливается Postman и генераторам клиентов как есть — отдельная коллекция для этого не нужна.
Человеческое: https://valeravezet.ru/api/organizer/docs/ (Swagger UI, с кнопкой «Try it out») и https://valeravezet.ru/api/organizer/redoc/, если удобнее читать подряд.
Этот текст целиком, со всеми примерами, опубликован на https://valeravezet.ru/developers/ — туда удобно давать ссылку тому, кто будет писать интеграцию.
Вебхуки#
Чтобы не опрашивать фид, укажите адрес — и мы сами постучимся, как только что-то произошло.
Адреса заводятся в кабинете («Интеграции») или через API ключом с правом webhooks:manage:
GET /webhooks список адресов
POST /webhooks {"url": "https://…", "events": ["booking.paid"]}
POST /webhooks/{id} сменить секрет подписи
DELETE /webhooks/{id} убрать адресСписок адресов:
curl https://valeravezet.ru/api/organizer/v1/webhooks \
-H 'Authorization: Bearer <ваш ключ>'{
"items": [
{
"id": 3,
"url": "https://example.ru/valera/hook",
"events": ["booking.paid", "booking.cancelled"],
"is_active": true,
"status": "active",
"consecutive_failures": 0,
"last_success_at": "2026-08-25T14:03:11+03:00",
"last_failure_at": null,
"last_error": "",
"created_at": "2026-08-20T10:00:00+03:00"
}
]
}Секрета подписи в списке нет: он приходит один раз при заведении адреса и при смене секрета. Забыли — смените секрет, посмотреть старый неоткуда. status адреса — active или failing, consecutive_failures считает неудачи подряд, last_error показывает, что ответил ваш сервер в последний раз.
Новый адрес. Без поля events он получит все пять событий; секрет подписи придёт в ответе один раз, полем secret:
curl -X POST https://valeravezet.ru/api/organizer/v1/webhooks \
-H 'Authorization: Bearer <ваш ключ>' \
-H 'Content-Type: application/json' \
-d '{"url": "https://example.ru/valera/hook", "events": ["booking.paid", "booking.cancelled"]}'Ответ — 201 и тот же объект, что в списке, плюс поле secret. Это и есть секрет подписи; сохраните его сразу, второй раз он не придёт:
{
"id": 3,
"url": "https://example.ru/valera/hook",
"events": ["booking.paid", "booking.cancelled"],
"is_active": true,
"status": "active",
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"last_error": "",
"created_at": "2026-08-20T10:00:00+03:00",
"secret": "whsec_XXXXXXXXXXXX"
}Сменить секрет подписи — например, когда старый утёк. Новый приходит в ответе тем же полем secret, старый перестаёт подходить сразу:
curl -X POST https://valeravezet.ru/api/organizer/v1/webhooks/7 \
-H 'Authorization: Bearer <ваш ключ>'Убрать адрес:
curl -X DELETE https://valeravezet.ru/api/organizer/v1/webhooks/7 \
-H 'Authorization: Bearer <ваш ключ>'Адрес должен быть на https и вести на публичный сервер: во внутреннюю сеть мы не стучимся и по переадресациям не ходим. Логин и пароль в адресе не поддерживаются — подлинность запроса подтверждает подпись. Не подошедший адрес — 400 invalid_webhook_url.
Адресов у организатора не больше пяти (400 webhook_limit_reached), и один и тот же адрес дважды не заводится (400 webhook_url_taken). Чужой или уже удалённый адрес в пути — 404 webhook_not_found.
События#
| Тип | Когда |
|---|---|
booking.created | заказ создан |
booking.paid | заказ оплачен — с этой секунды место занято |
booking.cancelled | заказ отменён |
booking.changed | заказ изменился: статус, число участников, сумма или дата |
schedule.conflict | расхождение по слоту, которое разбирает наш менеджер |
ping | проверка связи, отправленная из кабинета по кнопке |
На ping не подписываются: он приходит только тогда, когда организатор нажал «Проверить связь», и всегда с полем test: true в теле. Заказа за ним нет — принимать его как продажу нельзя. У настоящих событий поля test нет вовсе.
{
"event_id": "…",
"type": "ping",
"test": true,
"created_at": "2026-08-27T11:20:04+03:00",
"supplier_id": 17,
"api_version": "v1",
"data": {"note": "Проверка связи из кабинета организатора…", "endpoint_id": 3}
}booking.changed приходит и на перенос заказа на другой слот: у вас при этом меняется день в расписании, и узнать об этом из кабинета постфактум — худший из возможных способов.
Поле events необязательное: не присылайте его вовсе — и адрес получит все пять событий. А вот пустой список [] мы не принимаем — 400 invalid_events: «подпишись на всё» и «не подписывай ни на что» не должны выглядеть одинаково, иначе опечатка в сборке списка молча превращается в подписку на всё.
Хотите выбрать — перечислите нужные:
{"url": "https://…", "events": ["booking.paid", "booking.cancelled"]}Неизвестное событие в списке — тоже 400 invalid_events, с перечислением того, что мы не узнали. Порядок значения не имеет: в ответе события всегда идут в том порядке, в каком перечислены в таблице выше.
Тело#
{
"event_id": "0f1d8f2a-7c6b-4f0e-9a1d-3a2b4c5d6e7f",
"type": "booking.paid",
"created_at": "2026-08-25T14:03:11+03:00",
"supplier_id": 17,
"api_version": "v1",
"data": {
"booking_id": 55210,
"status": "confirmed",
"state": "paid",
"paid": true,
"product_id": 42,
"external_ref": "td_88214",
"slot_id": 91043,
"option_id": 118,
"date": "2026-09-12",
"time": "10:00",
"persons_count": 2,
"total": "7000.00",
"prepaid_online": "1750.00",
"due_to_organizer": "5250.00",
"platform_commission_amount": "1750.00",
"affiliate_commission_amount": "0.00",
"platform_commission_percent": "25.00",
"organizer_receives": "5250.00",
"commission_settlement": "withheld_online",
"settlement_note": "Сбор площадки удержан из онлайн-оплаты гостя.",
"seat_held": true,
"cancel_reason": null,
"revision": 2841556122
}
}event_id не меняется между повторами — по нему отличайте повтор от второго события. Отвечайте 2xx сразу, а работу делайте у себя: мы ждём ответа десять секунд.
Два словаря статуса, и они разные. status — сырой статус заказа, как он лежит у нас: on_request, pending, confirmed, cancelled, completed. state — тот же витринный статус, что в GET /bookings: messaging, confirmation, pending_payment, paid, cancelled (таблица выше). Читайте тот, который удобнее вашей системе: сырой ближе к нашей записи заказа, витринный — к тому, что видит гость.
Главное про них: confirmed в сыром статусе не означает оплату. Заказ подтверждаете и вы, и менеджер площадки, а деньги при этом могут не прийти вовсе. Про деньги отвечает paid — дошёл ли до площадки онлайн-платёж. Расчёт на месте оставляет его false: это про платёж у нас, а не про то, рассчитался ли гость с вами.
prepaid_online и due_to_organizer — те же две суммы, что в фиде: сбор площадки, уплаченный онлайн, и остаток, который гость отдаст вам на месте.
platform_commission_amount, affiliate_commission_amount, platform_commission_percent, organizer_receives, commission_settlement и settlement_note — те же значения, что в price у фида, и считаются они тем же кодом. Расхождение между событием и фидом означало бы расхождение в заказе, и мы его не допускаем. Что каждое из них значит, разобрано в разделе про фид.
cancel_reason — почему заказ отменили; вне отмены null.
seat_held — занимает ли заказ место на слоте. Неоплаченный заказ места не держит, и в событии это видно так же, как в фиде.
option_id — формат, которым заказ куплен: тот же номер, что в фиде и в options[] карточки.
У schedule.conflict тело устроено иначе: заказа в нём нет, есть слот. В data приходят slot_id, product_id, option_id, date и time, причина расхождения в reason, а запрошенные вами числа и то, что мы знаем сами, в details.
Контактов гостя в теле события нет и не будет — ни имени, ни телефона, ни почты, и право bookings:contacts этого не меняет. Событие уходит на чужой адрес по сети и оседает в чужих логах; за контактами приходите в GET /bookings.
Подпись#
X-Valera-Signature: t=1756209791,v1=5257a869e7ecebeda32affa62cdca3fa793333c2bda4f4d3ffb6b8e0d0d69a0d
X-Valera-Event-Id: 0f1d8f2a-7c6b-4f0e-9a1d-3a2b4c5d6e7f
X-Valera-Event-Type: booking.paidПодписывается строка <t>.<тело запроса как есть> секретом адреса, HMAC-SHA256. Проверять надо и время: окно — пять минут, иначе перехваченный запрос можно повторить когда угодно. Сравнивать подпись — функцией постоянного времени (hmac.compare_digest, crypto.timingSafeEqual).
import hmac, hashlib, time
def valid(secret: str, header: str, raw_body: str) -> bool:
parts = dict(item.split('=', 1) for item in header.split(',') if '=' in item)
if abs(int(time.time()) - int(parts['t'])) > 300:
return False
expected = hmac.new(
secret.encode(), f"{parts['t']}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts['v1'])То же на Node.js:
const crypto = require('node:crypto')
function valid(secret, header, rawBody) {
const parts = {}
for (const item of header.split(',')) {
const at = item.indexOf('=')
if (at > 0) parts[item.slice(0, at).trim()] = item.slice(at + 1).trim()
}
if (!/^\d+$/.test(parts.t ?? '') || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) {
return false
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
const got = Buffer.from(parts.v1 ?? '', 'utf8')
const want = Buffer.from(expected, 'utf8')
// timingSafeEqual требует одинаковой длины — иначе бросает, а не отвечает false.
return got.length === want.length && crypto.timingSafeEqual(got, want)
}И на PHP:
<?php
function valera_signature_valid(string $secret, string $header, string $rawBody): bool
{
$parts = [];
foreach (explode(',', $header) as $item) {
$at = strpos($item, '=');
if ($at === false || $at === 0) {
continue;
}
$parts[trim(substr($item, 0, $at))] = trim(substr($item, $at + 1));
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > 300) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}Три места, где эта проверка обычно ломается:
- Тело нужно сырое. Подпись считается по тем байтам, которые пришли, а не по результату разбора JSON: разбор и обратная сборка переставят пробелы, и подпись перестанет сходиться. В Django это
request.body, в Express —express.raw()илиverify-обработчик уexpress.json(), в PHP —file_get_contents('php://input'). - Окно времени обязательно. Без сверки
tперехваченный запрос повторяют когда угодно, и подпись у него настоящая. - Сравнение — за постоянное время. Обычное сравнение строк отвечает тем быстрее, чем раньше разошлись байты, и по времени ответа подпись подбирают посимвольно. Отсюда
hmac.compare_digest,crypto.timingSafeEqualиhash_equalsв примерах выше.
Повторы#
Не получили 2xx — повторим через минуту, пять минут, полчаса, два часа и шесть часов. После последней неудачи адрес переходит в состояние «не отвечает», и в кабинете появляется плашка: молча переставать слать события мы не станем. Как только доставка прошла, состояние возвращается само.
Журнал доставок#
В кабинете, под каждым адресом, лежит журнал: какое событие уходило, когда, сколько было попыток, что ответил ваш сервер (первые два килобайта тела) и когда будет следующий заход. Раскрытие строки показывает тело события как оно ушло по проводу — по event_id его же можно найти у себя в логах.
Оттуда доставку можно повторить руками. Уйдёт то же событие с тем же event_id: у себя вы опознаете повтор и второй раз работу не сделаете. Расписание автоматических повторов такая кнопка не сдвигает — это ровно одна попытка, сверх лестницы.
Сроки хранения. Успешная доставка живёт тридцать дней. Неудачная — девяносто, а ответы вашего сервера у неё те же тридцать: дальше остаётся сама запись, уже без тел. Контактов гостя в журнале нет по той же причине, по какой их нет в событиях.
История изменений#
Здесь отмечаются изменения контракта: что появилось и в какой версии.
Версия 1, 29 августа 2026 — сбор площадки в заказе. У заказа в фиде (внутри price) и в теле события booking.* появились platform_commission_amount, affiliate_commission_amount, platform_commission_percent, organizer_receives, commission_settlement и settlement_note. Раньше комиссию приходилось выводить вычитанием, и на заказе со скидкой площадки, с оплатой баллами или без онлайн-оплаты она выходила не та. Ставка отдаётся снимком на момент заказа, а не нынешней настройкой. У заказов старше нынешней модели расчётов суммы и ставка приходят как null, а commission_settlement — unknown. Ломающих изменений нет: прежние total, prepaid_online и due_to_organizer остались как были.
Версия 1, 28 августа 2026 — прежние номера слитых карточек. Карточка, которая стала форматом другой, сохраняет свой номер: под ним работают GET /products/{id}, чтение и пакетная запись расписания и привязка внешнего номера, а означает он «родитель, формат». Появились поля legacy_product_id (у продукта, формата, слота, заказа в фиде и в теле события) и legacy_product_ids у продукта. Слитая копия из GET /products уходит. Ломающих изменений нет.
Версия 1, 28 августа 2026 — форматы продукта. У карточки появился список форматов options[]: та же экскурсия в общей группе и своей компанией, с разной ценой и разным размером компании. Номер формата приходит полем option_id у слота, у заказа в фиде и в теле вебхука, принимается строкой пакетной записи и параметром GET /products/{id}/slots. Ключ слота стал «продукт, формат, дата, время». Состав форматов вошёл в revision продукта, option_id вошёл в revision слота. Ломающих изменений нет: option_id необязателен везде, а у продукта с одним форматом ответы прежние.
Версия 1, август 2026 — первый выпуск. Ключи с правами, сроком жизни и выдачей из кабинета; каталог продуктов и карточка продукта; привязка внешнего номера с передачей цены организатору; чтение расписания и пакетная запись окна дат — режимы merge и replace, проверка if_revision, отдельное решение по каждой строке; фид заказов с курсором и changed_since, разбором денег на сбор площадки и остаток организатору и полем seat_held; контакты гостя отдельным правом bookings:contacts; вебхуки на пять событий с подписью X-Valera-Signature, лестницей повторов и журналом доставок в кабинете; GET /sync/status, чтобы видеть, доезжает ли обмен; Idempotency-Key у всех пишущих ручек; машинная схема OpenAPI со Swagger UI.
Чего ещё нет#
Здесь перечислено то, чего в v1 нет, — чтобы это не выяснялось посреди работы.
Постоянной тестовой среды. Отдельного контура, в который можно зайти и попробовать, у нас пока нет. В планах — тестовые ключи прямо в боевом кабинете, рядом с боевыми: тогда переключение между средами сводится к смене ключа.
Начинать без него не страшно. Чтение — каталог, слоты, фид заказов — не меняет ничего вообще, так что первые запросы можно делать смело. Запись касается только ваших собственных предложений: чужого расписания вы не видите и тронуть не можете, а свою первую правку разумно сделать на будущей дате одного продукта.
Создания и публикации продуктов. Карточка проходит модерацию, поэтому заводится в кабинете. API подхватывает её сразу после публикации и дальше ведёт расписание и цену.
Заведения и правки форматов. Список options[] читается, но не пишется: формат живёт на карточке, а карточка заводится в кабинете. Расписание и цены дат по каждому из форматов API ведёт полностью.
Отмены заказа со стороны организатора. Место, снятое чужой системой в обход площадки, оставило бы гостя с оплаченным заказом и без услуги. Расхождение приезжает событием schedule.conflict и попадает к менеджеру площадки.
Цен многодневного тура по датам старта. Стоимость такого тура задаётся на карточке; price в строке пакетной записи для него игнорируется, о чём приходит предупреждение price_ignored_multiday.
Программного повтора вебхука. Расписание автоматических повторов фиксированное: минута, пять минут, полчаса, два часа, шесть часов. Повторить доставку руками можно из журнала в кабинете, отдельной ручки для этого нет. Если события потеряны пачкой, доберите их из GET /bookings с changed_since — фид для того и нужен.
