Receive the webhooks of Slen

PHP

Check 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

  • Add a webhook on Webhooks: the address of your receiver and the events it hears — Webhooks walks through it.

  • 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:

HTTP
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.

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\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: secret of 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 2xx as soon as the signature holds, and do the work afterwards. A delivery that gets no 2xx in 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 whose id you have already seen.

A delivery that is refused

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;
}

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:

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 }
]
ReasonWhy
missing_headerThe request has no signature header, or an empty one
malformed_headerThe header has no time t, or no signature v1
outside_toleranceThe signature was made longer ago than webhookToleranceSeconds allows — 300 seconds by default — or the clock of your server is off
no_matching_signatureNo 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.

  • toleranceSeconds of VerifyOptions replaces the tolerance for one call, and now the moment the signature is checked at.

  • A body whose signature holds but that is not an event raises InvalidResponseError or InvalidResponseException, with the code webhook_payload_invalid.

  • webhooks.verify() makes the same check and answers true or false, without reading the event.

Where to go next