Ваше бронирование
Русский
Найти парковку

API для операторов · v1

Отдавайте свои парковки. Забирайте свои бронирования.

Справочник для оператора, который подключает к Parkena собственную систему. Всё описанное ниже — программа, которая развёрнута и отвечает, включая те её части, которые отвечают отказом.

Прочитайте это, прежде чем планировать проект вокруг API.

Это полный и точный справочник. Владелец или управляющий в вашем аккаунте оператора выпускает и отзывает ключи в консоли, в разделе Account → API keys, — секрет показывается один раз. Открыто не всё остальное: песочницы нет, и мы подключаем по одному оператору за раз, поэтому напишите нам, прежде чем планировать разработку. То, что v1 делать отказывается, выписано целиком в §12, а не оставлено на то, чтобы вы обнаружили это на третьей неделе.

1. Что это такое и чем это не является

Это справочник по v1 API Parkena для операторов. Он — альтернатива тому, чтобы вести свои площадки в консоли Parkena: вы отдаёте сюда свои парковки и свои цены, а забираете отсюда свои бронирования Parkena. Оба пути пишут в одни и те же таблицы через одну и ту же защиту, и ни один из них не может дотянуться до данных другого оператора. Пользоваться можно и тем и другим — консолью для того, что решает человек, и API для ночной синхронизации, — и друг другу они мешать не будут.

Чем это не является, так это продуктом, с которым можно интегрироваться без нашего участия. Ключ выпускает вошедший в систему владелец или управляющий; песочницы, на которой можно потренироваться, нет, и первая интеграция строится вместе с человеком с нашей стороны. Это утверждение о стадии, на которой находится Parkena, а не очередь, которую можно обойти.

Пять вещей, которых это API не делает, — сразу и прямо

Бронирования вы забираете сами, по курсору, и этот курсор — источник истины. Зарегистрированный адрес может получать подписанную подсказку booking.changed, означающую «заберите сейчас», — §13, — но данные бронирования в вебхуке не едут никогда, намеренно, а §12 говорит, чего по-прежнему нет.

Календаря доступности нет. Закрытые периоды — §7 — снимают с продажи целые диапазоны дат, но отдать «сегодня свободно столько-то мест» числом нельзя, а поле, которое выглядит похожим, — capacity — это ОБЩЕЕ число мест. Положив туда доступность, вы искажаете данные о своей парковке и каждую ночь аннулируете проверку её объявления.

Через это API не проходят деньги. Ни списаний, ни возвратов, ни данных о выплатах, ни цифр комиссии.

Ничто здесь не создаёт, не изменяет и не отменяет бронирование, не отмечает заезд и не делает возврат. Ни области доступа для этого нет, ни права за ней.

Базовый адрес — https://api.parkena.com/v1, см. §3. Указывать что-либо другое не следует.

2. Два правила, которые надо усвоить до первой строки кода

Это те две вещи, в которых грамотная интеграция всё равно ошибается, потому что в обоих случаях неверное поведение выглядит как сработавшее. Именно поэтому они подняты в начало страницы; всё, что идёт после них, — обычный справочный материал.

Правило первое: сравнивайте, прежде чем обновлять

Когда проверяющий Parkena одобряет ваше объявление, он одобряет конкретное содержимое. Если это содержимое изменится, одобрение больше не описывает то, что опубликовано, поэтому оно аннулируется, а парковка уходит на повторную проверку — и перестаёт продаваться на parkena.com, пока человек не одобрит её снова.

Это правильное поведение, но перед машиной, которая каждую ночь заново отдаёт все свои парковки, это ещё и способ уходить с публикации в 03:00 каждую ночь и навсегда. Поэтому это API не выдаёт обновление, которое ничего не меняет. Оба маршрута записи читают текущую строку, сравнивают её поле за полем, и, если не отличается ничего, не выполняют вообще никакого оператора — не пустой UPDATE, а никакого. Вы получаете "result": "unchanged" и "changed": [].

Чтобы получить это, вам не нужно делать ничего. Это не флаг, и заголовка, который надо прислать, нет. Ночная переотправка целиком одинаковых данных не делает ничего и стоит одного чтения на парковку.

# an identical re-push of an approved car park
{ "result": "unchanged", "changed": [], "listing_review_superseded": false }
#
# the same car park, with one word of the name changed
{ "result": "updated",   "changed": ["name"], "listing_review_superseded": true }
Сокращённые ответы развёрнутой функции. Полный их вид — в §7.

Второй ответ — не предупреждение, которое можно пропустить: эта парковка ушла с витрины и не вернётся на неё, пока её не одобрят снова. В §8 перечислено каждое поле, которое стоит вам повторной проверки, и каждое, которое не стоит.

Правило второе: GET возвращает поле, которое PUT не принимает

GET /lots возвращает каждую парковку ВМЕСТЕ с её ценовым коридором rates, потому что читать полезно именно это. PUT /lots/{external_id} его НЕ принимает — цены задаются на PUT /lots/{external_id}/rates. Поэтому очевидный цикл — прочитать парковку, изменить одно поле и отправить обратно — не работает, пока вы не уберёте rates. Уберите вместе с ним и external_id: он живёт в пути.

curl "$BASE/lots" -H "Authorization: Bearer $KEY.$SECRET" \
  | jq '.lots[0] | del(.rates, .external_id)' > lot.json
# edit lot.json
curl -X PUT "$BASE/lots/edge-main" -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' --data @lot.json

Отправьте всё обратно вместе с rates — и получите 422 {"error":"field_belongs_to_another_route","field":"rates"}. Мы отказываем, а не игнорируем, и в этом различии весь смысл: если бы вы положили новую цену в тело парковки, а мы ответили 200, вы бы обоснованно решили, что цена изменилась. А она бы не изменилась.

3. Базовый адрес

Каждый путь на этой странице отсчитывается от https://api.parkena.com/v1. Всё — JSON, и на входе, и на выходе.

api.parkena.com — имя хоста, которым владеет Parkena

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

Локальная разработка на стеке из самого репозитория использует те же пути по адресу /functions/v1/operator-api/v1 на локальном origin Supabase — те же пути, другая база.

Ни песочницы, ни тестовой базы нет. База выше — боевая, а то, от имени какого оператора действует запрос, определяется по предъявленным учётным данным и никогда по чему-либо в URL или в теле. См. §4, а про выбор оператора в запросе — §12.

4. Аутентификация

Каждый запрос несёт один заголовок:

Authorization: Bearer <key_id>.<secret>

key_id и secret — две половины одних учётных данных, соединённые точкой. Разделяет их первая точка; все последующие точки принадлежат секрету.

curl "$BASE/ping" \
  -H "Authorization: Bearer pk_api_3f9c….pk_sec_a71b…"
  • key_idpk_api_, за которым идут 32 шестнадцатеричных символа. Это открытая половина. Она попадает в наши журналы по замыслу, и её спокойно можно положить в файл настроек или привести в обращении в поддержку. Знание её даёт человеку ровно столько же, сколько знание имени пользователя.
  • secretpk_sec_, за которым идут 64 шестнадцатеричных символа, то есть 256 бит. Мы его не храним. Мы храним посоленный HMAC-SHA256 от него, в двух столбцах, которые не может прочитать ни одна роль в нашей базе данных. Восстановить его для вас мы не можем никогда. Потеряли — выпустите новые учётные данные и отзовите старые.

Запасного варианта ?key= в строке запроса нет и не будет: секрет в URL — это секрет в журнале прокси, в истории браузера и в заголовке Referer.

Любая неудача аутентификации возвращает один и тот же 401

Неизвестный key_id, неверный секрет, отозванные учётные данные, истёкшие учётные данные, приостановленный аккаунт оператора, учётные данные без области доступа, которой требует маршрут, и external_id, который не относится к вашим парковкам, — всё это 401 {"error":"unauthorized"}. Различить их нельзя, и это сделано намеренно: §9 говорит, что это даёт и чего вам стоит.

Чтобы проверить учётные данные, пользуйтесь GET /ping, а не выводами из 401.

5. Выпуск, ротация и отзыв учётных данных

Учётные данные выпускает вошедший в систему владелец или управляющий вашего аккаунта оператора — никогда не это API. Ключ API, способный выпускать ключи API, — это ключ, способный сам расширить свои права, поэтому ни один маршрут здесь их не выпускает. Учётные записи сотрудников не могут ни выпускать, ни отзывать.

За этим стоят два вызова, api_credential_issue и api_credential_revoke, которые делаются через PostgREST с токеном вошедшего пользователя, а не с учётными данными API. Они описаны здесь, потому что оператор со своей командой разработки захочет обращаться к ним напрямую; обычный же способ выпустить ключ — экран консоли в разделе Account → API keys.

Выпуск

curl -X POST '<project rest url>/rpc/api_credential_issue' \
  -H "apikey: <anon key>" \
  -H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
  -H 'Content-Type: application/json' \
  -d '{
        "p_label": "nightly sync",
        "p_scopes": ["read_supply", "write_supply", "read_bookings"],
        "p_expires_in_days": 365
      }'
ПараметрТипПримечания
p_labelстрока, 1–80 символовКак вы назовёте этот ключ через полгода. Обязателен.
p_scopesмассив областей доступаНепустой набор различных областей доступа. Обязателен.
p_expires_in_daysцелое число 1–3650 или nullNull означает, что срок не истекает. Необязателен.
[{
  "credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696",
  "key_id":        "pk_api_3f9c…",
  "secret":        "pk_sec_a71b…",
  "label":         "nightly sync",
  "scopes":        ["read_supply", "write_supply", "read_bookings"],
  "created_at":    "2026-08-19T14:22:07.113904Z",
  "expires_at":    "2027-08-19T14:22:07.113904Z"
}]
Ответ — и единственный раз, когда секрет вообще возвращается.

p_expires_at не существует и не появится. Срок — это число дней, которое сервер отсчитывает по своим часам; это API нигде и ни на одном маршруте не принимает метку времени от вызывающей стороны.

Ротация

Отдельного вызова для ротации нет, потому что ротация без простоя — это просто два вызова в правильном порядке:

  1. Выпустите вторые учётные данные с теми же областями доступа.
  2. Разверните их в своей системе и убедитесь, что трафик пошёл, — GET /ping с новым ключом, а затем следите у него за last_used_at.
  3. Отзовите старые.

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

Отзыв

curl -X POST '<project rest url>/rpc/api_credential_revoke' \
  -H "apikey: <anon key>" \
  -H "Authorization: Bearer <a signed-in owner or manager’s JWT>" \
  -H 'Content-Type: application/json' \
  -d '{"p_credential_id": "01a01a73-a650-710e-9be1-08ffc77b4696"}'

Отзыв мгновенный и окончательный. Отозванные учётные данные не восстанавливают — их заменяют. Повторный отзыв возвращает ту же строку с исходным revoked_at, потому что ключ не умирает дважды. Идентификатор, который вам не принадлежит, возвращает [] — ровно так же, как идентификатор, которого никогда не было.

6. Области доступа

Учётные данные несут набор областей доступа. Их три:

Область доступаЧто она разрешает
read_supplyЧитать список ваших парковок, их ценовые коридоры Parkena и закрытые периоды каждой парковки — включая агрегированный счётчик overlapping_bookings на каждом. Счётчик, никогда не бронирование: ни номера, ни имени, ни госномера при нём нет.
write_supplyСоздавать и изменять парковки, их цены Parkena и их списки закрытых периодов.
read_bookingsЗабирать ваши бронирования Parkena.

Выдавайте наименьшее из нужного. Синхронизации, которая только отдаёт площадки, не нужен read_bookings; отчётной задаче, которая только забирает бронирования, нельзя держать write_supply.

Проверка области доступа не носит рекомендательного характера. Это аргумент того же вызова к базе данных, который устанавливает, чьи строки запрос вправе трогать, поэтому пути к вашим данным в обход неё нет. Учётные данные без области доступа, которой требует маршрут, получают тот же единообразный 401 — тот же ответ, что и при неверном секрете.

write_bookings намеренно не существует. Ничто в Parkena не позволяет машине создать, изменить или отменить бронирование, поэтому область доступа с таким названием читалась бы вами как граница, не будучи ею вовсе.

7. Маршруты

МетодПутьТребуемая область доступа
GET/pingлюбые действующие учётные данные
GET/lotsread_supply
PUT/lots/{external_id}write_supply
PUT/lots/{external_id}/rateswrite_supply
GET/lots/{external_id}/blocked-periodsread_supply
PUT/lots/{external_id}/blocked-periodswrite_supply
GET/bookingsread_bookings

{external_id} — это ВАШ СОБСТВЕННЫЙ идентификатор парковки, как её называет ваша система. Для нас он непрозрачен: мы его никогда не разбираем, и быть UUID он не обязан. Экранируйте его процентами, если в нём есть / или пробел. Внутренние идентификаторы Parkena вам никогда не отправляются и от вас никогда не принимаются.

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

GET /ping

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

curl "$BASE/ping" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "ok": true,
  "key_id": "pk_api_3f9c…",
  "scopes": ["read_supply", "write_supply", "read_bookings"],
  "server_time": "2026-08-19T15:12:09.870151Z"
}

server_time — это наши часы, в UTC. Значение справочное: время вы нам обратно не присылаете никогда.

GET /lots

Ваши парковки, у каждой — её ценовой коридор Parkena.

Параметр запросаТипПо умолчаниюПримечания
afterстрокаПродолжить после этого external_id. Возьмите next_after с предыдущей страницы.
limitцелое число 1–20050Запрос большего отклоняется, а не уменьшается молча.

Постраничный проход идёт по вашему же external_id, по возрастанию, а не по смещению, поэтому граница страницы не сдвигается из-за параллельной записи.

curl "$BASE/lots?limit=50" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "lots": [
    {
      "external_id": "LOT-1",
      "name": "Terminal Park",
      "timezone": "Europe/Berlin",
      "handover": "self",
      "online_bookable": true,
      "capacity": 250,
      "latitude": "52.520000",
      "longitude": "13.405000",
      "city": "Berlin",
      "country_code": "DE",
      "features": ["indoor", "valet"],
      "arrival": null,
      "media": null,
      "min_advance_days": 1,
      "min_stay_days": 1,
      "max_stay_days": 60,
      "cancellation": {
        "free_until_hours_before_check_in": 48,
        "penalty_percent_after": "50.00",
        "no_show_forfeits_full": true
      },
      "rates": {
        "channel": "parkena",
        "currency": "EUR",
        "base_price": "12.50",
        "floor_price": "9.00",
        "ceiling_price": "18.00",
        "min_first_day_price": "11.00",
        "dynamic": false,
        "valid_from": null,
        "valid_to": null
      }
    }
  ],
  "next_after": null
}

next_after присутствует, только если страница заполнена целиком. Идите по нему, пока он не станет null, — и вы увидите все свои парковки ровно по одному разу. rates равен null у парковки, для которой цены Parkena ещё нет.

PUT /lots/{external_id}

Создать или обновить ОДНУ парковку. Один запрос — одна парковка; массового маршрута нет, и обеспечивает это сама форма маршрута. Тело-массив отклоняется с too_many_lots.

PUT — это полное представление. Отсутствующий ключ означает null, а не «оставить как было». Присылайте парковку целиком каждый раз. Иначе очистить поле было бы невозможно, а ключ с опечаткой превратился бы в вечное молчаливое бездействие.

ПолеТипОбязательноПримечания
external_idстроканетЕсли присутствует, должен совпадать с тем, что в пути. Проверяется, но не используется.
nameстрока, ≤200да
timezoneстрокадаИмя из базы IANA, например Europe/Berlin. Через него разрешается любой расчёт границы суток.
handoverстрокадаself или attended.
online_bookableлогическое значениедаВаш переключатель продаж. Обязателен именно потому, что значение по умолчанию сняло бы работающую парковку с продажи при первой же неполной отправке.
capacityцелое число 0–1000000нетОБЩЕЕ число мест. Не число свободных мест на сегодня — см. предупреждение ниже.
latitudeчисло или строканетОкругляется до 6 знаков после запятой. Присылается только вместе с longitude.
longitudeчисло или строканетОкругляется до 6 знаков после запятой. Присылается только вместе с latitude.
cityстрока, ≤120нет
country_codeстроканетISO 3166-1 alpha-2, например DE.
featuresмассив строкнетСм. список ниже. Порядок не важен — мы их сортируем.
arrivalобъектнетУказания по прибытию, в свободной форме.
mediaобъектнетСсылки на медиафайлы, в свободной форме.
min_advance_daysцелое число 0–365нетПо умолчанию 0.
min_stay_daysцелое число 1–365нет
max_stay_daysцелое число 1–365нетВсе три ограничены 365 днями — настолько вперёд Parkena вообще считает стоянку. Большее значение будет принято и никогда не соблюдено.
cancellationобъект или nullнетВсе три ключа или ни одного — см. ниже.

features принимает: indoor, security, cameras, gate_automation, plate_recognition, ev_charging, disabled_access, oversize_vehicle, valet.

cancellation — это один вложенный объект, и он либо целиком, либо никак:

"cancellation": {
  "free_until_hours_before_check_in": 48,
  "penalty_percent_after": "50.00",
  "no_show_forfeits_full": true
}

Присылка одного или двух ключей из трёх — это 422 incomplete_cancellation_policy. Правило с бесплатным окном и без названного штрафа — это не частично известное правило, а вопрос о возврате, на который нельзя ответить. Пришлите null или опустите ключ, если правила нет.

capacity — это ОБЩЕЕ ЧИСЛО МЕСТ, а не доступность

Если ваша ночная выгрузка кладёт «сегодня свободно столько-то мест» в capacity, вы сообщите Parkena, что ваша парковка уменьшилась, И будете каждую ночь аннулировать проверку своего объявления, потому что capacity — проверяемое содержимое.

Живая доступность — другой механизм, и в v1 его нет. Это API не способно обнаружить такую ошибку, потому что число есть число.

curl -X PUT "$BASE/lots/LOT-1" \
  -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
        "name": "Terminal Park",
        "timezone": "Europe/Berlin",
        "handover": "self",
        "online_bookable": true,
        "capacity": 250,
        "latitude": 52.5200,
        "longitude": 13.4050,
        "city": "Berlin",
        "country_code": "DE",
        "features": ["valet", "indoor"],
        "min_advance_days": 1,
        "min_stay_days": 1,
        "max_stay_days": 60,
        "cancellation": {
          "free_until_hours_before_check_in": 48,
          "penalty_percent_after": "50.00",
          "no_show_forfeits_full": true
        }
      }'
Полная отправка.
{
  "external_id": "LOT-1",
  "result": "created",
  "changed": ["name", "timezone", "handover", "…"],
  "listing_review_superseded": false,
  "lot": { "…": "the car park as it now stands" }
}
201, когда парковка была создана, иначе 200.
ПолеЗначение
resultОдно из: created, updated, unchanged.
changedИмена полей, которые действительно отличаются. Пусто при unchanged.
listing_review_supersededtrue, если эта запись аннулировала проверку вашего объявления Parkena, — см. §8.
lotСохранённая парковка, прочитанная обратно.

result: "unchanged" означает, что не было выполнено вообще ни одного оператора: ни блокировки строки, ни записи, ни триггера. Это нормальный и ожидаемый исход ночной переотправки, и именно он не даёт этому API снимать вас с публикации каждую ночь.

PUT /lots/{external_id}/rates

Задать ценовой коридор Parkena для одной парковки. Тарифный план — это не цена, а ДИАПАЗОН, в котором вы готовы продавать в канале Parkena: нижняя граница, верхняя граница и базовая цена между ними.

Этот маршрут пишет канал parkena и только его. Ваши собственные прямые цены — ваши, и это API их тронуть не может.

Принимаются два вида тела. Диапазон:

{
  "floor_price": "9.00",
  "base_price": "12.50",
  "ceiling_price": "18.00",
  "min_first_day_price": "11.00",
  "dynamic": false
}

Или одна фиксированная цена, которая разворачивается в floor = base = ceiling:

{ "fixed_price": "12.50" }
ПолеТипОбязательноПримечания
fixed_priceдесятичное числоодин вид тела или другойНельзя сочетать с полями диапазона.
floor_priceдесятичное числовместе с диапазономДолжна быть ≤ base_price.
base_priceдесятичное числовместе с диапазономДолжна лежать между нижней и верхней границей.
ceiling_priceдесятичное числовместе с диапазономДолжна быть ≥ base_price.
min_first_day_priceдесятичное число или nullнетДолжна лежать внутри коридора.
currencyстроканетISO 4217. По умолчанию — ваша валюта расчётов, и никакой другой быть не может.
dynamicлогическое значениенетПо умолчанию false.
valid_fromYYYY-MM-DD или nullнетКалендарная дата. Метка времени отклоняется, а не усекается.
valid_toYYYY-MM-DD или nullнетНе может быть раньше valid_from.

Деньги — это строка, и её скорее отклонят, чем округлят. Присылайте "12.50", а не 12.345. В суммах два знака после запятой; третий — это 422 invalid_body, потому что цена, которую вы не набирали, — не та цена, на которую вы согласились. С координатами наоборот: это измерение, поэтому они округляются.

Присылка одновременно fixed_price и диапазона — это 422. Догадки о том, что вы имели в виду, — это способ получить парковку с ценой на неверном конце её собственного диапазона.

{
  "external_id": "LOT-1",
  "result": "updated",
  "changed": ["floor_price"],
  "envelope_widened": true,
  "rates": {
    "channel": "parkena",
    "currency": "EUR",
    "base_price": "12.50",
    "floor_price": "7.00",
    "ceiling_price": "18.00",
    "min_first_day_price": "11.00",
    "dynamic": false,
    "valid_from": null,
    "valid_to": null
  }
}
201 при создании, иначе 200.

envelope_widened равно true, когда эта запись раздвинула коридор НАРУЖУ — нижняя граница вниз, верхняя вверх, смена валюты или переключение dynamic, — а также когда коридора Parkena до этого не было вовсе. Именно расширение может стоить вам проверки объявления.

Это осторожный сигнал, и мы скорее скажем об этом, чем преувеличим его: мы сравниваем с коридором, который действовал мгновение назад, а не с тем, который одобрил проверяющий, потому что это API намеренно не может читать состояние вашей проверки. Поэтому оно может сообщить true там, где ничего не аннулировано. Оно не сообщит false, когда что-то было аннулировано.

GET /lots/{external_id}/blocked-periods

Список закрытых периодов парковки: каждый диапазон дат, на который она снята с продажи на Parkena, кем бы он ни был записан, — ваши синхронизации и консоль пишут один и тот же список. Закрытый период останавливает НОВЫЕ продажи Parkena для стоянок, которые его касаются, и не делает больше ничего: он ничего не отменяет и не трогает ваши собственные прямые продажи. Обе даты включительные: ends_on — последний закрытый день, а не следующий за ним, и период, у которого starts_on равен ends_on, закрывает ровно этот один день.

curl "$BASE/lots/LOT-1/blocked-periods" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "external_id": "LOT-1",
  "blocked_periods": [
    { "starts_on": "2026-11-02",
      "ends_on":   "2026-11-08",
      "reason":    "resurfacing",
      "overlapping_bookings": 2 },
    { "starts_on": "2026-12-24",
      "ends_on":   "2026-12-26",
      "reason":    null,
      "overlapping_bookings": 0 }
  ]
}
Периоды возвращаются от самого раннего к позднему. reason — ваша собственная пометка, null, если её не давали.

overlapping_bookings — счётчик для прозрачности: сколько бронирований этой парковки — любой канал, любой статус, кроме cancelled, — имеют стоянку, которая касается периода. Оба сравнения включительные, по календарным дням самой парковки: бронирование, которое выезжает утром первого дня периода, всё ещё считается, как и то, что заезжает вечером его последнего дня. Обратите внимание, что входит в «кроме cancelled»: ЗАВЕРШЁННАЯ стоянка тоже считается. Счётчик отвечает на вопрос «что было продано на эти даты», включая историю, — это не число будущих машин, которые закрытие оставило бы у шлагбаума, поэтому он может оказаться выше, чем панель предупреждения в консоли, задающая тот более узкий вопрос. Это счётчик и ничего больше: ни номера, ни имени, ни госномера, ни дат ни одного бронирования в ответе нет.

PUT /lots/{external_id}/blocked-periods

Заменяет ВЕСЬ список закрытых периодов парковки списком из тела запроса. Вызова «добавить один период» нет, как нет и способа обратиться к отдельному периоду: синхронизация, умеющая только сливать, — это синхронизация, не умеющая удалять, и период, снятый в вашей системе, остался бы на Parkena навсегда. Не больше 100 записей; каждая дата — настоящий календарный день между 2020-01-01 и 2032-12-31; ends_on никогда не раньше starts_on; и две записи не могут пересекаться — включительно: пара, делящая один общий день, сталкивается.

Список, который вы прислали, — это список, который существует

PUT здесь — полное представление, то же правило, что у PUT /lots/{external_id}, и на этом маршруте у правила есть следствие, заслуживающее заглавных букв: ПЕРИОДЫ, ЗАПИСАННЫЕ В КОНСОЛИ, — ЧАСТЬ ТОГО ЖЕ СПИСКА. Если человек внесёт закрытый период в консоль во вторник, а ваша ночная синхронизация во вторник ночью пришлёт только свои периоды, синхронизация удалит период этого человека — молча и правильно, потому что вы сказали нам, что присланный список и есть весь список.

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

curl -X PUT "$BASE/lots/LOT-1/blocked-periods" \
  -H "Authorization: Bearer $KEY.$SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
        "blocked_periods": [
          { "starts_on": "2026-11-02", "ends_on": "2026-11-08", "reason": "resurfacing" },
          { "starts_on": "2026-12-24", "ends_on": "2026-12-26" }
        ]
      }'
Полная замена. Второй период без reason, и это разрешено.
ПолеТипОбязательноПримечания
starts_onYYYY-MM-DDдаПервый закрытый день, включительно. Календарная дата: метка времени отклоняется, а не усекается.
ends_onYYYY-MM-DDдаПОСЛЕДНИЙ закрытый день, включительно, — не следующий за ним. Не может быть раньше starts_on.
reasonстрока ≤200 или nullнетПометка, которую показывает консоль. Пустая отклоняется; null и отсутствие оба значат «без причины». Хранится дословно, никогда не обрезается: изменённый пробел — это изменённый период.

Каждый содержательный отказ на этом маршруте несёт один и тот же код, 422 {"error":"invalid_blocked_periods"}, а field называет виновную запись в координатах вашего же тела запроса — blocked_periods[3].ends_on. Две ошибки уровня конверта сохраняют коды, которые им даёт остальное API: лишний ключ верхнего уровня — unknown_field, отсутствующий ключ blocked_periodsmissing_field.

Что было не так`field`
Больше 100 записей, или blocked_periods не массивblocked_periods
Не настоящий календарный день (2026-02-30), метка времени или выход за 2020..2032starts_on / ends_on записи
ends_on раньше starts_onends_on записи
Две записи пересекаются (включительно: достаточно одного общего дня)starts_on записи, начинающейся позже
reason пустой, не строка или длиннее 200 символовreason записи

Периоды, пересекающиеся с проданными бронированиями, НЕ отклоняются. Машинная синхронизация не должна заклинивать из-за машины, проданной на прошлой неделе; бронирования остаются в силе — закрытие останавливает только НОВЫЕ продажи. Вместо отказа ответ несёт счётчик overlapping_bookings каждого периода (смысл выше, завершённые стоянки включены), чтобы ваша система — и человек за ней — точно видели, в какие закрытия уже проданы машины.

{
  "external_id": "LOT-1",
  "result": "updated",
  "added": 1,
  "removed": 1,
  "blocked_periods": [
    { "starts_on": "2026-11-02",
      "ends_on":   "2026-11-08",
      "reason":    "resurfacing",
      "overlapping_bookings": 2 },
    { "starts_on": "2026-12-24",
      "ends_on":   "2026-12-26",
      "reason":    null,
      "overlapping_bookings": 0 }
  ]
}
201, если у парковки раньше не было ни одного периода, иначе 200.
ПолеЗначение
resultcreated (раньше ноль периодов, теперь есть), updated (всё остальное, что писало, — включая PUT пустого [], опустошивший список) или unchanged.
added / removedЧисло строк, которые запись действительно сдвинула. Оба 0 при unchanged.
blocked_periodsСохранённый список, перечитанный ПОСЛЕ записи со свежими счётчиками overlapping_bookings, — то, что база данных держит сейчас, никогда не эхо вашего тела запроса.

Правило «сравни, прежде чем обновлять» из §2 действует здесь в форме списка: result: "unchanged" означает, что сохранённый список и ваше тело запроса уже говорили одно и то же и не было выпущено ни одной команды. Ночная повторная отправка неизменного списка периодов стоит одно чтение.

GET /bookings

Ваши бронирования Parkena, в виде КУРСОРА, который вы читаете сами. Этот курсор — источник истины: зарегистрированный адрес вебхука может получать подписанную подсказку booking.changed, говорящую забрать его пораньше, — §13, — но подсказка не несёт данных бронирования, и ничто из построенного на этом курсоре никогда не пропадает.

Параметр запросаТипПо умолчаниюПримечания
sinceстрокаТокен next из вашего предыдущего вызова. Опустите, чтобы читать «с самого начала».
limitцелое число 1–500200Запрос большего отклоняется, а не уменьшается молча.
curl "$BASE/bookings?since=$CURSOR" -H "Authorization: Bearer $KEY.$SECRET"
#
{
  "bookings": [
    {
      "reference": "PK0000000040",
      "external_reference": "OPS-9911",
      "lot_external_id": "LOT-1",
      "lot_name": "Terminal Park",
      "timezone": "Europe/Berlin",
      "check_in_local": "2026-09-01T08:00:00",
      "check_out_local": "2026-09-05T19:30:00",
      "parking_days": 5,
      "status": "confirmed",
      "payment_status": "paid",
      "currency": "EUR",
      "total": "56.00",
      "channel": "direct",
      "flight_number": "LH401",
      "customer": {
        "first_name": "Ada",
        "last_name": "Lovelace",
        "email": "[email protected]",
        "phone": "+49301234567"
      },
      "vehicle_plates": ["BXY4242"],
      "created_at": "2026-08-19T15:18:01.099213Z",
      "cancelled_at": null
    }
  ],
  "next": "eyJ2IjoxLCJ0IjoiMjAyNi0wOC0xOVQxNToxMzowOC44MjQxMDhaIiwi…",
  "has_more": false
}
ПолеПримечания
referenceНомер бронирования PARKENA. Это ключ для устранения дублей.
external_referenceВаша собственная строка, если она была задана. Возвращается как есть, ключом никогда не служит.
check_in_local / check_out_localНастенные часы самой парковки, БЕЗ смещения зоны. Читайте их в timezone.
totalДесятичная строка, а не число с плавающей точкой.
statusОдно из: pending, confirmed, checked_in, completed, cancelled.
payment_statusОдно из: pending, paid, partial, refunded, expired, not_required.

Как опрашивать правильно

cursor = load_saved_cursor()          # null on the first run
loop:
    r = GET /bookings?since=<cursor>&limit=200
    for b in r.bookings:
        upsert_into_your_system(b, key = b.reference)   # NOT external_reference
    cursor = r.next
    save_cursor(cursor)
    if not r.has_more: sleep(60)

Три правила, и каждое из них несущее:

  1. Устраняйте дубли по reference. Доставка — не менее одного раза, а не ровно один раз. Одно и то же бронирование вы увидите не единожды, и это правильное поведение, а не сбой.
  2. Сохраняйте next ПОСЛЕ того, как надёжно сохранили пачку, а не до. Если ваш процесс умрёт посреди пачки, несохранённый курсор доставит её заново — а правило 1 делает это безвредным.
  3. Не конструируйте курсор сами. Это токен, который выдали мы. Никакого ?since=<момент, который я выбрал> нет, и это отсутствие намеренно: партнёр, назвавший момент в будущем, молча перестал бы получать собственные бронирования, а первым симптомом стала бы машина у шлагбаума, о которой его система никогда не слышала. Курсор за нашей отметкой прижимается обратно к ней, поэтому худшее, что может сделать подделанный токен, — это доставить строки дважды.

Почему бронирование может прийти вам ещё раз сразу же: курсор, который мы возвращаем, никогда не указывает дальше, чем now() − 5 minutes. Бронирования новее этого всё равно возвращаются — вам нужно ваше бронирование сейчас, а не через пять минут, — мы просто не двигаем к ним курсор. Это не осторожность ради осторожности. Транзакция базы данных помечается временем своего НАЧАЛА, поэтому медленная транзакция может зафиксировать строку с меткой более ранней, чем у той, которую вам уже отдали; без этого отставания курсор, прыгнувший сразу к самой новой строке, перешагнул бы через неё навсегда и молча.

8. Что стоит вам повторной проверки

В §2 сформулировано правило; здесь — список полей за ним. Изменение любого из перечисленных полей на PUT /lots/{external_id} аннулирует поданное или одобренное объявление, и ответ говорит об этом через "listing_review_superseded": true:

name · timezone · handover · capacity · latitude · longitude · city · country_code · features · arrival · media · min_advance_days · min_stay_days · max_stay_days · cancellation_free_until_hours_before_check_in · cancellation_penalty_percent_after · cancellation_no_show_forfeits_full

Что её вам не стоит

  • online_bookable. Это ваш переключатель продаж, а не проверяемое содержимое. Его выключение немедленно снимает парковку с продажи, ничего не аннулируя, — именно поэтому это обязательное поле в каждом PUT.
  • Всё на маршруте цен, что не раздвигает коридор. Поднять нижнюю границу или опустить верхнюю — значит остаться внутри обещания, которое вы уже дали. Опустить нижнюю, поднять верхнюю, сменить валюту или переключить dynamic — значит дать новое, и это может отменить проверку; envelope_widened говорит вам, когда именно.
  • valid_from / valid_to. Истечение и возобновление читаются на лету, поэтому смена окна снимает парковку с витрины и возвращает её обратно без участия человека.

Две ловушки, о которых стоит знать

  • Порядок удобств не важен вам, но раньше был важен нам. Мы сортируем features перед сохранением, поэтому ["valet","indoor"] и ["indoor","valet"] — это одна и та же отправка. Сортировать вам не нужно.
  • Точность координат не важна. Мы округляем до шести знаков после запятой перед сравнением, поэтому 52.5200001 каждую ночь не будет вечно показывать latitude изменившимся.

Удаления нет

Это API не может удалить ни парковку, ни цену, и права, которое это позволило бы, не существует. delete, а затем insert — самая частая идиома upsert в коде загрузки данных, и на этих данных она катастрофична: парковка получила бы новую личность и унесла бы с собой историю своего объявления, свои цены и свои свободные места — из задачи, за которой никто не следил. Убрать парковку — это то, что человек делает в консоли, осознанно, и в подтверждении там перечислено всё, что уйдёт вместе с ней. Парковку, по которой хоть раз было бронирование, убрать нельзя вообще и никому: бронирование — это финансовая запись, она сохраняется, а вместе с ней и парковка, для которой она была сделана. Чтобы перестать продавать парковку, поставьте "online_bookable": false.

9. Ошибки

Каждая ошибка — это JSON со стабильным машиночитаемым кодом в error:

{ "error": "invalid_price_envelope", "field": "floor_price" }

field, когда он есть, — это ВАШЕ СОБСТВЕННОЕ имя поля, возвращённое вам обратно. Мы никогда не отдаём ни сообщение базы данных, ни имя ограничения, ни имя таблицы: их мы вправе переименовать, а ваша интеграция не должна из-за этого ломаться. Ветвитесь по error.

Учётные данные

СтатусКодЗначение
401unauthorizedСм. ниже — он покрывает шесть разных ситуаций и отказывается говорить, какую именно.

Форма запроса

СтатусКодЗначение
404not_foundНет такого МАРШРУТА. Для ресурса не используется никогда.
405method_not_allowedВерный путь, неверный метод. Заголовок Allow называет тот метод, которого мы ждали.
413payload_too_largeТело больше 64 KiB.
415unsupported_media_typeContent-Type не был application/json.
422invalid_jsonТело не разобралось как JSON.
422invalid_bodyКорректный JSON, но неверная форма или непригодное значение.
422missing_fieldОбязательное поле отсутствовало.
422unknown_fieldПоле, которого мы не знаем. Отклонено, а не проигнорировано.
422field_belongs_to_another_routeПоле настоящее, но задаётся в другом месте. Сегодня такое одно — rates, см. §2.
422invalid_queryПлохой или неизвестный параметр запроса.
422invalid_cursorТокен since был не нашим.
422too_many_lotsТело-массив. Один запрос — одна парковка.

Содержимое

СтатусКодЗначение
422invalid_timezoneНе имя часового пояса из базы IANA.
422invalid_coordinatesВне диапазона или широта без долготы.
422invalid_country_codeНе ISO 3166-1 alpha-2.
422invalid_stay_boundsmin_stay_days и max_stay_days противоречат друг другу.
422incomplete_cancellation_policyОдин или два ключа отмены из трёх.
422invalid_price_envelopeНижняя граница, базовая цена и верхняя граница идут не по порядку.
422invalid_first_day_pricemin_first_day_price вне коридора.
422invalid_validity_windowvalid_to раньше, чем valid_from.
422currency_is_not_settlement_currencyНе ваша валюта расчётов.
422invalid_blocked_periodsЗапись закрытых периодов непригодна; field называет её в координатах вашего же тела запроса (blocked_periods[3].ends_on). Таблица отказов — в §7.
409conflictНастоящая гонка: две отправки одновременно создали один и тот же external_id. Повторите.

Наши

СтатусКодЗначение
429rate_limitedПревышено ограничение. Соблюдайте Retry-After.
503try_againВременный конфликт в базе данных. Повторите; Retry-After проставлен.
500internal_errorНаша вина. Она целиком у нас в журналах. Повторить попытку разумно.

Почему 401 не скажет вам больше

unauthorized покрывает всё перечисленное и отказывается это различать:

  1. Заголовка Authorization нет или мы не можем его разобрать.
  2. key_id, который не называет никаких учётных данных.
  3. key_id, который существует, но с неверным секретом.
  4. Учётные данные, которые отозваны, истекли или чей аккаунт оператора приостановлен.
  5. Действующие учётные данные без области доступа, которой требует этот маршрут.
  6. Действующие учётные данные, называющие external_id, который не относится к их парковкам.

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

Случай 6 стоит вам некоторого удобства, и мы скажем почему, а не просто это заявим: учётные данные, у которых есть только write_supply, не могут получить список парковок. Если бы неизвестный external_id отвечал lot_not_found, у этих учётных данных был бы рабочий оракул перебора ровно по тем парковкам, которые их области доступа им закрывают, — собранный из отказа. Поэтому они получают тот же 401.

Ответ на всё это — GET /ping. Он говорит, что ваш ключ живой и что им можно делать, и вам не приходится гадать по 401. Если ping проходит, а маршрут отвечает 401, дело либо в области доступа, которой у вас нет, либо в external_id, который вам не принадлежит; проверьте и то и другое по GET /lots.

10. Ограничения частоты, ограничения размера и что мы записываем

ОграничениеЗначение
Тело запроса64 KiB
Парковок в одном запросе1
Страница GET /lots1–200, по умолчанию 50
Страница GET /bookings1–500, по умолчанию 200
Закрытых периодов на PUT …/blocked-periods100 — один список, одна парковка

Ограничения частоты устроены как «корзины токенов»: запас на всплеск, который пополняется непрерывно.

КорзинаСчитается поЗапасПополнение
Все запросыадресу источника2404 / сек
Неудачные аутентификацииадресу источника201 раз в 3 сек
Чтенияучётным данным1202 / сек
Записи (всплеск)учётным данным601 / сек
Записи (за час)учётным данным10001000 / час

Отказ — это 429 с заголовком Retry-After в целых секундах. Отказ не тратит токен, поэтому повторная попытка не отодвигает ваше же восстановление дальше.

Записи ограничены дважды, и важнее из двух — почасовое окно. Утёкший ключ на запись, обнуляющий все парковки, выглядит ровно как законная ночная синхронизация: те же учётные данные, тот же маршрут, та же форма, тот же час ночи, — поэтому один только предел всплеска отличить их не может, ведь настоящая синхронизация тоже всплеск. Почасовой потолок ограничивает, какую часть ваших парковок один украденный ключ успеет переписать до того, как человек мог бы это заметить. Оператор с 1000 парковок, отдающий каждую по разу за ночь, в него укладывается; если парковок у вас больше — попросите нас, и мы поднимем предел, вместо того чтобы вы его обходили.

Маршруты закрытых периодов тратят те же вёдра, что и всё остальное: GET стоит один жетон чтения, а PUT — запись, засчитываемая в оба ведра записи. Ночная синхронизация периодов идёт в тот же потолок 1000 в час, что и ваши отправки парковок, — намеренно, потому что утёкший ключ, снимающий парк с продажи, — ровно та форма, для ограничения которой этот потолок и существует.

Неудачные аутентификации считаются ПО АДРЕСУ ИСТОЧНИКА, а никогда не по предъявленному key_id. Считать их по ключу означало бы отказ в обслуживании, направленный против вас: key_id — несекретная половина, она по замыслу попадает в журналы и файлы настроек, поэтому любой, кто её прочитал, мог бы парой десятков неверных секретов запереть вас снаружи вашей же интеграции.

Учтите, что неизвестный external_id тоже тратит бюджет неудач, потому что возвращает тот же 401, что и всё остальное. Ограничитель, который обходился бы с этими двумя случаями по-разному, был бы оракулом существования, собранным из 429. Если у вас устарело сопоставление идентификаторов, сверьте его по GET /lots, а не перебором.

Что мы записываем

Каждый запрос порождает у нас одну строку журнала; каждая запись, которая что-то изменила, порождает вторую — с ИМЕНАМИ ПОЛЕЙ, которые изменились, и с тем, стоило ли изменение проверки. Имена полей, но никогда значения: журнал отвечает на вопрос «что случилось с этими парковками прошлой ночью», и ваших цен в нём нет. А вот ваш key_id есть — по нему вы и можете спросить нас, какая из ваших интеграций что-то сделала. Вашего секрета в нём нет никогда и ни в каком виде.

11. Прежде чем ваша парковка сможет продаваться

Отправка парковки через это API создаёт её, но новая парковка не начинает продаваться на parkena.com в тот момент, когда API вернул 201. Часть требуемого — это содержимое, которое это API может дать, а часть — решение, которое человек должен подтвердить в консоли.

Полный PUT /lots/{external_id} плюс PUT …/rates закрывают требования по вместимости, цене, координатам, городу и стране и правилам отмены. Остаётся то, что можно сделать только в консоли:

  • Подтвердить часовой пояс и правила бронирования: они выведены и проставлены по умолчанию, а правдоподобный неверный ответ молча исказит цену бронирований, поэтому человек подтверждает их один раз.
  • Указания по прибытию и главное изображение — для публичного объявления.
  • Принять соглашение о размещении Parkena, один раз на весь аккаунт.
  • Подать парковку на проверку.

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

Консоль показывает каждое требование, выполнено ли оно и что оно защищает. Это не то ограничение, которое мы намерены снять в v1.

12. Чего v1 не делает

Сказано прямо, потому что интеграция, построенная на допущении, которого мы никогда не делали, хуже, чем построенная на описанном пробеле.

  • Живых чисел доступности нет. Снять с продажи целые диапазоны дат теперь можно через PUT /lots/{external_id}/blocked-periods — §7, — но отдать «сегодня свободно столько-то мест» числом по-прежнему невозможно, а capacity — это общее число мест: положив туда живую доступность, вы исказите данные о своей парковке и будете отправлять её на повторную проверку каждую ночь. Календаря доступности по счётчикам в v1 нет.
  • Данных в вебхуках нет. Зарегистрированный адрес получает подписанную ПОДСКАЗКУ booking.changed из §13 — номер бронирования и ничего больше, — а источником истины остаётся курсор, ровно как этот список и обещал до появления подсказки. Чего по-прежнему нет: тел по каждому событию (данные бронирования в вебхуке не едут никогда), гарантий порядка (подсказки сливаются и повторяются; последовательность — дело курсора), API повторной доставки (нечего доставлять повторно — перечитайте курсор). Интеграция обязана работать с выключенными подсказками, потому что подсказка, проваливая пять доставок, умирает тихо и намеренно.
  • Записи бронирований нет. Через это API нельзя создать, изменить или отменить бронирование, отметить заезд или сделать возврат. Ни области доступа для этого нет, ни права за ней.
  • Платежей нет. Ни списаний, ни возвратов, ни данных о выплатах, ни цифр комиссии. Лента бронирований несёт итоговую сумму сделки и валюту — и ничего о том, как двигались деньги.
  • Раздачи в OTA и менеджеры каналов нет. Это API пишет только канал parkena. Оно не менеджер каналов и никуда больше ничего не отдаёт.
  • Удаления нет. См. §8.
  • Массового маршрута нет. Один запрос — одна парковка.
  • Подачи объявления и состояния проверки нет. Ни подать объявление на проверку, ни прочитать состояние проверки через API нельзя. listing_review_superseded и envelope_widened — единственные сигналы, близкие к проверке, и второй намеренно осторожен.
  • Выбора оператора в запросе нет. Ни в одном теле и ни в одной строке запроса, которые разбирает это API, нет tenant_id. То, от имени какого оператора действует запрос, определяется по учётным данным и ни по чему больше: присланный в запросе идентификатор оператора был бы ключом на запись в чужие данные.
  • Меток времени от вызывающей стороны нет нигде. Ни as_of, ни watermark, ни updated_since. Единственные даты, которые вы можете прислать, — это valid_from и valid_to, календарные дни, которые вы объявляете о своей собственной цене. Всё остальное — наши часы.

13. Вебхуки: подсказка `booking.changed`

Вы можете зарегистрировать один HTTPS-адрес на аккаунт оператора, и мы будем отправлять на него POST с подписанной подсказкой всякий раз, когда ваше бронирование создаётся или меняется, — любой канал, любое поле. Прочитайте предупреждение ниже, прежде чем что-либо вокруг этого проектировать.

Подсказка — это не данные. Данные — это курсор.

Всё тело подсказки — имя события, номер бронирования и метка времени. Ни статуса, ни дат, ни путешественника, ни сумм — ничего, на что ваша система могла бы отреагировать напрямую, и ничего, что устаревает в пути. Единственный правильный ответ на подсказку — то, что ваша интеграция и так делает: прочитать GET /bookings со своим сохранённым курсором.

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

Это разделение труда и есть причина, по которой §12 больше не говорит «вебхуков нет»: мы отказывались выпускать вебхук, который ВЁЗ БЫ бронирование, потому что вебхук, молча не сработавший, — это бронирование, о котором вы никогда не узнали, тогда как машина всё равно приедет к вашему шлагбауму. Подсказка может молча не сработать и не стоить вам ничего.

Доставка

POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Parkena-Webhooks/1
Parkena-Signature: t=1767139200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
#
{"event":"booking.changed","booking_reference":"PK0000000040","occurred_at":"2026-08-27T09:15:12.114532Z"}
Одно событие в v1 — booking.changed, одно имя и для создания, и для изменения, и для отмены, потому что подсказка не говорит, что случилось; это говорит курсор.
  • booking_reference — та же reference, которую несёт лента бронирований: отдайте её своей дедупликации, ровно как вы дедуплицируете строки курсора.
  • occurred_at — момент, когда изменение было записано у нас, а не когда была отправлена эта попытка. Повторные попытки шлют его дословно.

Последовательные изменения одного бронирования СЛИВАЮТСЯ, пока подсказка о нём ещё не доставлена: пять правок за минуту дают один сигнал, и это правильно именно потому, что подсказка не несёт состояния, — сколько бы записей она ни представляла, ваше следующее чтение курсора видит итоговую строку. Доставка — как минимум один раз, как и всё на этом API: вы можете получить две подсказки об одном изменении, и дедупликация по booking_reference делает это бесплатным.

Регистрация адреса и правила, которым он обязан соответствовать

Адреса регистрируются в консоли, на той же странице Account → API keys, где выпускаются ключи, владельцем или менеджером — не через это API, по той же причине, что и ключи. Секрет подписи (whsec_ и 64 шестнадцатеричных символа) создаётся в вашем браузере и показывается ОДИН РАЗ: мы храним его, чтобы подписывать, но ни один экран консоли и ни один запрос не может прочитать его назад. В v1 активным может быть один адрес на аккаунт. Адрес никогда не редактируется: новый URL или сменённый секрет — это новый адрес (сначала отключите старый); отключение — единственный переключатель, который консоль предлагает после рождения.

  • Только HTTPS, только порт 443. http:// и любой явный порт, кроме 443, не пробуются никогда.
  • Имя хоста, а не адрес. IP-литералы (v4 или v6), localhost и всё на доменах нашей собственной платформы отклоняются.
  • Перенаправления не выполняются никогда. Перенаправление — это второй URL, которого никто не проверял; попытка вместо этого проваливается.
  • Пять секунд на ответ, и дальше кода состояния ваш ответ никогда не читается. Отвечайте быстро, работайте потом — правильная форма: «поставить в очередь и вернуть 204».

Контракт 2xx, повторные попытки и смерть

Любой 2xx в пределах таймаута означает «доставлено». Всё остальное — 4xx, 5xx, таймаут, отклонённое соединение — повторяется по фиксированным нарастающим паузам, и после пятой неудачной попытки подсказка мертва: последний HTTP-статус и причина сбоя записаны и видны в консоли, и больше попыток не будет. Само бронирование, как всегда, ждёт на курсоре. Отключение адреса убивает его ожидающие подсказки при следующем проходе, вместо того чтобы позже доставить их адресу, который вы выключили.

Неудачных попытокСледующая попытка
1через 1 минуту
2через 5 минут
3через 30 минут
4через 2 часа
5не будет — подсказка мертва, последний статус и причина записаны

Проверка подписи

Каждая доставка несёт один заголовок Parkena-Signature: t=<unix-seconds>,v1=<hex HMAC-SHA-256>. Подписывается буквальная строка t + . + СЫРОЕ тело запроса — подписывайте байты, которые получили, а не повторную сериализацию разобранного JSON. Это намеренно ровно та схема, которой Stripe подписывает свои вебхуки, включая префикс секрета whsec_: любой ваш работающий верификатор вебхуков Stripe — или опубликованный в собственном репозитории Parkena — проверяет эти доставки без изменений.

# header: "t=1767139200,v1=5257a869…"   secret: "whsec_…" exactly as shown once
const pairs = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const expected = hexHmacSha256(secret, `${pairs.t}.${rawBody}`);
const fresh = Math.abs(nowSeconds() - Number(pairs.t)) <= 300;
const ok = fresh && timingSafeEqual(pairs.v1, expected);
Всё целиком, наброском.

Три детали, которые спешная реализация делает неправильно: сравнивайте равенством ПОСТОЯННОГО ВРЕМЕНИ, а не ===, чтобы время ответа не выдавало, какая часть поддельной подписи совпала; отклоняйте t старше нескольких минут — мы советуем 300 секунд, — именно это обесценивает перехваченную доставку для позднейшего повтора; и если вы разбираете заголовок по-настоящему, а не режете его наивно, проверяйте КАЖДУЮ присутствующую пару v1= и принимайте, если совпала любая, — именно это не даст будущей ротации секрета подписи сломать вас посреди окна.

Подсказка, не прошедшая вашу проверку, — не доставка Parkena. Ответьте ей 401 и не делайте больше ничего — в частности, не читайте курсор в ритме, который она вам диктует. Читать курсор всегда БЕЗОПАСНО; отказ нужен для того, чтобы неаутентифицированный звонящий не управлял тактом вашей системы.

14. Как получить доступ и как получить помощь

Ключи выпускает в консоли владелец или управляющий, в разделе Account → API keys. Песочницы нет, а доступ к пилоту устраивается с нами по одному оператору за раз: если прочитанное здесь подходит той системе, которая у вас уже работает, — именно это и стоит написать.

Пишите на [email protected]. Для поддержки уже работающей интеграции приведите свой key_id — но никогда секрет — и external_id вместе с меткой времени того запроса, о котором спрашиваете. И то и другое есть в наших журналах, и вместе они определяют один-единственный запрос.

Спросите про пилот.

Расскажите, что у вас за система и что вы хотите ей отдавать. Мы честно скажем, покрывает ли это v1: §12 — полный список того, чего оно не умеет, выписанный целиком, чтобы вы могли отказаться прежде, чем что-то построите.