Разработчикам

Публичный 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 /bookingsproduct_external_id
Продуктатело вебхука (data)external_ref
СлотаGET /products/{id}/slotsexternal_id
СлотаPOST /products/{id}/slots:batch (строка и ответ)external_ref

Значение во всех строках одно и то же — то, которое вы прислали. Номер продукта и номер слота живут отдельно и не пересекаются.

Форматы продукта#

Одну и ту же экскурсию можно продавать по-разному: местами в общей группе или целиком своей компанией. На площадке это не две карточки, а одна с переключателем. Карточка отвечает за то, куда едем и что показываем, формат за то, почём и сколько нас. Адрес, отзывы и фотографии у форматов общие.

Форматы приезжают списком options[] в теле продукта, а их номера ездят по остальному API полем option_id:

ГдеЧто появилось
GET /products, GET /products/{id}options[], все форматы карточки
GET /products/{id}/slotsoption_id у слота и такой же параметр запроса
POST /products/{id}/slots:batchoption_id в строке, в её результате и в closed_slots
GET /bookingsoption_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
curl https://valeravezet.ru/api/organizer/v1/me \
  -H 'Authorization: Bearer <ваш ключ>'

2. Забрать свои продукты. Номер продукта — в поле id:

curl
curl 'https://valeravezet.ru/api/organizer/v1/products?limit=50' \
  -H 'Authorization: Bearer <ваш ключ>'

3. Связать продукт со своим номером. Чтобы заказ лёг в вашу систему без ручного сопоставления, а цену дальше держала она же:

curl
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
curl 'https://valeravezet.ru/api/organizer/v1/products/42/slots?from=2026-09-01&to=2026-09-30' \
  -H 'Authorization: Bearer <ваш ключ>'

5. Записать расписание на месяц. Одним запросом и с ключом идемпотентности — повтор после оборванной связи не запишет вторую копию:

curl
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
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
curl https://valeravezet.ru/api/organizer/v1/sync/status \
  -H 'Authorization: Bearer <ваш ключ>'

state: "ok" по нужным направлениям — подключение готово.

Ответы и ошибки#

Ошибка — это всегда объект с detail (по-русски, человеку) и code (машине).

JSON
{"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 символов уникален среди форматов организатора: повтор с новым ключом обновляет тот же формат.

JSON
{"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: перечитайте карточку и согласуйте изменения. Прежний номер слитой карточки здесь не принимается.

JSON
{
  "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_typeper_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_modeinstant или 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_monweekday_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 — пути текстов, которые изменились при очистке, включая отклонённые. Неизменённый обычный текст туда не попадает; если ничего не изменилось, список пуст. Для вложенных значений указываются индексы с нуля:

JSON
{"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": "Уточнили маршрут и продолжительность"
}
JSON
{
  "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
curl https://valeravezet.ru/api/organizer/v1/me \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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
curl https://valeravezet.ru/api/organizer/v1/sync/status \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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
curl 'https://valeravezet.ru/api/organizer/v1/products?limit=50&product_type=excursion' \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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номер формата на площадке; им же слот привязывается к формату
kindgroup или 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
curl https://valeravezet.ru/api/organizer/v1/products/42 \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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.

JSON
{"external_id": "td_88214"}
curl
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
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
curl 'https://valeravezet.ru/api/organizer/v1/products/42/slots?from=2026-09-01&to=2026-11-30' \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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_allotmentnull, если пул общий: продаём из той же вместимости, и ваши продажи её уменьшают. Число — выделенный площадке кусок, который внешние продажи не подъедают;
  • available — сколько сейчас свободно у нас;
  • is_open — открыт ли слот к продаже.

GET /bookings#

Фид заказов: всё, что у вас купили, и всё, что с этими заказами стало.

Параметры: limit (до 200), cursor, changed_since (дата или время по ISO 8601; знак «плюс» часового пояса в строке запроса надо кодировать как %2B).

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

curl
curl 'https://valeravezet.ru/api/organizer/v1/bookings?limit=200&changed_since=2026-08-25T00:00:00%2B03:00' \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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 появляется предупреждение:

JSON
{
  "warnings": [
    {
      "code": "contacts_daily_limit",
      "detail": "За сутки по этому ключу открыто 500 броней с контактами.",
      "limit": 500
    }
  ]
}

Это не 429: остальное содержимое ответа вам нужно, и обмен из-за предела не встаёт. Счёт обнуляется в полночь по московскому времени. Если ваша работа честно упирается в предел — напишите, поднимем.

Вебхуки контактов не содержат вовсе, независимо от прав ключа: событие уходит на чужой адрес по сети, и телефонам гостей там не место. За контактами приходите в фид.

POST /products/{id}/slots:batch#

Окно дат целиком, одним запросом: сколько мест, сколько из них вы продали мимо нас, почём и открыта ли дата. Право — schedule:write, лимит записи — 60 запросов в минуту, до 500 слотов в теле.

Слот опознаётся по формату, дате и времени внутри продукта. Второго пространства идентификаторов нет: чтобы писать, наши номера хранить не нужно. Слота с таким ключом ещё не было — он заводится, был — правится.

Формат в ключе появился вместе с options[]. Для карточки с одним форматом это по-прежнему просто дата и время: строка без option_id уходит в формат по умолчанию. У карточки с двумя форматами десять утра двенадцатого сентября это две разные строки, и различает их option_id.

JSON
{
  "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
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. У индивидуального формата вместимость считается компаниями, а не людьми: одна бронь занимает одно место.

JSON
{
  "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 дней), отказ по строке живёт в строке.

JSON
{
  "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_conflictif_revision не совпал с текущим (или слота с таким ключом ещё нет) — прочитайте слот заново
slot_outside_windowдата строки вне window
slot_in_pastдата и время уже прошли
capacity_requiredновая дата без capacity
date_required, start_time_requiredв строке нет ключа слота
option_not_foundформат из option_id не принадлежит этому продукту
invalid_option_idoption_id не целое число
option_requiredу продукта нет ни одного формата; так бывает только у карточки, которой формат ещё не завели
slot_time_conflictв строке есть и start_time, и time, и они разные
invalid_slotстрока — не объект
external_ref_too_longexternal_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_held

valera_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
curl https://valeravezet.ru/api/organizer/v1/webhooks \
  -H 'Authorization: Bearer <ваш ключ>'
JSON
{
  "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
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. Это и есть секрет подписи; сохраните его сразу, второй раз он не придёт:

JSON
{
  "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
curl -X POST https://valeravezet.ru/api/organizer/v1/webhooks/7 \
  -H 'Authorization: Bearer <ваш ключ>'

Убрать адрес:

curl
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 нет вовсе.

JSON
{
  "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: «подпишись на всё» и «не подписывай ни на что» не должны выглядеть одинаково, иначе опечатка в сборке списка молча превращается в подписку на всё.

Хотите выбрать — перечислите нужные:

JSON
{"url": "https://…", "events": ["booking.paid", "booking.cancelled"]}

Неизвестное событие в списке — тоже 400 invalid_events, с перечислением того, что мы не узнали. Порядок значения не имеет: в ответе события всегда идут в том порядке, в каком перечислены в таблице выше.

Тело#

JSON
{
  "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).

Python
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:

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
<?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, а не удаляют.

JSON
{
  "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 (остальные поля задания опущены):

JSON
{
  "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 ожидающего кадра меняет его предложенную версию. У черновика правка применяется сразу. Изменение подписи не требует повторного скачивания файла или подтверждения прав; причина изменения обязательна для опубликованной карточки.

JSON
{
  "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_settlementunknown. Ломающих изменений нет: прежние 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):

JSON
{
  "focal_point": {"x": 0.6, "y": 0.15},
  "image_url": "/media/tours/images/cover.jpg"
}

Передавайте image_url из списка фотографий, чтобы не применить точку к обложке, которую другой редактор успел заменить. При несовпадении URL вернётся 400. Обе координаты обязательны, значения вне диапазона и нечисловые значения дают 400. Ответ 200 имеет тот же вид, что GET фотографий. У карточки без обложки — 404.

Правка кадрирования одобренной обложки применяется сразу, без причины изменения и повторной модерации текста. Если новая обложка уже ожидает редактора, её точка сохраняется в той же медиаревизии: публичный кадр остаётся прежним до одобрения. При замене файла старая точка не переносится на новый снимок. Изменение подписей, порядка и самих фотографий по-прежнему проходит обычную модерацию медиа.

API для организаторов — Валера везёт