Вебхуки
Подписка на изменения вместо периодического опроса. Событие отправляется тогда, когда состояние изменилось: склад сообщает об этом сам — подтвердили заявку, закончили приёмку, передали поставку. Обычная задержка от действия на складе до запроса на ваш адрес — несколько секунд.
Раз в несколько минут мы дополнительно сверяем состояние целиком, чтобы изменение, о котором сообщить не удалось (сбой сети, наша перезагрузка), всё равно доехало — с задержкой, но не пропало. Поэтому проектируйте обработчик на «событие придёт, порядок и точный момент не гарантированы», а не на «событие придёт ровно через N секунд».
Регистрация и проверка URL
POST https://api.perfektpak.ru/v1/webhooks
Authorization: Bearer $PP_KEY
Content-Type: application/json
{ "url": "https://your-system.example/webhooks/perfektpak", "events": ["receipt.completed", "order.status_changed"], "description": "Синхронизация статусов" }
При создании мы сразу отправляем на этот URL событие webhook.ping.
Если он не ответил 2xx в течение 10 секунд — подписка не
создаётся, а ответ на POST — 400 webhook_url_unverified:
так подписку нельзя завести на URL, который вам не принадлежит или временно
недоступен. Иначе — 201:
{
"webhook": {
"id": "whk_5f3a9c1e2b7d", "url": "https://your-system.example/webhooks/perfektpak",
"events": ["receipt.completed", "order.status_changed"], "description": "Синхронизация статусов",
"active": true, "created_at": "2026-09-02T10:00:00+03:00"
},
"secret": "whsec_9f3a1c2b7e4d5f60718293a4b5c6d7e8"
}
secret нужен только для проверки подписи доставок (см. ниже)
и не возвращается повторно ни в GET /v1/webhooks/{id}, ни
где-либо ещё. Потеряли — удалите подписку (DELETE /v1/webhooks/{id})
и заведите новую.
Лимит — 10 подписок на клиента; сверх лимита POST отвечает 400 webhook_limit. Управление подпиской:
| Ручка | Что делает |
|---|---|
GET /v1/webhooks | Список ваших подписок. |
GET /v1/webhooks/{id} | Одна подписка (без секрета). |
DELETE /v1/webhooks/{id} | Удалить подписку. |
POST /v1/webhooks/{id}/ping | Переслать тестовое событие webhook.ping вручную — проверить обработчик, не дожидаясь реального события. |
Формат доставки
Каждое событие — отдельный POST на ваш URL:
POST /webhooks/perfektpak HTTP/1.1
Content-Type: application/json
X-PP-Event: receipt.completed
X-PP-Delivery: dlv_7a1b2c3d
X-PP-Timestamp: 1757000000
X-PP-Signature: sha256=5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d
{
"id": "evt_7c1b2a",
"type": "receipt.completed",
"occurred_at": "2026-08-12T16:45:00+03:00",
"data": { "id": "r_2026_0812_04", "status": "RECEIVED_WITH_DISCREPANCY", "discrepancy_qty": 120 }
}
data для событий *.status_changed — конверт
{"object": {…}, "previous_status": "…"}; для остальных — сам
объект (Receipt / Shipment / Order),
в точности как он выглядит в соответствующей ручке GET.
Ответ 2xx в течение 10 секунд считается доставкой; дольше или
с любым другим статусом — неудачей, включается расписание повторов ниже.
Отвечайте быстро и обрабатывайте асинхронно, если сама обработка может
занять больше времени, чем таймаут.
Идемпотентность
Повтор доставки после локального сбоя на нашей стороне может прислать одно
и то же событие дважды с одинаковым id (но, возможно, другим
X-PP-Delivery — это разные попытки одного события). Сохраняйте
обработанные id событий и пропускайте повтор — пример в гайде
«Статусы заказов FBS в CRM».
Проверка подписи
X-PP-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>,
где timestamp — значение заголовка X-PP-Timestamp
(unix-время), а body — сырое, необработанное тело
запроса, ровно те байты, что пришли по сети. Разбор в объект и
повторная сериализация в JSON перед проверкой — частая причина, по которой
подпись «не сходится»: форматирование чисел, порядок полей или пробелы
после такого прохода могут отличаться от исходных байтов.
import hashlib
import hmac
def verify_signature(secret: str, timestamp: str, raw_body: bytes, signature_header: str) -> bool:
payload = timestamp.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header)
# в обработчике запроса (Flask):
# ok = verify_signature(secret, request.headers["X-PP-Timestamp"], request.get_data(), request.headers["X-PP-Signature"])<?php
function verifySignature(string $secret, string $timestamp, string $rawBody, string $signatureHeader): bool
{
$expected = "sha256=" . hash_hmac("sha256", $timestamp . "." . $rawBody, $secret);
return hash_equals($expected, $signatureHeader);
}
// в обработчике запроса:
// $ok = verifySignature($secret, $_SERVER["HTTP_X_PP_TIMESTAMP"], file_get_contents("php://input"), $_SERVER["HTTP_X_PP_SIGNATURE"]);const crypto = require("crypto");
function verifySignature(secret, timestamp, rawBody, signatureHeader) {
const payload = Buffer.concat([Buffer.from(timestamp), Buffer.from("."), Buffer.from(rawBody)]);
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(payload).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// в обработчике запроса (Express, с express.raw() для этого пути, не express.json()):
// const ok = verifySignature(secret, req.headers["x-pp-timestamp"], req.body, req.headers["x-pp-signature"]);Повторы и отключение
Неудачная доставка (не 2xx, таймаут 10 секунд, недоступный
хост) повторяется по расписанию: через 1 минуту, 5 минут, 30 минут, 2 часа,
12 часов. После восьмой подряд неудачи подписка автоматически отключается
(active: false, disabled_reason) — новые события
по ней не отправляются. Включить обратно можно только пересозданием
подписки: DELETE старую и POST новую (с новым
webhook.ping и новым секретом).
События
| Событие | Когда |
|---|---|
receipt.created | Заявка на приёмку создана (в том числе через POST /v1/receipts). |
receipt.confirmed | Администратор подтвердил заявку — PENDING_CONFIRMATION → EXPECTED. |
receipt.status_changed | Любой другой переход статуса приёмки. |
receipt.completed | Приёмка завершена — RECEIVED или RECEIVED_WITH_DISCREPANCY. |
shipment.status_changed | Смена статуса отгрузки, включая переход в ACCEPTED. |
order.status_changed | Смена нормализованного статуса заказа FBS. |
webhook.ping | Проверка URL при регистрации и по ручному вызову POST /v1/webhooks/{id}/ping. |
Журнал доставок
Каждая попытка доставки видна вам самим — полезно, когда обработчик молчит и непонятно, дошло ли событие вообще:
curl "https://api.perfektpak.ru/v1/webhooks/whk_5f3a9c1e2b7d/deliveries?status=failed&limit=50" \
-H "Authorization: Bearer $PP_KEY"
GET /v1/webhooks/{id}/deliveries/{delivery_id} отдаёт ту же
запись с телом события — удобно свериться, что именно мы отправили,
байт в байт, при разборе проблем с подписью.
Что дальше
Готовый пример подписчика для одного конкретного события — в гайдах «Статусы заказов FBS в CRM» и «Сверка недостач по приёмке».