Принимайте вебхуки Slen

PHP

Проверьте подпись доставки, прочитайте её как типизированное событие, ответьте Slen и отличите доставку от её повтора.

5 мин чтения

Slen сообщает вашему сервису, что произошло, — запуск закончился, кредиты клиента на исходе, согласование запросили или по нему решили — вебхуком: POST на ваш адрес, подписанный секретом этого вебхука. SDK проверяет подпись и превращает доставку в типизированное событие. API-ключ для этого ему не нужен, и запросов он не отправляет.

Прежде чем начать

  • Добавьте вебхук на странице Вебхуки: адрес вашего приёмника и события, которые он слушает, — по шагам это описано в статье Вебхуки.

  • Скопируйте его секрет подписи — он показывается один раз — и храните его вместе с сервисом — Сохраните секрет.

Принять доставку

Доставка — это POST, тело которого — событие. Подпись идёт в заголовке рядом с типом тела:

HTTP
POST /webhooks/slen HTTP/1.1
Content-Type: application/json
Slen-Signature: t=1792233600,v1=5f0c8e2a…

t — время подписи, а v1 — подпись. Пример сам подписывает тело — вместо запроса, который Slen присылает вашему приёмнику.

PHP
// Stands in for the request Slen sends to your endpoint: its raw body and its
// Slen-Signature header, signed here with the secret of the example's endpoint.
$secret = 'whsec_example_endpoint';
$payload = '{"id":"74438200973594801","type":"execution.finished","created_at":"2026-10-17T12:00:00+00:00",'
    . '"data":{"execution_id":"74438200973594700","flow_id":"74438200973590001","operation":"generate_email","status":"completed","error_code":null}}';
$timestamp = time();
$signature = 't=' . $timestamp . ',v1=' . hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
PHP
use Slen\Sdk\Modules\Webhooks\Data\CustomerBalanceLowEvent;
use Slen\Sdk\Modules\Webhooks\Data\ExecutionFinishedEvent;
use Slen\Sdk\Modules\Webhooks\Options\WebhooksOptions;
use Slen\Sdk\Modules\Webhooks\WebhooksFacade;

// A receiver needs the secret of the endpoint, not an API key. Pass the body as it arrived.
$webhooks = WebhooksFacade::standalone(new WebhooksOptions(secret: $secret));

$event = $webhooks->constructEvent($payload, $signature);

if ($event instanceof ExecutionFinishedEvent) {
    echo $event->type, ': ', $event->executionId, ' ', $event->status->value, PHP_EOL;
}

if ($event instanceof CustomerBalanceLowEvent) {
    echo $event->type, ': ', $event->externalUserId, PHP_EOL;
}
  • Передавайте тело ровно таким, каким оно пришло, байт в байт. Тело, которое ваш фреймворк прочитал как JSON и записал заново, — не то тело, которое подписал Slen, и проверка его отклоняет.

  • Секрет, переданный в вызов, имеет приоритет: secret его опций, VerifyOptions. Без него проверка берёт секрет, с которым собран SDK, — webhookSecret. Если нет ни того ни другого, вызов отклоняется, прежде чем что-либо проверено.

Берите тело таким, каким оно пришло: $request->getContent() в Laravel. WebhooksFacade::standalone() даёт приёмник, которому не нужен API-ключ: секрет, допуск и логгер несут его WebhooksOptions. $slen->webhooks() — та же проверка с секретом и допуском из конфигурации SDK.

События

У четырёх событий есть свой тип, с прочитанными и типизированными полями: execution.finished, customer.balance_low, approval.requested и approval.decided. Любое другое событие приходит со своим type и своим data — такими, какими их прислал Slen, — и так же приходит событие, которое новее SDK. Каждое событие и поля этих четырёх перечисляет статья Вебхуки.

Событие — это ExecutionFinishedEvent, CustomerBalanceLowEvent, ApprovalRequestedEvent или ApprovalDecidedEvent, у каждого — свои поля как свойства. Любое другое событие — UnknownWebhookEvent, с type и сырым data.

Ответить на доставку

  • Отвечайте 2xx, как только подпись сошлась, а работу делайте потом. Доставку, которая не получила 2xx вовремя, отправляют ещё раз — когда и сколько раз, говорит статья Вебхуки.

  • Каждая доставка одного события несёт один и тот же id. Храните идентификаторы, которые ваш приёмник уже обработал, и пропускайте доставку, чей id вы уже видели.

Отклонённая доставка

PHP
use Slen\Sdk\Exceptions\WebhookSignatureException;

try {
    $webhooks->constructEvent(str_replace('completed', 'failed', $payload), $signature);
} catch (WebhookSignatureException $refused) {
    // Not the body Slen signed: answer 400 and do nothing with it.
    echo 'refused: ', $refused->reason, PHP_EOL;
}

Если подпись не сошлась, выбрасывается WebhookSignatureError в Node.js и Python и WebhookSignatureException в PHP, с кодом webhook_signature_invalid и полем reason. Ответьте 400 и ничего не делайте с телом. SDK пишет отказ в свой журнал, с причиной и возрастом подписи — но никогда с телом, заголовком или секретом:

slen.webhook.rejected
[
  { "reason": "missing_header", "age_seconds": null },
  { "reason": "malformed_header", "age_seconds": null },
  { "reason": "outside_tolerance", "age_seconds": 412 },
  { "reason": "no_matching_signature", "age_seconds": 2 }
]
ПричинаПочему
missing_headerУ запроса нет заголовка подписи или он пустой
malformed_headerВ заголовке нет времени t или нет подписи v1
outside_toleranceПодпись сделана раньше, чем позволяет webhookToleranceSeconds, — по умолчанию 300 секунд, — или часы вашего сервера идут неверно
no_matching_signatureНи одна подпись заголовка не совпадает с телом: его подписал другой секрет, или тело изменили
  • Заголовок может нести несколько подписей, и достаточно одной совпавшей.

  • toleranceSeconds из VerifyOptions заменяет допуск для одного вызова, а now — момент, на который проверяется подпись.

  • Для тела, подпись которого сошлась, но которое не является событием, выбрасывается InvalidResponseError или InvalidResponseException, с кодом webhook_payload_invalid.

  • webhooks.verify() делает ту же проверку и отвечает true или false, не читая событие.

Что читать дальше