Разработчикам
Публичный API организатора
Содержание
https://valeravezet.ru/api/organizer/v1/
Для организаторов, у которых есть своя система: заказы, цены и остатки мест живут в ней, а площадка — ещё один канал продаж. API даёт программный доступ к собственному кабинету: каталог, расписание, заказы.
Версия зашита в путь. Пока живёт v1, ломающих изменений в нём не будет: новые поля добавляются, старые не исчезают и смысла не меняют. Незнакомые поля в ответах игнорируйте — они будут появляться.
Ключ#
Ключ заводит владелец кабинета: Кабинет организатора → Интеграции. По умолчанию раздел не видит больше никто: ключ открывает доступ ко всем заказам и всему расписанию, а это уровень договора.
Сотруднику раздел открывают поимённо — разрешением «Интеграции» в «Сотрудниках». Связывает системы обычно приглашённый программист, и выдать ему ровно этот раздел лучше, чем переслать ключ в переписке или отдать свой вход; как это делается — ниже.
Ключ показывается один раз, сразу после выдачи. У нас остаётся только его свёртка, восстановить ключ нельзя — потерянный отзывают и заводят новый.
Ключей может быть несколько. Так меняют ключ, не останавливая обмен: выпустили второй, переключили свою систему, отозвали первый.
Ключ живёт, пока подтверждена анкета организатора. Ушла анкета на доработку — доступ закрывается сам и возвращается вместе с подтверждением.
Не все права выдаёт кабинет: контакты гостя (bookings:contacts) площадка открывает по договору — подробнее.
Как передавать#
Authorization: Bearer vlr_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxКлюч равен паролю: не кладите его в репозиторий, не пересылайте в переписке, храните там же, где остальные секреты своей системы.
Права#
Ключ умеет ровно то, что ему открыли при выдаче:
| Право | Что даёт |
|---|---|
content:write | обновлять содержимое карточек с модерацией; выдаёт только владелец, по умолчанию выключено |
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 необязателен всюду, где его можно прислать. Строка пакетной записи без него уходит в формат, которым карточка представлена, — первый активный по sort_order, 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, а не тихий возврат к началу.
Запись форматов#
POST /products/{id}/options:batch, право content:write, обязательный Idempotency-Key.
Цена формата должна быть строго больше нуля (от 0,01 ₽). Бесплатные места задаются тарифами. Ноль возвращает invalid_option с объяснением в error.detail.price; остальные строки пакета обрабатываются независимо.
Правила цен на даты и недельный план принадлежат конкретному option_id. Выключение формата или смена sort_order не передаёт его правила соседнему. Цена, подпись, вид и вместимость карточки показывают первый активный формат по sort_order, id; запись цены через content меняет именно этот формат. При выключении формат исчезает из виджета дат. Новое бронирование и оплата по ссылке отклоняются с объяснением, что формат больше не продаётся; существующие заказы, их суммы и история остаются. Уже проведённые платежи не отменяются.
До 100 строк в options; формат создаётся без номера, существующий адресуется через option_id (или id из чтения). external_id длиной до 128 символов уникален среди форматов организатора: повтор с новым ключом обновляет тот же формат.
{"options":[
{"external_id":"private-42","kind":"individual","name":"Своей компанией",
"pricing_type":"per_group","price":"12000.00","group_size_min":1,
"group_size_max":4,"is_active":true,"sort_order":1},
{"option_id":81,"is_active":false}
]}Ответ 200: results[] с index, option_id, status (created, updated, deleted или error). При успехе записи возвращается option в форме чтения; при отказе — error.code и error.detail. Ошибка строки не отменяет соседние строки. Повтор ключа с тем же телом возвращает сохранённый ответ, с другим телом — 409.
Все изменения форматов применяются сразу, как в кабинете. До шести форматов, вид существующего формата не меняется. Групповой формат имеет цену за человека; цена неотрицательна, размер группы от 1 до 1000, минимум не больше максимума. Первый активный формат по sort_order, затем option_id определяет цену и вид карточки; запись цены через content меняет этот же формат. Все форматы можно выключить, как в кабинете; последний формат нельзя удалить. Порядок — неотрицательное целое до 32767.
Удаление: {"option_id":81,"action":"delete"}. Будущие выезды с живыми заказами запрещают и выключение, и удаление (option_has_bookings); отменённый заказ формат не держит. Любые выезды или прежний номер слитой карточки запрещают удаление (option_in_use), связи сохраняются. Формат, которым представлена карточка, не удаляется вовсе (option_is_default) — как в кабинете: на нём держатся цена, вид и вместимость самой карточки. Прежний legacy_product_id в адресе позволяет править только его формат. Пропущенные форматы и поля сохраняются. GET /products/{id} возвращает options[] с теми же полями, включая option_id, external_id, sort_order; legacy_product_id — справочное поле, менять его нельзя.
Версия объекта#
У продуктов и слотов есть revision — целое число, слепок изменяемого состояния. Это не растущий счётчик: сравнивать его можно только на равенство. Совпал с тем, что вы видели в прошлый раз, — ничего не поменялось.
В revision продукта входит и состав форматов. Появился второй формат, сменились его цена, название или размер компании: отметка стала другой. В revision слота входит option_id, потому что выезд, перевешенный на другой формат, это другой выезд. Иначе if_revision принял бы такую правку за «ничего не изменилось».
Программа по дням и проживание#
PUT /products/{id}/program, право content:write, обязательный Idempotency-Key. Передайте целиком duration_days, nights, itinerary, accommodation_options, photo_days и текущий revision из GET /products/{id}. Отсутствие любой из этих частей — 400, несовпадение версии — 409 revision_conflict: перечитайте карточку и согласуйте изменения. Прежний номер слитой карточки здесь не принимается.
{
"revision": 274019826,
"duration_days": 2,
"nights": 1,
"includes_accommodation": false,
"itinerary": [
{"day_number":1,"title":"Приезд","description":"Прогулка по городу."},
{"day_number":2,"title":"Загородная поездка","description":"Возвращение вечером."}
],
"accommodation_options": [
{"id":25,"title":"Гостевой дом","stay_type":"guest_house",
"comfort_level":"simple","description":"Двухместные номера.","order":0}
],
"photo_days": {"301":1,"302":2,"303":null},
"change_reason":"Уточнили маршрут и размещение"
}Номера фотографий берутся из GET /products/{id}/photos, включая ожидающие модерации кадры. Обложка main относится ко всей программе и сюда не входит. null и пропуск номера снимают привязку к дню, {} снимает всю разметку. Чужие, удалённые и отсутствующие в текущей галерее номера не принимаются. День кадра должен входить в новую программу.
Дни идут подряд от 1, по одному на каждый день поездки; длительность от 2 до 365 дней. Ночи от 0 до количества дней, а с включённым проживанием — не меньше одной, как в кабинете. includes_accommodation можно передать в этом же пакете; если его нет, сохраняется эффективное значение с учётом ожидающей модерации. Включить проживание вправе только организатор с подтверждённым правом продажи турпродукта и записью в реестре туроператоров. Варианты размещения сами по себе не включают ночёвку в общую цену.
До 20 вариантов проживания. Новый вариант идёт без id или с id:null, сохранённый — со своим номером, чтобы его фотографии остались на месте. Повторяющиеся и чужие номера отклоняются. Порядок задаётся порядком элементов, поле order в ответе совпадает с ним. Пропущенные варианты удаляются вместе с их фотографиями при применении пакета. Пустой список очищает коллекцию.
Тексты очищаются от HTML перед проверкой длины, изменённые пути перечисляются в normalized_fields. Пакет принимается или отклоняется целиком. У опубликованного тура все части ждут общей модерации; нужна change_reason. У черновика сохраняются сразу, без отправки на публикацию. Действуют общие ограничения модерации и квота 60 правок содержимого/программы в час на организатора.
Ответ 200 содержит revision_id, новый revision, status, accepted_fields, rejected_fields (пустой список) и normalized_fields. status:published для черновика означает сохранение данных, статус публикации самой карточки не меняется. Повтор того же ключа и тела возвращает первый ответ даже после смены версии; тот же ключ с другим телом даёт 409.
GET /products/{id} возвращает перечисленные поля на верхнем уровне в форме записи. Это сохранённая публичная версия (у черновика — его данные). pending_program содержит полный ожидающий комплект либо null. content_revision сообщает состояние последней API-правки и причины отказа. В revision входят также программа, проживание, разметка фото и ожидающие правки из кабинета: новая запись не затрёт незамеченное параллельное изменение.
Содержимое карточки#
PATCH /products/{id}/content принимает только те поля, которыми располагает ваша система. Право content:write выключено по умолчанию. Его выбирает только владелец кабинета при выпуске ключа в разделе «Интеграции»; сотрудник с доступом к интеграциям выдать это право не может. Уже выданный ключ не меняется: выпустите новый с нужными правами.
Все поля необязательны, но запрос должен содержать хотя бы одно поле карточки. Поля, которых нет в запросе, фотографии и форматы сохраняются. Используйте номер основной карточки: прежний номер слитого формата получает 409 с кодом canonical_product_required.
| Поле | Значение и проверка |
|---|---|
title | Название, до 200 символов, не пустое |
short_description | Короткое описание, до 300 символов |
description | Полное описание |
included, not_included | Что включено и не включено в стоимость, текст |
duration_hours | Длительность экскурсии в часах, больше нуля; у многодневного тура отклоняется |
meeting_point | Публичное место встречи, до 300 символов |
meeting_point_details | Подробные инструкции к месту встречи |
latitude, longitude | Координаты: от −90 до 90 и от −180 до 180; null очищает координату |
price | Цена в рублях, неотрицательная, до двух знаков после запятой |
pricing_type | per_person или per_group; у сборной группы допустимо только per_person |
commercial_terms | Объект с разделами tariffs и add_ons, описан ниже |
org_details, timing | Организационные детали и тайминг, текст; опубликованная версия ждёт модерации |
end_point | Конец маршрута, до 300 символов |
max_group_size | Максимум участников, положительное целое |
booking_cutoff_hours | За сколько часов закрыть бронирование, неотрицательное число |
minimum_order_amount | Минимальная сумма заказа, неотрицательная; только для цены за человека |
movement_type | Способ передвижения: on_site, on_foot, by_car, by_bus, by_boat, by_bike, by_yacht, by_kayak, by_motorcycle, on_horseback, by_air, combined, other |
includes_transport, transport_model | Включён ли транспорт и его описание; у многодневного тура ждут модерации. Снятие флага очищает описание |
includes_accommodation, accommodation_intro | Включено ли проживание и вводный текст. Только для многодневного тура; включение требует уже заданных ночей и проверенной реестровой записи организатора. Ждут модерации |
discount_percent, discount_until | Скидка: 0 или от 5 до 70 шагом 5; при ненулевой скидке обязательна дата YYYY-MM-DD, не в прошлом. 0 очищает срок |
children_allowed | Можно ли с детьми: true, false, null — не указано |
confirmation_mode | instant или manual; для сборной экскурсии только instant |
weekly_schedule | Недельный календарь: например {"mon":{"times":["10:00"]}}; для индивидуального формата {"mon":{"start_time":"10:00","end_time":"18:00"}} |
schedule_exceptions | Исключения по датам: {"2026-12-31":{"closed":true}} или такой же временной интервал/список времени, как в недельном календаре |
weekday_mon … weekday_sun | Флаги дней недели; передавайте согласованно с weekly_schedule, как в кабинете |
booking_horizon_months | Горизонт календаря: от 3 до 6 месяцев |
booking_on_request | Принимается только false; для продаж нужны даты, ручное подтверждение задаёт confirmation_mode |
change_reason | Причина правки для редактора, до 500 символов; обязательна при изменении текста опубликованной карточки |
Проверки полей и сочетаний такие же, как в редакторе кабинета. Ошибочные поля попадают в rejected_fields, остальные проверяются повторно и сохраняются. Если общая модерация откажет, например из-за отсутствующей причины или неподтверждённого телефона, вся принятая часть откатывается. При отсутствии пригодных полей ответ 400, правка не создаётся.
У опубликованной карточки название, описания, организационные детали, тайминг, включённое и не включённое в стоимость, доплаты add_ons ждут модерации в общей очереди CRM. Пока редактор не одобрил правку, витрина показывает прежние тексты и доплаты. Цена, единица цены, длительность, место встречи, координаты и тарифы tariffs, размер группы, минимум заказа, закрытие брони, конец маршрута, скидка и её срок, допуск детей, способ передвижения и настройки календаря применяются сразу, как в кабинете. У многодневного тура включение проживания, вводный текст проживания и транспорт ждут модерации вместе с описанием. Существующая отложенная правка и фотографии сохраняются при последующих частичных запросах.
У черновика данные сохраняются сразу, но карточка остаётся черновиком и не появляется на витрине. Первая публикация выполняется через кабинет. Для карточки, уже поданной на первую модерацию, изменения остаются в этой очереди. Новый запрос может дополнить текущую редакцию, отдельную заявку модерации на каждое сохранение он не создаёт.
Тарифы и доплаты#
commercial_terms.tariffs — до 10 тарифов с полями id, category, name, price, is_default. Непустой список должен содержать ровно один основной тариф (is_default: true); тарифы доступны при цене за человека. category: standard, child, school, student, pensioner, custom.
commercial_terms.add_ons — до 20 доплат с полями id, name, description, price, pricing_type (per_order или per_person), booking_cutoff_hours (0–8760 часов или null — в любое время). Необязательное child_price — детская цена только для per_person: число от 0 до взрослой price включительно. 0 означает «детям бесплатно»; чтобы убрать детскую цену, передайте доплату без child_price. null не является ценой. Она применяется только к участникам по тарифам категории child, включая несколько детских тарифов. school, student и остальные категории оплачивают взрослую цену. Без тарифов или без child_price расчёт остаётся прежним. Изменение детской цены проходит ту же модерацию, что и остальные поля доплаты.
Пример: {"id":"lunch","name":"Обед","description":"Обед в кафе","price":"800.00","child_price":"400.00","pricing_type":"per_person"}. Для двух взрослых и двух детей доплата составит 800 × 2 + 400 × 2 = 2400 ₽; при child_price: "0.00" — 1600 ₽. В снимке заказа сохраняются взрослая unit_price, детская child_price, adult_count, child_count и subtotal. Без тарифов или детской цены новые поля в снимок не добавляются. Скидки, комиссия и предоплата вычисляются от полученного итога по прежним правилам.
Название и описание доплаты обязательны. Цена тарифа или доплаты — от 0 до 10 000 000 рублей. Идентификаторы: 1–40 латинских букв, цифр, _ или -, уникальные во всём объекте. Название тарифа — до 80 символов, доплаты — до 100, описание доплаты — до 1000.
Переданный список заменяет свой раздел целиком, [] очищает его. Непереданный раздел сохраняется, в том числе из текущей редакции на модерации: запрос только с add_ons не стирает тарифы. null для всего commercial_terms очищает оба раздела. Форматы options[] и варианты проживания этой ручкой не изменяются.
В content также доступны только для чтения free_cancellation_hours, product_type, activity_kind, experience_format, destination (ID направления), destinations (ID объектов показа), rest_kinds, comfort_level, accommodation_types, activity_level, duration_days, nights. Попытка записи попадёт в rejected_fields. Справочники меняются через кабинет/редактора, длительность многодневного тура — вместе с программой по дням. Фото и видео изменяются отдельными ручками раздела «Фотографии и видео».
Обычный текст на входе#
Тексты карточки хранятся без HTML. API очищает присланную разметку до проверки длины и передаёт в общую модерацию уже обычный текст. Это относится к title, short_description, description, included, not_included, meeting_point, meeting_point_details, org_details, timing, end_point, transport_model, accommodation_intro, change_reason, названиям тарифов (tariffs[].name), названиям и описаниям допуслуг (add_ons[].name, add_ons[].description). Идентификаторы, URL файлов, перечисления и числовые поля не очищаются как текст.
Правила преобразования:
<p>и другие блоки разделяются пустой строкой,<br>— переводом строки;- пункты
<ul>и<ol>превращаются в строки с•; <a href="https://example.org/map">Карта</a>превращается вКарта (https://example.org/map);- HTML-сущности раскодируются, прочие теги и комментарии удаляются; содержимое
script,style,templateне переносится; - подряд остаётся не больше двух переводов строки, пробелы по краям убираются.
Например, "<p>Встреча<br>Прогулка</p><p>Обед</p>" сохраняется как "Встреча\nПрогулка\n\nОбед". Проверка длины считается по этому результату. Если обязательное поле (например, название карточки или допуслуги) осталось пустым, оно отклоняется с причиной в rejected_fields. Необязательное поле можно очистить; остальные допустимые поля запроса сохраняются по прежним правилам.
Ответ PATCH с результатом проверки полей содержит normalized_fields — пути текстов, которые изменились при очистке, включая отклонённые. Неизменённый обычный текст туда не попадает; если ничего не изменилось, список пуст. Для вложенных значений указываются индексы с нуля:
{"normalized_fields":["description","commercial_terms.add_ons.0.description"]}Это сообщение о преобразовании входа, а не подтверждение публикации: проверяйте также accepted_fields, rejected_fields и status. Отсутствующие поля и разделы commercial_terms не очищаются задним числом. Идемпотентный повтор возвращает тот же список; исходное тело запроса по-прежнему определяет конфликт идемпотентности. Порядок и условия модерации сохраняются.
Пример запроса и ответа#
PATCH /api/organizer/v1/products/42/content
Authorization: Bearer <ваш ключ>
Content-Type: application/json
Idempotency-Key: product-42-edit-2026-09-18-1
{
"title": "Прогулка по старому городу",
"description": "Покажем купеческие дома и расскажем историю набережной.",
"price": "2500.00",
"duration_hours": "2.5",
"change_reason": "Уточнили маршрут и продолжительность"
}{
"revision_id": 731,
"status": "pending_moderation",
"accepted_fields": ["title", "description", "price", "duration_hours"],
"rejected_fields": [],
"normalized_fields": []
}revision_id — уникальный номер записи в истории, не номер заявки модерации. pending_moderation означает, что редакция карточки ждёт проверки; published — данные сохранены сразу (для черновика это не публикация на витрине). Это статус на момент запроса, а не отслеживание дальнейшего решения редактора. Если прежняя редакция возвращена на доработку или отклонена, оперативная правка её повторно не подаёт: статус будет needs_changes или rejected. Для новой проверки исправьте текст и укажите причину. У отказа 400 без сохранения: revision_id: null, status: "rejected", accepted_fields: []. Элемент rejected_fields имеет вид {"field": "latitude", "reasons": ["Широта должна быть от −90 до 90."]}.
Idempotency-Key работает как у slots:batch: необязателен, успешный ответ хранится сутки; повтор возвращает тот же ответ с Idempotent-Replay: true, другое тело с тем же ключом получает 409. Повтор не создаёт историю и не расходует квоту правок. Общие лимиты обращений по ключу продолжают действовать.
Лимит — 60 успешных запросов за скользящий час на организатора, общий для всех его ключей и карточек. Превышение получает 429 и Retry-After. Действует и общий лимит кабинета: не более 15 карточек на модерации, отказ 409 с кодом tour_moderation_limit_reached. Каждая принятая правка сохраняет время, владельца, номер ключа и значения принятых полей; последние 50 записей видны в истории карточки CRM.
free_cancellation_hours отклоняется при записи, как в кабинете. Сохранённый срок доступен в content.free_cancellation_hours и не переписывается. Длительность многодневного тура (duration_days, nights) меняется в кабинете вместе с программой по дням; отправка одного числа могла бы сделать её несогласованной.
Ручки#
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— черновик или карточка на проверке.
Опубликованная карточка может временно не продаваться (истёк документ, пропало место встречи). Причину отдаёт карточка продукта.
Срок бесплатной отмены читается в content.free_cancellation_hours подробного продукта. В корне продукта и в списке этого поля нет. Запись через API недоступна, как и в редакторе кабинета; сохранённое значение не меняется.
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 самого продукта повторяют первый активный формат из этого списка по sort_order, id. Выключенный формат из списка не пропадает: он приходит с is_active: false, потому что его расписание и заказы остаются на месте, и option_id в них по-прежнему надо понимать.
Создавать и публиковать продукты через API нельзя. Карточка проходит модерацию, и заводить её программно значило бы либо обходить проверку, либо копить черновики, которых никто не увидит. Продукт заводится в кабинете, API его подхватывает.
GET /products/{id}#
Те же поля, что в списке, плюс sales_blocked, sales_blocked_reasons, content и content_revision. Достаточно права catalog:read; content:write для чтения не требуется.
curl https://valeravezet.ru/api/organizer/v1/products/42 \
-H 'Authorization: Bearer <ваш ключ>'{
"sales_blocked": false,
"sales_blocked_reasons": [],
"content": {
"title": "Ночная Москва",
"short_description": "Вечерняя прогулка по центру",
"description": "<p>Пройдём по набережной и старым улицам.</p>",
"included": "Работа гида",
"not_included": "Ужин",
"duration_hours": "3.0",
"meeting_point": "У выхода из метро",
"meeting_point_details": "Гид с красным зонтом",
"latitude": "55.750000",
"longitude": "37.610000",
"price": "3500.00",
"pricing_type": "per_person",
"commercial_terms": {
"tariffs": [
{"id": "adult", "name": "Взрослый", "category": "standard", "price": "3500.00", "is_default": true}
],
"add_ons": [
{"id": "transfer", "name": "Трансфер", "description": "От вокзала", "price": "500.00", "pricing_type": "per_order"}
]
}
},
"content_revision": {
"id": 81,
"status": "pending_moderation",
"submitted_at": "2026-09-18T12:30:00+03:00",
"fields": ["description"],
"rejected_fields": [
{"field": "latitude", "reasons": ["Широта должна быть от -90 до 90."]}
]
}
}В примере показаны дополнительные поля ответа. content содержит ровно поля, которые принимает PATCH /products/{id}/content, без change_reason. Это сохранённая версия карточки: у опубликованного продукта она видна гостю, а ожидающие модерации тексты и допуслуги её пока не заменяют. У черновика возвращаются сохранённые значения без обещания публикации. HTML в описании сохраняется. Десятичные числа приходят строками, пустые координаты и длительность как null, пустые тексты как "". commercial_terms сохраняет структуру PATCH: tariffs и add_ons; если условия не заполнены, возможен пустой объект {}. price здесь базовая цена карточки, до скидок и цены конкретного выезда.
content_revision — последняя принятая к обработке правка API этого продукта, отправленная любым ключом организатора. Если таких правок нет, значение null. id совпадает с revision_id ответа PATCH, submitted_at — время отправки, fields — список принятых имён полей. rejected_fields содержит отклонённые поля с причинами: ошибки при приёме и замечания модератора, если они есть; без ошибок это []. Полностью отклонённый запрос PATCH не создаёт правку.
Статусы совпадают с PATCH: pending_moderation — на проверке, published — применена (для черновика это сохранение без публикации), needs_changes — нужны исправления, rejected — отклонена. После решения модератора статус обновляется при следующем чтении. Условия, применяемые сразу, могут уже находиться в content, пока текст той же правки ещё ожидает модерации. Ответ не кэшируется.
При чтении по прежнему номеру слитого продукта оба блока относятся к основной карточке из id; PATCH содержимого отправляйте на этот номер. В GET /products эти блоки не добавляются.
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 |
Фид также возвращает paid (предоплата внесена) и payment_received (есть поступившие деньги, включая частичную оплату площадке или организатору). Оба признака учитывают ручной реестр и кассу; статус completed сам по себе не доказывает оплату. Сумма prepaid_online описывает условия заказа. Если payment_received=true, автоматически закрывать заказ как неоплаченный нельзя. Неоплаченное ожидание прекращается после начала выезда при ближайшей очистке; отмена записывается событием в фид, без письма о давно прошедшем выезде.
Про место. 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 | формат, в который пишется дата. Необязателен: без него строка уходит в формат, которым представлена карточка, — первый активный по sort_order, 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, повторите чуть позже.
Ключ помнится сутки, считается вместе с адресом запроса: один и тот же ключ, присланный на два разных продукта или адреса уведомлений, повтором не считается и выполнится для каждого. Своя строка на операцию всё равно надёжнее.
Для options:batch и program заголовок обязателен. У остальных ручек без него повтор выполняется заново. Для большинства ручек это безобидно: привязка номера, пакетная запись окна и удаление адреса приводят к тому же состоянию, сколько раз их ни повтори. Разница видна там, где повтор создаёт или меняет: без ключа второй 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 после фиксации операции. Снимок содержит итоговое состояние заказа и оплаты; повтор с тем же ключом операции и откат транзакции событие не отправляют. Запись payment в журнале остаётся доступной в фиде по курсору. Письма гостю при этом не включаются.
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 — подтверждена ли предоплата кассой или ручным реестром. payment_received дополнительно защищает частичную оплату от автоотмены. Расчёт на месте оставляет его 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: у себя вы опознаете повтор и второй раз работу не сделаете. Расписание автоматических повторов такая кнопка не сдвигает — это ровно одна попытка, сверх лестницы.
Сроки хранения. Успешная доставка живёт тридцать дней. Неудачная — девяносто, а ответы вашего сервера у неё те же тридцать: дальше остаётся сама запись, уже без тел. Контактов гостя в журнале нет по той же причине, по какой их нет в событиях.
Фотографии и видео#
Чтение требует catalog:read, запись — content:write. Используйте основной номер продукта. Все пишущие запросы поддерживают Idempotency-Key.
| Метод и путь | Назначение |
|---|---|
GET /products/{id}/photos | Текущий предложенный набор и последние 50 заданий загрузки фото |
POST /products/{id}/photos | Скачать фото в фоне; ответ 202 с номером задания |
POST /products/{id}/photos:batch | Скачать до 25 фото одним пакетом; 202, результат по каждой строке |
PATCH /products/{id}/photos/{photo_id} | Изменить caption и/или itinerary_day, для опубликованной карточки с change_reason |
DELETE /products/{id}/photos/{photo_id} | Убрать кадр галереи, тело содержит change_reason |
PUT /products/{id}/photos/order | Задать полный порядок галереи: ids и change_reason |
GET /products/{id}/video | Опубликованное, ожидающее и отклонённое видео и последние 50 заданий |
POST /products/{id}/video | Скачать видео в фоне; ответ 202 с номером задания |
DELETE /products/{id}/video | Удалить неопубликованную версию, как в кабинете |
Пример добавления фотографии:
POST /api/organizer/v1/products/42/photos
Authorization: Bearer <ваш ключ>
Content-Type: application/json
Idempotency-Key: product-42-photo-1
{
"url": "https://media.example.org/route.jpg",
"role": "gallery",
"caption": "Вид с набережной",
"itinerary_day": null,
"rights_confirmed": true,
"change_reason": "Обновили фотографии маршрута"
}role по умолчанию gallery; main заменяет обложку. caption — обычный текст до 200 символов: разметка очищается по правилам выше до проверки длины и постановки загрузки в очередь. itinerary_day — положительный номер дня или null. rights_confirmed: true подтверждает права на материал, как галочка кабинета. У опубликованной карточки обязательна причина до 500 символов. Новые кадры добавляются к текущему предложенному набору, включая кадры на модерации. Обложка обязательна для публикации: её заменяют через role: main, а не удаляют.
{
"id": 81,
"kind": "photo",
"status": "queued",
"error": "",
"media_id": null,
"created_at": "2026-09-18T15:00:00+03:00",
"updated_at": "2026-09-18T15:00:00+03:00"
}Проверяйте задание по id в массиве imports ответа GET. Статусы загрузки: queued, processing, completed, failed. completed означает, что файл проверен и передан в медиаревизию, а не одобрен редактором. Ошибка приходит в error; исходный URL не возвращается. При недостаточном разрешении file_requirements содержит min_short_side, фактические width и height в пикселях (с учётом ориентации EXIF); для остальных результатов поле равно null. Успешный кадр получает media_id (при сохранении обложки черновика он null). Зависшее задание через 10 минут получает failed; для пакета срок составляет 30 минут. Повторите запрос с новым ключом идемпотентности.
Пакет фотографий#
POST /api/organizer/v1/products/42/photos:batch
Content-Type: application/json
Idempotency-Key: product-42-gallery-v2
{
"rights_confirmed": true,
"change_reason": "Обновили фотографии маршрута",
"items": [
{"url":"https://media.example.org/cover.jpg","role":"main","caption":"Панорама"},
{"url":"https://media.example.org/river.jpg","caption":"Набережная","order":0},
{"url":"https://media.example.org/park.jpg","caption":"Парк","order":1}
]
}От 1 до 25 строк; rights_confirmed и причина общие для пакета. role по умолчанию gallery; в пакете допустима одна обложка main. order от 0 до 24 сортирует новые кадры между собой, они добавляются после существующих. Без order используется индекс строки; при равенстве сохраняется порядок строк. Общий порядок старых и новых кадров затем меняется через /photos/order. itinerary_day — номер дня в пределах текущей или ожидающей модерации программы многодневного тура; null снимает привязку. Для экскурсии допустим только null. Эта проверка действует и в одиночной загрузке, и в PATCH.
Ответ 202: {"batch_id":"…","items":[…]}. Каждая строка items — задание обычной загрузки, дополненное batch_id и исходным index (с нуля). В одиночной загрузке эти два поля равны null. Внутренний адрес или ошибка полей дают строке failed сразу; тип файла, байты и разрешение проверяются в фоне. Остальные строки продолжают обработку. 202 означает регистрацию пакета, а не успешную загрузку. Даже пакет, в котором все строки невалидны, возвращает результаты каждой строки. Текущее состояние ищите по номерам заданий в GET /photos, поле imports. Повтор с тем же Idempotency-Key и телом возвращает первоначальный ответ без повторной постановки в очередь; другой пакет с тем же ключом получает 409.
Пример отклонённого кадра в imports (остальные поля задания опущены):
{
"id": 83,
"batch_id": "f337e54d3c764974be7b8d0a71155365",
"index": 2,
"status": "failed",
"error": "Кадр 640×480. Нужно не меньше 1200 по длинной стороне и 720 по короткой.",
"file_requirements": {"min_long_side":1200,"min_short_side":720,"width":640,"height":480},
"media_id": null
}Как считается комплект. Обложка считается одним кадром, её замена не увеличивает количество. Берётся текущая предложенная галерея, включая уже загруженные кадры на модерации и ожидающие удаления/перестановки, затем добавляются все подходящие файлы пакета. Нескачанные задания из очереди не могут закрыть минимум: пригодность их файлов ещё неизвестна. Предел итогового комплекта — 25. У опубликованной экскурсии минимум по умолчанию — 3 вместе с обложкой; настройка организатора может увеличить его. Для многодневного тура используется общий порог кабинета с учётом длительности предложенной программы. Проверка выполняется после скачивания под блокировкой карточки, поэтому учитывает и правки кабинета за это время. Если подходящий остаток пакета нарушает минимум или максимум, он целиком получает failed с причиной, галерея не меняется; причины невалидных файлов сохраняются. Если правила соблюдены, подходящие строки получают completed в одной медиаревизии.
Карточка только с обложкой: пакет из двух подходящих кадров даёт три и принимается; если один из этих файлов не подходит, оба задания получают failed, поскольку комплект из двух ниже минимума. Одиночная загрузка позволяет добрать недостающий комплект постепенно: первые кадры сохраняются на модерации, следующий учитывает их. GET /photos.count_issue объясняет недобор; одобрить такой комплект до достижения минимума нельзя. Пакет всегда проверяет итоговый минимум опубликованной карточки. Черновик, как в кабинете, можно сохранять ниже минимума без публикации.
Лимит 60 загрузок в час считается по строкам, включая отклонённые. Нельзя одновременно загрузить две обложки: дождитесь предыдущего задания. Пакет проверяет максимум после отсева плохих файлов; одиночная загрузка дополнительно резервирует место с учётом ещё не завершённых заданий.
Подпись и день существующего кадра#
PATCH /api/organizer/v1/products/42/photos/901
Content-Type: application/json
Idempotency-Key: photo-901-caption-v2
{"caption":"Набережная утром","itinerary_day":null,"change_reason":"Уточнили подпись"}Можно передать любое из двух полей или оба. caption очищается от HTML до проверки лимита 200 символов; пустая строка убирает подпись. itinerary_day: null убирает привязку к дню. Пропущенное поле сохраняет прежнее значение. Другие поля не принимаются. Используется числовой номер кадра галереи, включая ожидающий модерации; main здесь не поддерживается. Обложка не имеет отдельной редактируемой подписи в выдаче. Ответ 200 имеет ту же форму, что GET /photos.
У опубликованной карточки подпись и день входят в общую медиаревизию кабинета: на витрине остаются прежние значения до одобрения. PATCH ожидающего кадра меняет его предложенную версию. У черновика правка применяется сразу. Изменение подписи не требует повторного скачивания файла или подтверждения прав; причина изменения обязательна для опубликованной карточки.
{
"items": [
{"id":"main","url":"https://valeravezet.ru/media/cover.jpg","order":-1,"caption":"","role":"main","itinerary_day":null,"moderation_status":"pending_moderation"},
{"id":"901","url":"https://valeravezet.ru/media/route.jpg","order":0,"caption":"Вид с набережной","role":"gallery","itinerary_day":null,"moderation_status":"pending_moderation"}
],
"status":"pending_moderation",
"review_reason":"",
"imports":[]
}id в выдаче — строка: main для обложки, число для кадра. В ids передавайте числа всех кадров галереи, без обложки, пропусков и повторов:
PUT /api/organizer/v1/products/42/photos/order
Content-Type: application/json
{"ids":[902,901,903],"change_reason":"Показываем маршрут в порядке посещения"}У опубликованной карточки добавление, замена обложки, удаление и порядок ждут общей модерации медиа кабинета. Публичные фотографии сохраняются до одобрения. Статус предложенного набора — pending_moderation, needs_changes, rejected; без отложенной ревизии — approved, у опубликованных кадров — active. Черновик сохраняет фото сразу и остаётся черновиком. Текстовая ревизия при изменении фото сохраняется.
До 25 фото вместе с обложкой, включая ещё скачиваемые кадры. Действуют минимум комплекта и требования к дням тура из кабинета. Один файл — до 50 МиБ, длинная сторона — от 1200 px, короткая — от 720 px; проверяется содержимое, повреждённые файлы, рисунки и анимация отклоняются. До 60 загрузок медиа в час на организатора.
Для photos и photos:batch размеры проверяются после скачивания. При отказе задание возвращает file_requirements: min_long_side: 1200, min_short_side: 720 и фактические width, height с учётом ориентации EXIF. Вертикальная и квадратная обложки (role: main) принимаются с явно выбранной точкой focal_point: {"x": 0.5, "y": 0.2}. Передавайте её в том же запросе POST photos или в строке photos:batch: координаты сохраняются вместе с файлом, у опубликованной карточки — в медиаревизии до одобрения. Центр тоже допустим, если явно переданы x: 0.5, y: 0.5. Без точки задание отказывает: «Выберите на фото главное место, и обложку можно оставить вертикальной.»; в file_requirements приходит focal_point_required: true и фактический размер. Горизонтальная обложка и галерея не требуют точки.
GET photos у обложки возвращает focal_point_selected: он отличает выбранный центр от центра по умолчанию. Для сохранённой обложки используйте PATCH photos/main с focal_point и, желательно, image_url из GET для защиты от смены файла другим редактором. null, пустой объект и некорректные координаты отклоняются без изменения данных. Снятия точки нет: для возврата в центр передайте {"x":0.5,"y":0.5}. Точка прежней обложки не переносится на замену. Старые опубликованные карточки не снимаются с витрины автоматически; при новом одобрении вертикальная обложка должна иметь выбранную точку.
Короткая сторона от 720 до 899 px допустима: кабинет подсказывает «Кадр небольшой: на больших экранах будет мягким. Лучше от 1600 по длинной стороне».
Источник — HTTPS на порту 443, без авторизации в URL и перенаправлений. Внутренние, локальные и служебные IP запрещены, адрес заново проверяется перед скачиванием, соединение закрепляется за проверенным IP с проверкой TLS имени. Размер ограничен и по заголовку, и по реально полученным байтам. Скачивание имеет общий бюджет 40 секунд и сетевой таймаут 10 секунд; текущая сетевая операция может завершиться после бюджета. Фоновое задание имеет жёсткий предел 90 секунд. Сервер источника должен отдавать файл без HTTP-сжатия.
Видео отправляется аналогично, без полей фото:
POST /api/organizer/v1/products/42/video
Content-Type: application/json
{"url":"https://media.example.org/route.mp4","rights_confirmed":true}Видео доступно только экскурсиям и всегда проходит отдельную модерацию видео кабинета. Форматы: MP4, MOV, WEBM, M4V; до 500 МиБ и 60 секунд; вертикальное, короткая сторона от 720 px, длинная до 2560 px. GET возвращает video.active, video.pending, video.rejected (объект или null) и imports. У версии есть id, url, duration_seconds, width, height, moderation_note, created_at. URL предоставляется только для опубликованной версии; неопубликованное видео доступно для просмотра в кабинете. Новая загрузка не меняет опубликованную версию до решения редактора.
История изменений#
Версия 1, 18 сентября 2026 — пакет фото и метаданные кадра. Добавлены POST /products/{id}/photos:batch и PATCH /products/{id}/photos/{photo_id}. Пакет проверяет комплект после частичных отказов и учитывает кадры на модерации. Одиночные загрузки позволяют постепенно добрать минимум без публикации неполного комплекта. В выдаче появились count_issue, batch_id, index и file_requirements с фактическим разрешением и минимальной стороной.
- 18.09.2026: полная запись программы и проживания через
PUT /products/{id}/program, разметка фото, защита версией объекта и общая модерация.
- 18.09.2026: запись форматов
options:batch, внешние номера, частичные отказы и зеркало первого активного формата. - 18.09.2026: отменённый заказ больше не держит формат, а основной формат карточки не удаляется (
option_is_default).
Здесь отмечаются изменения контракта: что появилось и в какой версии.
Версия 1, 18 сентября 2026 — очистка HTML на входе. Тексты карточки, названия тарифов, названия и описания допуслуг и подписи фото автоматически приводятся к обычному тексту до проверки длины. Абзацы, переносы, списки и адреса ссылок сохраняют смысл. В ответ PATCH добавлен normalized_fields с путями изменённых текстов. Правила модерации не изменились.
Версия 1, 18 сентября 2026 — расширенные поля и медиа. PATCH содержимого принимает организационные детали, тайминг, конец маршрута, коммерческие условия, скидку и срок, допуск детей, транспорт и календарь. Чтение симметрично; справочники и срок отмены доступны только для чтения. Добавлены загрузка фото и видео по HTTPS-адресу, чтение состояний фоновых заданий, удаление и порядок галереи. Правила модерации и проверки совпадают с кабинетом.
Версия 1, 18 сентября 2026 — чтение содержимого продукта. В GET /products/{id} добавлены content для сверки с PATCH содержимого и content_revision со статусом последней правки API. Доступны с catalog:read. Список продуктов не изменился.
Версия 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 подхватывает её сразу после публикации и дальше ведёт расписание, цену и содержимое через модерацию.
Отмены заказа со стороны организатора. Место, снятое чужой системой в обход площадки, оставило бы гостя с оплаченным заказом и без услуги. Расхождение приезжает событием schedule.conflict и попадает к менеджеру площадки.
Цен многодневного тура по датам старта. Стоимость такого тура задаётся на карточке; price в строке пакетной записи для него игнорируется, о чём приходит предупреждение price_ignored_multiday.
Программного повтора вебхука. Расписание автоматических повторов фиксированное: минута, пять минут, полчаса, два часа, шесть часов. Повторить доставку руками можно из журнала в кабинете, отдельной ручки для этого нет. Если события потеряны пачкой, доберите их из GET /bookings с changed_since — фид для того и нужен.
Порядок перехода на принадлежность правил форматам#
Релиз со схемой 0188_tour_rules_option сначала добавляет совместимые чтение и запись старого (option_id=NULL) и нового хранения. У старых карточек до переноса основной формат определяется по sort_order, id; перестановка, меняющая владельца, отклоняется как option_order_locked. Новые карточки сразу сохраняют правила на формате.
Только после успешного выката этого релиза применяется 0189_option_owned_rules: NULL-правила и старые выезды получают исходный формат по sort_order, id, недельный план и исключения копируются в него. Ссылка rules_option хранит исходного владельца для совместимого чтения, option_rules_owned отмечает завершённый переход даже после разрешённого удаления исходного формата. Миграция печатает агрегированные количества до/после и прерывается при потере строк, отсутствии формата или конфликтующем плане. Обратной миграции данных нет. При откате приложения допустим только совместимый релиз с 0188; схему и перенесённые правила назад не откатывают.
Точка кадрирования обложки#
GET /products/{id}/photos возвращает у кадра id: "main" поле focal_point: {"x": 0.5, "y": 0.5}. Координаты — доли ширины и высоты исходного кадра от 0 до 1 включительно: (0, 0) — левый верхний угол, (1, 1) — правый нижний. Без настройки используется центр. У кадров галереи точки нет.
PATCH /products/{id}/photos/main (право content:write, поддерживает Idempotency-Key):
{
"focal_point": {"x": 0.6, "y": 0.15},
"image_url": "/media/tours/images/cover.jpg"
}Передавайте image_url из списка фотографий, чтобы не применить точку к обложке, которую другой редактор успел заменить. При несовпадении URL вернётся 400. Обе координаты обязательны, значения вне диапазона и нечисловые значения дают 400. Ответ 200 имеет тот же вид, что GET фотографий. У карточки без обложки — 404.
Правка кадрирования одобренной обложки применяется сразу, без причины изменения и повторной модерации текста. Если новая обложка уже ожидает редактора, её точка сохраняется в той же медиаревизии: публичный кадр остаётся прежним до одобрения. При замене файла старая точка не переносится на новый снимок. Изменение подписей, порядка и самих фотографий по-прежнему проходит обычную модерацию медиа.
