Zubair Akhtar logo — Muhammad Zubair Akhtar, Software Engineer

Blog

Webhook API Explained: How Webhooks Work in Laravel

Webhook API explained: push versus polling, HMAC signatures, a fast 2xx, and Laravel 13 queues for idempotent third party API integration in production.

Muhammad Zubair Akhtar

A webhook API is an HTTPS URL you give another system so it can POST an event when something happens. I am Zubair Akhtar, a senior software engineer in Lahore, Pakistan. Polling asks on a timer. A webhook is that system telling you, with a body and a signature, on its own retry schedule. This page is the Laravel 13 receiver: the route, the HMAC, the queue, and the duplicate. A JSON API you call yourself is REST API development with Laravel. Cashier's Stripe events are Stripe payment integration in Laravel. HubSpot's signed events are the HubSpot CRM integration guide. Card go-live checks are the payment gateway integration checklist.

If you want the receiver built or reviewed, start at Laravel development.

Webhook delivery: the provider POSTs an event, the Laravel endpoint verifies the signature and returns 2xx, a queue holds the job, and the job does the slow work. A non-2xx sends the provider around again.

Figure 1. Store the event id, return 2xx, and let the job do the slow work.

I read the cited pages on 27 October 2026. I have not run these snippets.

Webhook API: push instead of polling

You publish the URL. The provider chooses the moment, the JSON, and the retry. There is no CSRF token, so the signature is the check.

What the request contains

The body is the event. Headers name it, identify the attempt, and sign specific bytes. Stripe's webhook guide verifies the raw body and says any change to those bytes fails verification. GitHub's validation guide puts an HMAC hex digest in X-Hub-Signature-256, always prefixed sha256=, over the secret and the payload. Read that payload as UTF-8 when your stack has an encoding. Stripe's header is Stripe-Signature, and the signed string includes a timestamp. Use the header that provider documents.

Stripe says you can retrieve the object when events arrive out of order. That GET stays part of the API integration.

At-least-once, retries, and order

Stripe and GitHub do not share a retry policy. These cells are from their pages on 27 October 2026.

StripeGitHub
AgainThe same event can arrive more than once. Log event ids.X-GitHub-Delivery identifies it. A redelivery you request keeps that header.
Auto retryLive: up to three days, exponential backoff. Sandbox: three tries over a few hours.No automatic redelivery.
Deadline2xx before slow work.2xx within 10 seconds, or the delivery fails.
OrderNot guaranteed. Do not sort on created.Can take minutes. A surge can be throttled.
SignatureStripe-Signature, HMAC-SHA256 of timestamp.payloadX-Hub-Signature-256, prefix sha256=

Stripe says a manual resend does not cancel automatic retries. Two Event objects for one happening are split by the id in data.object plus event.type. created is seconds, so two events can share it. A repeat after 2xx is at-least-once delivery, and the stored id is how you ignore it. GitHub does not promise that second automatic POST. Redeliver still has to be safe.

Receive the webhook in Laravel 13

The version this follows

The Laravel 13 release notes date the release to 17 March 2026. Bug fixes run through Q3 2027. Security fixes run through 17 March 2028. PHP 8.3 is the minimum. The table lists PHP 8.3 through 8.5. On 27 October 2026, Packagist listed laravel/framework v13.38.0, published 20 October 2026. The snippets follow the 13.x docs.

A route outside CSRF

The CSRF page puts PreventRequestForgery in the web group. It checks the browser Sec-Fetch-Site header, then the session token. A provider sends neither. The page's own example is Stripe: exclude the URI, because Stripe will not send a CSRF token. It also says these routes usually sit outside the web group on routes/web.php.

routes/api.php is already outside that group. Cashier's URL is the documented stripe/* case, which the Stripe article follows. A route that must live in routes/web.php excludes one path:

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

A browser form on that prefix would skip the token too. Origin-only mode on the same method returns 403 when the origin check fails, and a server POST fails it. That switch does not admit the webhook.

$request->all() is the parsed array. Encoding it again changes order and escaping, so the HMAC misses. Read the raw body.

Route::post('/webhooks/github', function (Request $request) {
    $raw = $request->getContent();
    // Verify $raw against the signature header, then decode.
});

Check the HMAC before the JSON

GitHub says to compare your HMAC with X-Hub-Signature-256 using a constant-time function, not ==. PHP's hash_equals does that. The manual says the timing does not depend on a match, the user-supplied string is the second argument, and different lengths return false immediately. Compare only when the lengths match.

$secret = config('services.github.webhook_secret');
if (! is_string($secret) || $secret === '') {
    abort(401);
}

$expected = 'sha256='.hash_hmac('sha256', $raw, $secret);
$given = (string) $request->header('X-Hub-Signature-256', '');

if (strlen($given) !== strlen($expected) || ! hash_equals($expected, $given)) {
    abort(401);
}

GitHub wants a high-entropy secret the server can read, not hardcoded and not placed in the payload URL. Their vector, secret It's a Secret to Everybody and payload Hello, World!, is sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17.

Stripe's manual steps sign a different string. Split Stripe-Signature on commas, then on =. Keep t and every v1. Ignore other schemes, including the test-only v0. The signed payload is the timestamp, a dot, and the raw JSON:

$signed = $timestamp.'.'.$raw;
$expected = hash_hmac('sha256', $signed, $endpointSecret);

hash_equals each v1 after a length check. One match is enough. A secret roll can send one v1 per active secret for up to 24 hours. The timestamp is inside the signed string, so editing it breaks the HMAC. After a match, reject a timestamp outside the window. Stripe's libraries default to 5 minutes, and a tolerance of 0 disables that check. Use NTP. Each retry mints a new timestamp and signature. The guide recommends the official libraries for this.

Five signature checks: raw body, HMAC-SHA256, hash_equals, a timestamp window, then either store the event id and return 2xx or return 401.

Figure 2. The timestamp window is Stripe's replay check. GitHub uses X-GitHub-Delivery for a repeated delivery.

A third party API integration that skips the compare acts on any JSON that arrives. The signature shows this body was signed with the secret. It does not show the event id is new.

Return 2xx, then queue the job

Stripe says return 2xx before slow work, such as marking an invoice paid elsewhere. GitHub's clock is 10 seconds, then a queue. Verify, insert the id, dispatch, return.

ProcessGithubDelivery::dispatch($deliveryId);

return response()->noContent();

204 is a 2xx. The queues page has a synchronous driver that runs the job immediately, for local use. On a public URL, use database, Redis, or SQS. dispatchSync() also runs in the request. If the insert and the dispatch share a transaction, after_commit waits for the commit so the worker can see the row.

Deduplicate on the event id

Stripe says log processed event ids, and skip created as that log. I store X-GitHub-Delivery for GitHub, and the Stripe event id for Stripe, plus data.object id and event.type when two events describe one change.

A unique index on provider and event id, from the migrations page, is what makes the second insert fail. firstOrCreate without it can lose a race.

$table->string('provider');
$table->string('event_id');
$table->timestamp('received_at');
$table->unique(['provider', 'event_id']);

Insert in the request, before the 2xx. On a duplicate, return 204 and skip dispatch. The job should still be safe twice. The queues page says a job can run twice when --timeout exceeds retry_after. The documented Redis example uses 90 seconds. The default timeout there is 60. Keep the timeout shorter.

Job retries, backoff, and failed jobs

Provider retries and job retries do not share a counter. The queues page attempts a job once unless you raise it. An exception, $this->release(), or a timeout counts as an attempt. php artisan queue:work --tries=3 is the worker default. #[Tries(5)] on the class wins. Failures release immediately unless you wait: --backoff=3 waits 3 seconds, and #[Backoff([1, 5, 10])] waits 1, then 5, then 10.

#[Tries(5)]
#[Backoff([1, 5, 10])]
class ProcessGithubDelivery implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $deliveryId)
    {
    }

    public function handle(): void
    {
        // Read the stored body and apply it.
    }
}

Past the last attempt, an async job lands in failed_jobs. php artisan queue:failed lists it. php artisan queue:retry {id} runs that row again. The failed method is the documented place to record it. A bad signature stops at 401 in the request.

Log the id, not the secret

The logging page uses the Log facade. I log provider, event id, status, and whether a job was dispatched, and I leave out the secret, the signature, and any card or token. Stripe's delivery list and GitHub's recent deliveries show the provider's view. failed_jobs shows the worker.

Local delivery, then sending your own

The Stripe CLI, and a tunnel

Stripe's guide lets you test before a public URL: stripe login, then stripe listen --forward-to localhost:8000/webhooks/stripe. It prints the signing secret for that listener. A live endpoint secret will not match. stripe trigger payment_intent.succeeded sends a fixture. stripe events resend repeats an event up to 30 days old. --forward-to runs in a terminal. I have not run these commands for this article.

GitHub has no matching automatic retry, so a tunnel is how a repository webhook reaches a laptop. Redeliver from the delivery log while it is up. The public URL should be HTTPS, with SSL verification left on.

Sending with spatie/laravel-webhook-server

Sending signs the bytes you transmit, retries a non-2xx, and keeps one id across attempts. I read the README on the main branch of spatie/laravel-webhook-server on 27 October 2026. It requires PHP 8.4 and Laravel 13, and points older apps at v3. Laravel 13 itself allows PHP 8.3, so this sender is stricter. Use a real queue, not sync, outside local environments. The call it shows:

WebhookCall::create()
    ->url('https://other-app.com/webhooks')
    ->payload(['key' => 'value'])
    ->useSecret('sign-using-this-secret')
    ->dispatch();

JSON is encoded once with json_encode($payload, JSON_THROW_ON_ERROR) before dispatch. The signature is hash_hmac('sha256', $body, $secret) in Signature, and every attempt sends that same body. A status outside 2xx, or silence past the default 3 seconds, fails. The README then waits 10 seconds, then 100. tries defaults to 3. ExponentialBackoffStrategy goes 10, 100, 1000, up to 100000 seconds, about 27 hours in its words.

Webhook-Id is a UUID stable across those attempts. The default signature covers the body only, not that id and not Timestamp. useTimestamp() adds the header and does not put the time inside the HMAC. Replay protection, the README says, means signing a timestamp in the payload and checking its age on receipt. dispatchSync() runs inline, and synchronous calls do not wait or loop. Receiving is a different package, spatie/laravel-webhook-client, which this article does not describe.

On a payment, the browser return is a receipt and the signed webhook is the proof. That split is the checklist. The route, the unique index, and the worker are Laravel development. I have not run this code.

Discuss this with Muhammad

Message Muhammad Zubair Akhtar on WhatsApp or email [email protected].