Receive the webhooks of Slen
PHPCheck the signature of a delivery, read it as a typed event, answer Slen, and tell a delivery from its repeat.
5 min read
Slen tells your service what happened — a run finished, the credits of a customer ran low, an approval was asked for or decided — with a webhook: a POST to an address of yours, signed with the secret of that webhook. The SDK checks the signature and reads the delivery into a typed event. It needs no API key for that and sends no request.
Before you start
Copy its signing secret, which is shown once, and keep it with your service — Keep the secret.
Receive a delivery
A delivery is a POST whose body is the event. Its signature travels in a header beside the type of the body:
POST /webhooks/slen HTTP/1.1
Content-Type: application/json
Slen-Signature: t=1792233600,v1=5f0c8e2a…t is the time of signing, and v1 is the signature. The example signs a body itself, in place of the request Slen sends to your receiver.
// 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\CustomerBalanceLowEventDTO;
use Slen\Sdk\Modules\Webhooks\Data\ExecutionFinishedEventDTO;
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 ExecutionFinishedEventDTO) {
echo $event->type, ': ', $event->executionId, ' ', $event->status->value, PHP_EOL;
}
if ($event instanceof CustomerBalanceLowEventDTO) {
echo $event->type, ': ', $event->externalUserId, PHP_EOL;
}Pass the body exactly as it arrived, byte for byte. A body your framework has read as JSON and written again is not the body Slen signed, and the check refuses it.
The secret passed to the call wins:
secretof its options,VerifyOptions. Without it the check takes the secret the SDK was built with,webhookSecret. With neither, the call is refused before anything is checked.
Take the body as it arrived: $request->getContent() in Laravel. WebhooksFacade::standalone() gives a receiver that needs no API key: its WebhooksOptions carry the secret, the tolerance and the logger. $slen->webhooks() is the same check with the secret and the tolerance of the SDK's config.
The events
Four events have a type of their own, with their fields read and typed: execution.finished, customer.balance_low, approval.requested and approval.decided. Every other event arrives with its type and its data as Slen sent it, and so does an event newer than the SDK. Webhooks lists every event and the fields of the four.
An event is ExecutionFinishedEventDTO, CustomerBalanceLowEventDTO, ApprovalRequestedEventDTO or ApprovalDecidedEventDTO, each with its fields as properties. Any other event is UnknownEventDTO, with type and the raw data.
Answer the delivery
Answer with a
2xxas soon as the signature holds, and do the work afterwards. A delivery that gets no2xxin time is sent again — Webhooks says when and how often.Every delivery of one event carries the same
id. Keep the ids your receiver has handled, and skip a delivery whoseidyou have already seen.
A delivery that is refused
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;
}A signature that does not hold raises WebhookSignatureError in Node.js and WebhookSignatureException in PHP, with the code webhook_signature_invalid and a reason. Answer 400 and do nothing with the body. The SDK writes the refusal to its journal, with the reason and the age of the signature — never the body, the header or the secret:
[
{ "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 }
]| Reason | Why |
|---|---|
missing_header | The request has no signature header, or an empty one |
malformed_header | The header has no time t, or no signature v1 |
outside_tolerance | The signature was made longer ago than webhookToleranceSeconds allows — 300 seconds by default — or the clock of your server is off |
no_matching_signature | No signature of the header matches the body: another secret signed it, or the body was changed |
A header may carry several signatures, and one that matches is enough.
toleranceSecondsofVerifyOptionsreplaces the tolerance for one call, andnowthe moment the signature is checked at.A body whose signature holds but that is not an event raises
InvalidResponseErrororInvalidResponseException, with the codewebhook_payload_invalid.webhooks.verify()makes the same check and answerstrueorfalse, without reading the event.
Where to go next
Webhooks — what a delivery carries, how its signature is made, and every event.
Customers and credits — the threshold that sends
customer.balance_low.Async runs and waiting —
execution.finishedin place of a wait.