Вебхуки
Основной канал уведомлений об изменении заказа. Поллинг (GET /v1/orders, GET /v1/orders/{id}) остаётся резервным — не полагайтесь только на вебхуки, отсутствие одного вебхука не доказывает отсутствие изменения (сеть ненадёжна в обе стороны).
Настройка
Вебхук настраивается на стороне AploDelivery для вашего ingress-credential: webhookUrl (только https://, http://127.0.0.1 разрешён исключительно для тестов) и webhookSecret (≥ 32 символов, обязательно отличается от вашего API HMAC-секрета — сервер откажет в конфигурации, если они совпадают). Если webhookUrl не задан — вебхуки не отправляются, остаётся только поллинг. Один webhookUrl на пару tenantId + source; настройка выпуска/смены webhookUrl в v1 — конфигурационная на стороне AploDelivery, self-service API для этого не задеплоен.
Событийная модель (v1)
| Событие | Когда |
|---|---|
order.state_changed | любое изменение state заказа, включая переход в cancelled |
order.delivery_status_changed | плагин сообщил новый deliveryStatus (см. Жизненный цикл заказа) |
order.cancel_requested | заказ был в процессе создания, партнёр запросил отмену — она поставлена в очередь плагину |
order.cancel_result | плагин сообщил исход отмены — cancelled или rejected |
docs/PARTNER_API_V2_SPEC.md §6 добавляет планируемые типы событий: menu.updated, stoplist.updated, point.status_changed, order.payment_recorded, order.loyalty_confirmed, order.loyalty_released, order.accrued. Они пока не отправляются.
Тело запроса
{
"eventId": "5c9c9e0a-2b7a-4c86-9b8e-...",
"type": "order.delivery_status_changed",
"occurredAt": "2026-09-23T10:30:00+03:00",
"order": { "...": "то же тело, что GET /v1/orders/{id}" }
}
order — полная проекция заказа (OrderView/OrderStatus) без внутренних технических полей; PII (имя/телефон/адрес гостя) в это тело не входит — вебхук несёт только заказные/статусные поля, PII не пересылается webhook-ом.
Заголовки и подпись
| Заголовок | Значение |
|---|---|
X-Aplo-Event-Id | UUID события, тот же, что eventId в теле |
X-Aplo-Timestamp | Unix time в секундах, момент отправки |
X-Aplo-Signature | hex HMAC-SHA256 от canonical-строки |
Canonical-строка вебхука:
timestamp + "\n" + eventId + "\n" + sha256hex(body)
— обратите внимание, это другая (короче) формула, чем у входящих партнёрских запросов (там ещё есть метод и путь, см. Аутентификацию); не переиспользуйте одну функцию для обеих проверок без параметризации.
Проверка на Go (ingressclient.VerifyWebhook)
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "request too large", http.StatusRequestEntityTooLarge)
return
}
event, err := ingressclient.VerifyWebhook(
[]byte(os.Getenv("APLODELIVERY_WEBHOOK_SECRET")),
r.Header,
body,
time.Now(),
time.Minute,
)
if err != nil {
http.Error(w, "invalid webhook", http.StatusUnauthorized)
return
}
// Дедуплицировать event.EventID ДО бизнес-обработки.
VerifyWebhook (pkg/ingressclient/webhook.go) проверяет по порядку: наличие всех трёх заголовков, что X-Aplo-Event-Id — валидный UUID, что X-Aplo-Timestamp в пределах переданного допуска (в примере — 60 секунд), HMAC-подпись, и что тело — строгий JSON без неизвестных полей и без лишних значений после основного объекта. При любой ошибке возвращается zero-value Event{} — не пытайтесь читать частично распарсенные поля из результата с ошибкой.
Если готового SDK на вашем языке нет — реализуйте формулу проверки вручную по той же схеме: заголовки → парсинг timestamp/UUID → пересчитать HMAC от timestamp + "\n" + eventId + "\n" + sha256hex(body) → сравнить постоянным по времени способом (hmac.Equal в Go; на других языках — соответствующий constant-time compare, не ==/memcmp).
Дедупликация
Доставка — at-least-once. Дедуплицируйте по eventId до применения бизнес-эффекта (например по уникальному индексу на eventId в своей БД или проверкой «уже обработан» перед изменением состояния заказа на своей стороне). Повторная доставка одного и того же eventId с одинаковым телом — нормальная ситуация, не ошибка.
Ретраи
При неуспешной доставке (таймаут, сетевая ошибка, ответ не 2xx) сервис повторяет попытку по расписанию:
10s → 1m → 5m → 30m → 2h → 6h (≈ 9 часов суммарно)
После исчерпания расписания запись помечается dead и больше не повторяется — на этот случай и нужен резервный поллинг (FindByExternalOrderID/GetOrder). Таймаут одного HTTP-запроса на доставку — 5 секунд. Worker не следует HTTP-редиректам (3xx считается неуспехом наравне с 5xx) — если ваш эндпоинт переехал, обновите webhookUrl, не полагайтесь на редирект. При сбое доставки в лог сервиса пишется структурная запись без тела запроса/ответа (PII) — если нужна диагностика, обращайтесь в поддержку с eventId и примерным временем.
Практические рекомендации
- Отвечайте
2xxкак можно быстрее (в пределах 5-секундного таймаута) — тяжёлую бизнес-обработку выносите в очередь на своей стороне, не делайте её синхронно в хендлере вебхука. - Не считайте вебхук единственным источником истины: если вебхук не пришёл или пришёл с задержкой, подтверждайте состояние заказа через
GetOrder/FindByExternalOrderID. - Ограничивайте размер читаемого тела (
http.MaxBytesReader, как в примере) — вебхук приходит от доверенного источника, но защита от аномально большого тела остаётся хорошей практикой.