marko/webhook
Send and receive webhooks with HMAC-SHA256 signature verification, automatic retry with exponential backoff, and delivery attempt tracking. Outgoing webhooks are signed with a shared secret and delivered over HTTP. Incoming webhooks are verified against the same signature before the payload is parsed. Failed deliveries are automatically retried via the queue with exponential backoff. Every delivery attempt --- success or failure --- is recorded to the webhook_attempts table.
Installation
Section titled “Installation”composer require marko/webhookRequires marko/http for the HTTP client and marko/queue for async dispatch.
Configuration
Section titled “Configuration”Override defaults in your config file:
return [ 'timeout' => 30, // seconds before the HTTP request times out 'max_retries' => 3, // maximum delivery attempts (including the first) 'retry_delay' => 60, // base delay in seconds; multiplied exponentially per attempt 'timestamp_tolerance' => 300, // seconds a webhook timestamp may differ from server time];With the defaults, a job that fails on every attempt retries at 120 s, 240 s, and 480 s.
The timestamp_tolerance controls replay-attack protection for inbound webhooks. Requests whose X-Webhook-Timestamp header is more than this many seconds in the past or future are rejected.
Sending Webhooks
Section titled “Sending Webhooks”Build a WebhookPayload and call WebhookDispatcher::dispatch() to send synchronously:
use Marko\Webhook\Sending\WebhookDispatcher;use Marko\Webhook\Value\WebhookPayload;
public function __construct( private readonly WebhookDispatcher $webhookDispatcher,) {}
public function notifySubscriber(): void{ $payload = new WebhookPayload( url: 'https://example.com/webhooks', event: 'order.created', data: ['order_id' => 42, 'total' => '99.99'], secret: 'your-shared-secret', );
$response = $this->webhookDispatcher->dispatch($payload);
if (!$response->successful) { // handle failure }}The dispatcher automatically records the current Unix timestamp, includes it as an X-Webhook-Timestamp header, and signs the message as "{timestamp}.{body}" — producing an X-Webhook-Signature: sha256={hash} header. The receiver verifies both the timestamp freshness and the signature before accepting the payload.
Sending Asynchronously with Retry
Section titled “Sending Asynchronously with Retry”Push a DispatchWebhookJob onto the queue to send in the background with automatic retry on failure:
use Marko\Queue\QueueInterface;use Marko\Webhook\Jobs\DispatchWebhookJob;use Marko\Webhook\Value\WebhookPayload;
public function __construct( private readonly QueueInterface $queue,) {}
public function scheduleWebhook(): void{ $payload = new WebhookPayload( url: 'https://example.com/webhooks', event: 'order.shipped', data: ['order_id' => 42, 'tracking' => 'ABC123'], secret: 'your-shared-secret', );
$this->queue->push(new DispatchWebhookJob($payload));}Failed deliveries retry up to max_retries times with delays calculated as retry_delay * 2^attempt seconds.
Receiving Webhooks
Section titled “Receiving Webhooks”Use WebhookReceiver::receive() in a controller to verify the signature and parse the payload. An InvalidSignatureException is thrown if the X-Webhook-Timestamp header is absent, if the timestamp is outside the tolerance window, or if the signature does not match:
use Marko\Routing\Http\Request;use Marko\Webhook\Exceptions\InvalidSignatureException;use Marko\Webhook\Receiving\WebhookReceiver;
public function __construct( private readonly WebhookReceiver $webhookReceiver,) {}
public function handle( Request $request,): void { try { $data = $this->webhookReceiver->receive( request: $request, secret: 'your-shared-secret', );
$event = $data['event']; $payload = $data['data'];
// process event... } catch (InvalidSignatureException) { // reject the request }}Using the WebhookEndpoint Attribute
Section titled “Using the WebhookEndpoint Attribute”Mark a controller method with #[WebhookEndpoint] to declare its path and secret inline:
use Marko\Routing\Http\Request;use Marko\Webhook\Attributes\WebhookEndpoint;
class StripeWebhookController{ #[WebhookEndpoint(path: '/webhooks/stripe', secret: 'whsec_...')] public function handle( Request $request, ): void { // $request is already routed here; verify with WebhookReceiver }}Delivery Tracking
Section titled “Delivery Tracking”Every attempt is saved to the webhook_attempts table via WebhookDeliveryService. Successful attempts store the HTTP status code and response body. Failed attempts store the error message. Use WebhookAttemptRepositoryInterface to query the records:
use Marko\Webhook\Contracts\WebhookAttemptRepositoryInterface;
public function __construct( private readonly WebhookAttemptRepositoryInterface $webhookAttemptRepository,) {}API Reference
Section titled “API Reference”WebhookPayload
Section titled “WebhookPayload”use Marko\Webhook\Value\WebhookPayload;
public function __construct( string $url, string $event, array $data, string $secret,);WebhookResponse
Section titled “WebhookResponse”use Marko\Webhook\Value\WebhookResponse;
public function __construct( int $statusCode, string $body, bool $successful,);WebhookDispatcher
Section titled “WebhookDispatcher”use Marko\Webhook\Sending\WebhookDispatcher;use Marko\Webhook\Value\WebhookPayload;use Marko\Webhook\Value\WebhookResponse;
public function dispatch(WebhookPayload $payload): WebhookResponse;WebhookReceiver
Section titled “WebhookReceiver”use Marko\Routing\Http\Request;use Marko\Webhook\Receiving\WebhookReceiver;
// @throws InvalidSignatureExceptionpublic function receive(Request $request, string $secret): array;WebhookVerifier
Section titled “WebhookVerifier”use Marko\Webhook\Receiving\WebhookVerifier;
public function verify(string $body, string $timestamp, string $signature, string $secret, int $tolerance): bool;WebhookSignature
Section titled “WebhookSignature”use Marko\Webhook\Sending\WebhookSignature;
// Returns "sha256={hash}"; message is "{timestamp}.{payload}"public static function sign(string $payload, string $secret, int $timestamp): string;DispatchWebhookJob
Section titled “DispatchWebhookJob”use Marko\Webhook\Jobs\DispatchWebhookJob;use Marko\Webhook\Value\WebhookPayload;
public function __construct( WebhookPayload $payload, int $attemptNumber = 1,);public function handle(): void;Implements ContainerAwareJobInterface from marko/queue. The job stores only the WebhookPayload value object and the attempt number; services (WebhookDispatcherInterface, WebhookDeliveryService, QueueInterface, config) are resolved from the container at handle() time by the queue Worker. Do not inject services via the constructor.
WebhookDeliveryService
Section titled “WebhookDeliveryService”use Marko\Webhook\Sending\WebhookDeliveryService;use Marko\Webhook\Value\WebhookPayload;use Marko\Webhook\Value\WebhookResponse;
public function recordSuccess(WebhookPayload $payload, WebhookResponse $response, int $attempt): void;public function recordFailure(WebhookPayload $payload, string $error, int $attempt): void;WebhookAttempt (Entity)
Section titled “WebhookAttempt (Entity)”| Column | Type | Description |
|---|---|---|
id | int | Auto-increment primary key |
webhook_url | string | Destination URL |
event | string | Event name |
attempt_number | int | Which attempt this record covers |
status_code | int | HTTP status code (success only) |
response_body | string | Response body (success only) |
error_message | string | Error message (failure only) |
attempted_at | string | Timestamp in Y-m-d H:i:s format |
WebhookConfig
Section titled “WebhookConfig”use Marko\Webhook\Config\WebhookConfig;
public int $timeout; // from webhook.timeoutpublic int $maxRetries; // from webhook.max_retriespublic int $retryDelay; // from webhook.retry_delaypublic int $timestampTolerance; // from webhook.timestamp_toleranceInvalidSignatureException
Section titled “InvalidSignatureException”use Marko\Webhook\Exceptions\InvalidSignatureException;
// Thrown when X-Webhook-Timestamp header is absentInvalidSignatureException::missingTimestamp();
// Thrown when the timestamp is outside the tolerance windowInvalidSignatureException::staleTimestamp(int $timestamp, int $tolerance);
// Thrown when the HMAC signature does not matchInvalidSignatureException::forRequest();Interfaces
Section titled “Interfaces”use Marko\Webhook\Contracts\WebhookDispatcherInterface;use Marko\Webhook\Value\WebhookPayload;use Marko\Webhook\Value\WebhookResponse;
interface WebhookDispatcherInterface { public function dispatch(WebhookPayload $payload): WebhookResponse;}use Marko\Routing\Http\Request;use Marko\Webhook\Contracts\WebhookReceiverInterface;
interface WebhookReceiverInterface { public function receive(Request $request, string $secret): array;}use Marko\Webhook\Contracts\WebhookAttemptRepositoryInterface;use Marko\Webhook\Entity\WebhookAttempt;
interface WebhookAttemptRepositoryInterface { public function save(WebhookAttempt $attempt): WebhookAttempt;}