Принимайте вебхуки Slen
PHPПроверьте подпись доставки, прочитайте её как типизированное событие, ответьте Slen и отличите доставку от её повтора.
5 мин чтения
Slen сообщает вашему сервису, что произошло, — запуск закончился, кредиты клиента на исходе, согласование запросили или по нему решили — вебхуком: POST на ваш адрес, подписанный секретом этого вебхука. SDK проверяет подпись и превращает доставку в типизированное событие. API-ключ для этого ему не нужен, и запросов он не отправляет.
Прежде чем начать
Скопируйте его секрет подписи — он показывается один раз — и храните его вместе с сервисом — Сохраните секрет.
Принять доставку
Доставка — это POST, тело которого — событие. Подпись идёт в заголовке рядом с типом тела:
POST /webhooks/slen HTTP/1.1
Content-Type: application/json
Slen-Signature: t=1792233600,v1=5f0c8e2a…t — время подписи, а v1 — подпись. Пример сам подписывает тело — вместо запроса, который Slen присылает вашему приёмнику.
// 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);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вы уже видели.
Отклонённая доставка
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 пишет отказ в свой журнал, с причиной и возрастом подписи — но никогда с телом, заголовком или секретом:
[
{ "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, не читая событие.
Что читать дальше
Вебхуки — что несёт доставка, как делается её подпись, и каждое событие.
Клиенты и кредиты — порог, который отправляет
customer.balance_low.Асинхронные запуски и ожидание —
execution.finishedвместо ожидания.