Skip to main content
WritingSpeakingCodeAboutNow

Sending And Receiving Webhooks In Laravel

A webhook is a contract between two codebases that never meet. Both ends of it in plain Laravel: what to sign, how long to retry, and how the receiver avoids doing the work twice.

A customer deploys their app at 14:00. Their webhook endpoint is down for four minutes while it happens. Your app sends them invoice.paid at 14:01, gets a 502, waits ten seconds, gets another, waits a hundred, gets a third, and gives up. Their invoice stays unpaid in their system. Nothing anywhere says so.

Both sides did what their code told them to. The sender retried three times, which sounds generous until you notice it spent less than two minutes doing it. The receiver went down for a deploy, which every receiver does. Neither half was written with the other in mind. That is how most webhook code in Laravel apps gets written: one end at a time, by people who will never read the other end.

A webhook is a contract between two codebases that never meet, so start with the contract.

Agree on the contract before writing either end

Three things have to be true of every delivery, whoever sends it:

  • It carries an identifier that stays the same across retries, so the receiver can tell a retry from a new event.
  • It carries a timestamp, and the signature covers it, so a request captured today cannot be replayed next week.
  • The signature is computed over the exact bytes of the body, so nothing has to agree on how JSON is formatted.

You can invent headers for that, and most providers did. I would rather use Standard Webhooks, a spec written by people who run webhook infrastructure, because it is small and it already says all three. It also means the receiver may have done this before.

POST /webhooks/billing HTTP/1.1
Content-Type: application/json
webhook-id: msg_01J9Z3K4M5N6P7Q8R9S0T1V2W3
webhook-timestamp: 1791295260
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
{"type":"invoice.paid","timestamp":"2026-10-06T14:01:00Z","data":{"id":"inv_8842"}}

The signature is HMAC-SHA256 over {id}.{timestamp}.{body}, base64 encoded, prefixed with a version. The secret is 32 random bytes, base64 encoded with a whsec_ prefix so it is recognisable when it leaks into a log. That is the whole format.

Sending

Record the event with the change that caused it

The event has to exist if and only if the change happened. Write it in the same transaction:

DB::transaction(function () use ($invoice): void {
$invoice->markAsPaid();
WebhookEvent::record('invoice.paid', ['id' => $invoice->public_id]);
});
final class WebhookEvent extends Model
{
public $incrementing = false;
protected $keyType = 'string';
public static function record(string $type, array $data): self
{
$event = self::create([
'id' => 'msg_' . Str::ulid(),
'type' => $type,
'payload' => json_encode([
'type' => $type,
'timestamp' => now()->toIso8601ZuluString(),
'data' => $data,
], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES),
]);
WebhookEndpoint::subscribedTo($type)->each(
fn (WebhookEndpoint $endpoint) => DeliverWebhook::dispatch($endpoint, $event)->afterCommit(),
);
return $event;
}
}

The payload is encoded once and stored as a string. Make that column text, not json. Postgres jsonb and MySQL’s JSON type both normalise what they store, reordering keys and rewriting whitespace, and the signature has to cover exactly the bytes you send. Encoding once also means every retry sends the same bytes, which the receiver can rely on.

afterCommit() stops a job being dispatched for a transaction that rolls back. It does not cover the process dying between the commit and the dispatch. That window is small, and if the event matters enough that you cannot lose one, closing it is what the transactional outbox is for.

The delivery job

One job per endpoint per event:

#[Tries(10)]
#[Timeout(30)]
final class DeliverWebhook implements ShouldQueue
{
use Queueable;
public function __construct(
public WebhookEndpoint $endpoint,
public WebhookEvent $event,
) {}
/** @return list<int> */
public function backoff(): array
{
// The Standard Webhooks example schedule: about three days, front-loaded.
$schedule = [5, 300, 1800, 7200, 18000, 36000, 50400, 72000, 86400];
return array_map(
fn (int $seconds): int => $seconds + random_int(0, intdiv($seconds, 10)),
$schedule,
);
}
public function handle(): void
{
if ($this->endpoint->disabled_at !== null) {
return;
}
$timestamp = now()->getTimestamp();
$response = Http::timeout(15)
->withoutRedirecting()
->withBody($this->event->payload, 'application/json')
->withHeaders([
'webhook-id' => $this->event->id,
'webhook-timestamp' => (string) $timestamp,
'webhook-signature' => $this->endpoint->sign($this->event->id, $timestamp, $this->event->payload),
])
->post($this->endpoint->url);
if ($response->successful()) {
return;
}
if ($response->status() === 410) {
$this->endpoint->disable(reason: 'The endpoint returned 410 Gone');
return;
}
$retryAfter = $response->header('Retry-After');
if (ctype_digit($retryAfter)) {
$this->release(min((int) $retryAfter, 3600));
return;
}
throw new RuntimeException("Webhook endpoint {$this->endpoint->id} returned {$response->status()}");
}
public function failed(?Throwable $exception): void
{
$this->endpoint->recordFailure($this->event, $exception);
}
}

Most of that reads as it looks. These are the lines that do not.

The timestamp and signature are made inside handle(), on every attempt. The receiver rejects anything older than about five minutes, and the eighth attempt happens more than a day after the first. A signature computed once at dispatch would be stale for every retry that matters.

Ten tries over three days is what the opening needed. Three tries in two minutes covers a blip. It does not cover a bad release that takes an hour to roll back, or an expired certificate nobody notices until Monday. The jitter stops every delivery that failed during the same outage from retrying in the same second and knocking the endpoint over again as it comes back. backoff() is evaluated once, when the job is dispatched, so each job gets its own jitter and keeps it.

withoutRedirecting(), because Laravel’s HTTP client follows redirects by default. A webhook URL that redirects is misconfigured, and following it means POSTing your signed payload to somewhere the customer did not register. A 3xx counts as a failure, and the customer can fix their URL.

A 410 means stop, not retry: the receiver is telling you the endpoint is gone on purpose. Retry-After is honoured, capped at an hour, because a 429 or 503 that tells you when to come back is more accurate than any schedule you guessed. The cap is there so a receiver cannot park a job for a month. This handles the seconds form of the header; it can also be an HTTP date, which is worth parsing if your receivers send it.

Check your queue driver before copying the schedule. On SQS, Laravel delays a retry by changing the message’s visibility timeout, and AWS caps that at twelve hours. The last three entries are fourteen, twenty and twenty-four hours, so either cap them or run this queue on the database or Redis driver.

Signing is a method on the endpoint, because the secret lives there:

final class WebhookEndpoint extends Model
{
protected function casts(): array
{
return [
'secrets' => 'encrypted:array',
'disabled_at' => 'datetime',
];
}
public function sign(string $id, int $timestamp, string $body): string
{
return collect($this->secrets)
->map(fn (string $secret): string => 'v1,' . base64_encode(hash_hmac(
'sha256',
"{$id}.{$timestamp}.{$body}",
base64_decode(Str::after($secret, 'whsec_')),
binary: true,
)))
->implode(' ');
}
}

secrets is a list rather than a single value. That is the whole of secret rotation. When a customer rotates, put the new secret first and keep the old one for a day. Every delivery carries both signatures, space separated, and the receiver accepts the delivery if either one matches. They can switch over whenever they like, inside that day, without a window where deliveries fail.

failed() runs when the tenth attempt fails. Make it do something a person will see. Retries that end in a row nobody reads still lose the customer’s event. They just take three days to do it. Email the customer, and disable the endpoint after enough consecutive failures, so you stop paying to retry a URL that is never coming back.

Receiving

The receiving end has one job before anything else: decide whether this request came from who it claims to.

Verify the bytes you were sent

final readonly class VerifyWebhookSignature
{
private const int TOLERANCE_SECONDS = 300;
public function handle(Request $request, Closure $next): Response
{
$id = (string) $request->header('webhook-id');
$timestamp = (string) $request->header('webhook-timestamp');
if ($id === '' || ! ctype_digit($timestamp) || abs(time() - (int) $timestamp) > self::TOLERANCE_SECONDS) {
return response()->noContent(400);
}
$secret = base64_decode(Str::after(config('services.billing.webhook_secret'), 'whsec_'));
$expected = base64_encode(hash_hmac(
'sha256',
"{$id}.{$timestamp}.{$request->getContent()}",
$secret,
binary: true,
));
foreach (explode(' ', (string) $request->header('webhook-signature')) as $signature) {
[$version, $value] = array_pad(explode(',', $signature, 2), 2, '');
if ($version === 'v1' && hash_equals($expected, $value)) {
return $next($request);
}
}
return response()->noContent(400);
}
}

$request->getContent() is the raw body. Anything else, $request->all() re-encoded or a form request’s validated array, is a different string from the one that was signed. Verification then fails sometimes rather than always. That is the worst way for it to fail. hash_equals() compares in constant time, so response timing does not tell an attacker how much of their guess was right.

The timestamp check runs first because it is cheap, and it is what makes a captured request useless five minutes later. Looping over the signatures is the receiving half of rotation.

There is an official PHP library for this if you would rather not own twenty lines of crypto. If you use it, pass it the three headers as strings. Laravel’s $request->headers->all() gives you each header as an array, and the library does not reject that. It reads it as a timestamp of 1 and fails every delivery with “Message timestamp too old”, which sends you off checking your server’s clock.

Exempt the route from CSRF in bootstrap/app.php, since there is no session to have a token:

->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['webhooks/*']);
})

Acknowledge, then work

Once the signature checks out, store the raw body and the webhook-id, dispatch a job, and return 202. The sender is waiting with a fifteen second timeout. To them a slow response looks exactly like a failure, so a handler that does the work inline earns retries it did not need. I have written up this half in more depth in receiving webhooks in Laravel, including why you keep the raw payload and the Cashier event that fires before Cashier has written anything.

Do the work once

The sender has just promised to retry for three days. Expect duplicates. Every timeout where the receiver finished but the response never arrived produces one.

The check that looks right is select-then-insert, and it has a gap between the two in which two deliveries of the same event both find nothing. Close it with the database instead, in the same transaction as the work:

final class ProcessBillingWebhook implements ShouldQueue
{
use Queueable;
public function __construct(
public ReceivedWebhook $webhook,
) {}
public function handle(): void
{
DB::transaction(function (): void {
$claimed = DB::table('processed_webhooks')->insertOrIgnore([
'webhook_id' => $this->webhook->webhook_id,
'processed_at' => now(),
]);
if ($claimed === 0) {
return;
}
$event = json_decode($this->webhook->payload, true, flags: JSON_THROW_ON_ERROR);
match ($event['type']) {
'invoice.paid' => $this->invoicePaid($event),
default => null,
};
});
}
}

insertOrIgnore() against a unique index on webhook_id returns 0 when the row already exists. When two deliveries race, the second insert waits for the first transaction to commit and then gets its 0. The claim and the work commit together. A crash halfway through rolls back both, so the retry gets a clean run instead of a claim for work that never happened.

That holds while the work is database writes. If it calls out to something else, you are back to the dual write problem, and the idempotent receiver goes through what changes.

Deliveries also arrive out of order, because retries reorder them by design. The payload’s timestamp is when the event happened, not when it was sent. If you store it next to whatever it updates, a subscription.updated older than the one you already applied can be dropped instead of undoing the newer one.

The test that makes both ends agree

When the same app sends and receives, or you own both services, this is the test I would write first. It puts what the sender sends through what the receiver checks:

it('signs deliveries the receiver accepts', function () {
Queue::fake();
Http::fake(['*' => Http::response(status: 204)]);
$endpoint = WebhookEndpoint::factory()->create([
'secrets' => [config('services.billing.webhook_secret')],
]);
$event = WebhookEvent::record('invoice.paid', ['id' => 'inv_8842']);
(new DeliverWebhook($endpoint, $event))->handle();
[$sent] = Http::recorded()->first();
$this->call('POST', '/webhooks/billing', server: [
'HTTP_WEBHOOK_ID' => $sent->header('webhook-id')[0],
'HTTP_WEBHOOK_TIMESTAMP' => $sent->header('webhook-timestamp')[0],
'HTTP_WEBHOOK_SIGNATURE' => $sent->header('webhook-signature')[0],
'CONTENT_TYPE' => 'application/json',
], content: $sent->body())->assertAccepted();
});

Whatever the sender produced goes through the receiver’s middleware unchanged, headers and bytes. It fails the day someone changes the signing string on one side and not the other, or adds a pretty-print flag to the encoder, or “tidies” the receiver into reading $request->all(). Each of those passes every test that only looks at one end.

Where the packages fit

Spatie’s laravel-webhook-server and laravel-webhook-client are what most Laravel apps reach for, and they agree with each other out of the box. Know their defaults before you send to anyone else, though. The server makes three attempts, with a three second timeout, waiting ten seconds and then a hundred: the two minutes from the opening. The signature covers the JSON body and nothing else. There is an optional timestamp header, but it is not signed, so it does not stop a replay. Between two services you own, that is a fine trade. Sending to customers’ endpoints, it is not, and changing it means replacing the signer and the job, at which point the package is not doing much.

What it costs

A delivery job can now sit in your queue for three days. An outage at a large customer leaves thousands of them waiting at once. Give webhook delivery its own queue, so a backlog there never delays the jobs your own users are waiting on.

You also keep every event’s payload for at least as long as you retry it. That is a table that only grows, so decide when rows get pruned before it is the biggest table you have.

What to do on Monday

Find where your app sends webhooks, and work out what its retry schedule adds up to. If the last attempt happens less than an hour after the first, a customer’s ordinary deploy is already losing them events.

Then find the receiving end of something you integrate with and look for the line that checks whether this event has been processed before. If it is a SELECT followed by an INSERT, or there is no such line, that is the next thing to fix.

Share

XLinkedIn

Related

Keep Reading

All posts →