Guide 7 of 7 · Integration
Webhook event delivery with signed payloads
Push events to third-party systems in near-real-time with signed payloads, retry logic, and event replay capabilities.
Use it when
Your API needs to push events to third-party systems in near-real-time.
Skip it when
Consumers can easily poll, latency requirements are low, or you don't want webhook operational overhead.
Webhooks let your system notify others when something happens, instead of making them constantly poll. A robust pattern includes:
- Registering endpoints as resources (
/webhook-endpoints). - Signing payloads so consumers can verify authenticity.
- Retries with exponential backoff on non-2xx responses.
- A way to replay events safely.
Example: webhook endpoint registration.
POST /webhook-endpointsContent-Type: application/json
{ "url": "https://api.example-client.test/webhooks/invoices", "events": ["invoice.created", "invoice.paid"]}
HTTP/1.1 201 CreatedContent-Type: application/json
{ "id": "wh_123", "url": "https://api.example-client.test/webhooks/invoices", "events": ["invoice.created", "invoice.paid"], "secret": "whsec_6d9e2ab4..."}Delivery example.
POST /webhooks/invoicesUser-Agent: YourAPI-Webhooks/1.0Content-Type: application/jsonX-Webhook-Event: invoice.createdX-Webhook-Signature: t=1736771700,v1=5fe46f5f3b...
{ "id": "evt_9d3", "type": "invoice.created", "data": { "id": "inv_123", "customer_id": "cus_123", "amount_due": 4900, "currency": "GBP" }}The signature header includes a timestamp and HMAC over the payload using the shared secret, so consumers can verify and guard against replay attacks.
When a shared secret is the wrong root of trust
HMAC is the right default, and it is what most providers use. It has one property worth checking against your own events: the receiver holds a key capable of forging your signatures, so a payload they have verified and filed is not something they can later show to anyone else as proof of what you sent. Rotating the secret, which you should be doing, also makes every previously signed payload uncheckable unless you keep retired secrets forever.
If your events are things a receiver may need to prove later, such as financial movements or audit records, sign asymmetrically instead: you hold a private key, you publish the public one, and a filed payload stays verifiable by anyone, including after rotation. Standard Webhooks covers this as v1a using Ed25519.
See webhook signature verification on apiguide.dev for the full treatment, including constant-time comparison, replay windows and key rotation.
Trade-offs
Pros
-
Efficient and timely updates; great for integrations.
-
Fits well with event-driven architecture.
-
Lets you decouple internal systems from external consumers.
Cons
-
Operationally heavier: you must handle retries, dead-lettering, and monitoring.
-
Consumers need to get security right (verification and time windows).
DX tips
-
Provide test endpoints or a CLI to simulate deliveries.
-
Offer a web UI or API to inspect recent webhook attempts and responses.
-
Clearly document retry schedule and when you consider an endpoint “dead”.
In Laravel
The delivery itself is a queued job, and the thing worth getting right is that sending and recording are one operation. If a second piece of code anywhere in the app can make an outbound webhook call without writing an attempt row, your delivery log stops describing what the system sent.
final class DeliverWebhook implements ShouldQueue{ public array $backoff = [60, 300, 1800]; public int $tries = 6;
public function __construct( private readonly WebhookEndpoint $endpoint, private readonly WebhookEvent $event, ) {}
public function handle(): void { $body = $this->event->payloadJson(); $timestamp = now()->timestamp; $signature = hash_hmac('sha256', "{$timestamp}.{$body}", $this->endpoint->secret);
$started = hrtime(true);
try { $response = Http::timeout(10) ->withBody($body, 'application/json') ->withHeaders([ 'X-Webhook-Event' => $this->event->type, 'X-Event-Id' => $this->event->id, 'X-Webhook-Signature' => "t={$timestamp},v1={$signature}", ]) ->post($this->endpoint->url);
$this->record(status: $response->status(), started: $started);
$response->throw(); } catch (ConnectionException $e) { $this->record(status: null, started: $started, error: $e->getMessage()); throw $e; } }}Five things in that are deliberate:
$backoff is an array, and it does not mean what it looks like. With tries = 6 and three delays, Laravel repeats the final value once the array is exhausted, so attempts four, five and six are all 1800 seconds apart. If you show customers a predicted next attempt, this is the rule you have to match. Deriving it from count($backoff) gives the wrong answer for half your retries.
A connection failure records a null status, not a zero. “We never heard back” and “it returned 500” need different investigations, and rendering the second when you mean the first sends someone hunting an access log for a request that was never completed.
The record is written before throw(). The throw is what tells the queue to retry; the record has to exist whether or not it happens.
The signature covers a timestamp and the raw body string, and that same string is what gets sent. Never sign one representation and transmit another.
X-Event-Id is the receiver’s dedupe key. It is the single most useful header you can send, because it is the only thing that lets a receiver make itself idempotent. See receiving webhooks in Laravel.
Record attempts, not delivery state
The obvious schema is a delivered_at and a last_error on the event row. It answers “did this arrive” and is useless the moment anyone investigates, because a delivery that failed five times and then succeeded clears its error on success and reads, forever afterwards, as though it always worked.
Write one row per attempt instead and derive current state from the history:
private function record(?int $status, int $started, ?string $error = null): void{ WebhookAttempt::create([ 'endpoint_id' => $this->endpoint->id, 'event_id' => $this->event->id, 'attempt' => $this->attempts(), 'of' => $this->tries, 'status' => $status, // null when nothing came back 'duration_ms' => (int) ((hrtime(true) - $started) / 1e6), 'error' => $error, 'next_at' => $this->nextAttemptAt(), // null when this was the last ]);}Do not store the response body. A receiver’s error page can contain anything, including their customers’ data, and the status code answers the question you actually have. See webhook delivery history for the full treatment, including what to show the customer.
One last trap, and it is the one that bites in tests rather than production: Queue::fake() makes dispatchSync() stop executing. Dispatcher::dispatchSync routes through dispatchToQueue when a queue resolver is present, so the job is recorded rather than run, and an assertion on its side effects fails for reasons that have nothing to do with the job. Call the handler through app()->call() when you want it to actually execute under a fake.
Read next
Related guides
Idempotency keys for safe retries
Let clients safely retry non-idempotent calls without accidentally creating duplicates. Essential for payments and critical operations.
Intro
IntegrationReceiving webhooks in Laravel
Verify before you touch the database, store the raw payload, process asynchronously, and dedupe on the sender event id. Plus the Cashier ordering trap that makes listeners fire too early.
Intermediate
Same subject
In other formats
Accepting Data You Don't Control
Webhooks and callbacks you did not design. An ingest server in Laravel 13 that owns the envelope, stores the payload whole, and validates where failure means a retry, not data loss.
API Design
Codelaravel-bastion
Stripe-inspired API authentication with environment isolation, granular scopes, and built-in security.
API Design
VideoThe Definitive Guide to Webhooks in Laravel
Real-time communication is no longer a luxury - it’s a necessity. In this video, we dive into the world of webhooks and show you how to integrate them seamlessly into your Laravel applications.
API Design