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 }Второй ответ — не предупреждение, которое можно пропустить: эта парковка ушла с витрины и не вернётся на неё, пока её не одобрят снова. В §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_id—pk_api_, за которым идут 32 шестнадцатеричных символа. Это открытая половина. Она попадает в наши журналы по замыслу, и её спокойно можно положить в файл настроек или привести в обращении в поддержку. Знание её даёт человеку ровно столько же, сколько знание имени пользователя.secret—pk_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 или null | Null означает, что срок не истекает. Необязателен. |
[{
"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 нигде и ни на одном маршруте не принимает метку времени от вызывающей стороны.
Ротация
Отдельного вызова для ротации нет, потому что ротация без простоя — это просто два вызова в правильном порядке:
- Выпустите вторые учётные данные с теми же областями доступа.
- Разверните их в своей системе и убедитесь, что трафик пошёл, —
GET /pingс новым ключом, а затем следите у него заlast_used_at. - Отзовите старые.
Между первым шагом и третьим действуют обе учётные данные. Ограничения, которое мешало бы держать две, нет.
Отзыв
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 | /lots | read_supply |
PUT | /lots/{external_id} | write_supply |
PUT | /lots/{external_id}/rates | write_supply |
GET | /lots/{external_id}/blocked-periods | read_supply |
PUT | /lots/{external_id}/blocked-periods | write_supply |
GET | /bookings | read_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–200 | 50 | Запрос большего отклоняется, а не уменьшается молча. |
Постраничный проход идёт по вашему же 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_superseded | true, если эта запись аннулировала проверку вашего объявления 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_from | YYYY-MM-DD или null | нет | Календарная дата. Метка времени отклоняется, а не усекается. |
valid_to | YYYY-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_on | YYYY-MM-DD | да | Первый закрытый день, включительно. Календарная дата: метка времени отклоняется, а не усекается. |
ends_on | YYYY-MM-DD | да | ПОСЛЕДНИЙ закрытый день, включительно, — не следующий за ним. Не может быть раньше starts_on. |
reason | строка ≤200 или null | нет | Пометка, которую показывает консоль. Пустая отклоняется; null и отсутствие оба значат «без причины». Хранится дословно, никогда не обрезается: изменённый пробел — это изменённый период. |
Каждый содержательный отказ на этом маршруте несёт один и тот же код, 422 {"error":"invalid_blocked_periods"}, а field называет виновную запись в координатах вашего же тела запроса — blocked_periods[3].ends_on. Две ошибки уровня конверта сохраняют коды, которые им даёт остальное API: лишний ключ верхнего уровня — unknown_field, отсутствующий ключ blocked_periods — missing_field.
| Что было не так | `field` |
|---|---|
Больше 100 записей, или blocked_periods не массив | blocked_periods |
Не настоящий календарный день (2026-02-30), метка времени или выход за 2020..2032 | starts_on / ends_on записи |
ends_on раньше starts_on | ends_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.| Поле | Значение |
|---|---|
result | created (раньше ноль периодов, теперь есть), updated (всё остальное, что писало, — включая PUT пустого [], опустошивший список) или unchanged. |
added / removed | Число строк, которые запись действительно сдвинула. Оба 0 при unchanged. |
blocked_periods | Сохранённый список, перечитанный ПОСЛЕ записи со свежими счётчиками overlapping_bookings, — то, что база данных держит сейчас, никогда не эхо вашего тела запроса. |
Правило «сравни, прежде чем обновлять» из §2 действует здесь в форме списка: result: "unchanged" означает, что сохранённый список и ваше тело запроса уже говорили одно и то же и не было выпущено ни одной команды. Ночная повторная отправка неизменного списка периодов стоит одно чтение.
GET /bookings
Ваши бронирования Parkena, в виде КУРСОРА, который вы читаете сами. Этот курсор — источник истины: зарегистрированный адрес вебхука может получать подписанную подсказку booking.changed, говорящую забрать его пораньше, — §13, — но подсказка не несёт данных бронирования, и ничто из построенного на этом курсоре никогда не пропадает.
| Параметр запроса | Тип | По умолчанию | Примечания |
|---|---|---|---|
since | строка | — | Токен next из вашего предыдущего вызова. Опустите, чтобы читать «с самого начала». |
limit | целое число 1–500 | 200 | Запрос большего отклоняется, а не уменьшается молча. |
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)Три правила, и каждое из них несущее:
- Устраняйте дубли по
reference. Доставка — не менее одного раза, а не ровно один раз. Одно и то же бронирование вы увидите не единожды, и это правильное поведение, а не сбой. - Сохраняйте
nextПОСЛЕ того, как надёжно сохранили пачку, а не до. Если ваш процесс умрёт посреди пачки, несохранённый курсор доставит её заново — а правило 1 делает это безвредным. - Не конструируйте курсор сами. Это токен, который выдали мы. Никакого
?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.
Учётные данные
| Статус | Код | Значение |
|---|---|---|
401 | unauthorized | См. ниже — он покрывает шесть разных ситуаций и отказывается говорить, какую именно. |
Форма запроса
| Статус | Код | Значение |
|---|---|---|
404 | not_found | Нет такого МАРШРУТА. Для ресурса не используется никогда. |
405 | method_not_allowed | Верный путь, неверный метод. Заголовок Allow называет тот метод, которого мы ждали. |
413 | payload_too_large | Тело больше 64 KiB. |
415 | unsupported_media_type | Content-Type не был application/json. |
422 | invalid_json | Тело не разобралось как JSON. |
422 | invalid_body | Корректный JSON, но неверная форма или непригодное значение. |
422 | missing_field | Обязательное поле отсутствовало. |
422 | unknown_field | Поле, которого мы не знаем. Отклонено, а не проигнорировано. |
422 | field_belongs_to_another_route | Поле настоящее, но задаётся в другом месте. Сегодня такое одно — rates, см. §2. |
422 | invalid_query | Плохой или неизвестный параметр запроса. |
422 | invalid_cursor | Токен since был не нашим. |
422 | too_many_lots | Тело-массив. Один запрос — одна парковка. |
Содержимое
| Статус | Код | Значение |
|---|---|---|
422 | invalid_timezone | Не имя часового пояса из базы IANA. |
422 | invalid_coordinates | Вне диапазона или широта без долготы. |
422 | invalid_country_code | Не ISO 3166-1 alpha-2. |
422 | invalid_stay_bounds | min_stay_days и max_stay_days противоречат друг другу. |
422 | incomplete_cancellation_policy | Один или два ключа отмены из трёх. |
422 | invalid_price_envelope | Нижняя граница, базовая цена и верхняя граница идут не по порядку. |
422 | invalid_first_day_price | min_first_day_price вне коридора. |
422 | invalid_validity_window | valid_to раньше, чем valid_from. |
422 | currency_is_not_settlement_currency | Не ваша валюта расчётов. |
422 | invalid_blocked_periods | Запись закрытых периодов непригодна; field называет её в координатах вашего же тела запроса (blocked_periods[3].ends_on). Таблица отказов — в §7. |
409 | conflict | Настоящая гонка: две отправки одновременно создали один и тот же external_id. Повторите. |
Наши
| Статус | Код | Значение |
|---|---|---|
429 | rate_limited | Превышено ограничение. Соблюдайте Retry-After. |
503 | try_again | Временный конфликт в базе данных. Повторите; Retry-After проставлен. |
500 | internal_error | Наша вина. Она целиком у нас в журналах. Повторить попытку разумно. |
Почему 401 не скажет вам больше
unauthorized покрывает всё перечисленное и отказывается это различать:
- Заголовка
Authorizationнет или мы не можем его разобрать. key_id, который не называет никаких учётных данных.key_id, который существует, но с неверным секретом.- Учётные данные, которые отозваны, истекли или чей аккаунт оператора приостановлен.
- Действующие учётные данные без области доступа, которой требует этот маршрут.
- Действующие учётные данные, называющие
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 /lots | 1–200, по умолчанию 50 |
Страница GET /bookings | 1–500, по умолчанию 200 |
Закрытых периодов на PUT …/blocked-periods | 100 — один список, одна парковка |
Ограничения частоты устроены как «корзины токенов»: запас на всплеск, который пополняется непрерывно.
| Корзина | Считается по | Запас | Пополнение |
|---|---|---|---|
| Все запросы | адресу источника | 240 | 4 / сек |
| Неудачные аутентификации | адресу источника | 20 | 1 раз в 3 сек |
| Чтения | учётным данным | 120 | 2 / сек |
| Записи (всплеск) | учётным данным | 60 | 1 / сек |
| Записи (за час) | учётным данным | 1000 | 1000 / час |
Отказ — это 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"}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 — полный список того, чего оно не умеет, выписанный целиком, чтобы вы могли отказаться прежде, чем что-то построите.
