How to Receive Shopify Webhooks in Laravel

Exclude webhook routes from CSRF + use getContent for raw bytes

Laravel is the dominant PHP framework for SaaS development in 2026. Out of the box, the VerifyCsrfToken middleware blocks any POST without a token — including webhooks. The fix is to exclude the webhook URI from CSRF and read $request->getContent() for the raw bytes that HMAC verification needs. This guide walks through the Laravel setup for Shopify webhooks end to end: capturing the raw body, verifying the signature, handling retries idempotently, and iterating locally without redeploying. Cross-reference the Shopify Webhooks overview for the event catalog and sample payload.

Shopify Official Webhook Docs

1. Set Up the Laravel Endpoint

The endpoint needs to do three things, in this order: read the raw body, verify the signature against those exact bytes, and only then parse the JSON for your business logic.

// app/Http/Middleware/VerifyCsrfToken.php — exclude webhook URIs
class VerifyCsrfToken extends Middleware
{
    protected $except = ['webhooks/*'];
}

// routes/web.php (or routes/api.php)
Route::post('/webhooks/{service}', [WebhookController::class, 'receive']);

// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    public function receive(Request $request, string $service)
    {
        // IMPORTANT: getContent() returns the raw body BEFORE parsing.
        $rawBody = $request->getContent();
        $signature = $request->header('X-Signature-Header');

        // 1. Verify HMAC over $rawBody
        // 2. Parse JSON only after verification passes
        // 3. Process the event idempotently (use the event id as your key)

        $event = json_decode($rawBody, true);
        logger()->info('Verified webhook', ['type' => $event['type'] ?? null]);

        return response('ok', 200);
    }
}
Raw body, every time
Don't reach for $request->input() or $request->all() on a webhook route — by the time you do, Laravel has parsed the JSON and the original byte sequence is gone. Use $request->getContent() to get the unparsed body string, verify HMAC against that, then json_decode for application use. Form-encoded webhooks (Twilio, Mailgun) need $request->all() for the parsed params, but $request->getContent() still has the raw bytes if you need them.

2. Verify the Shopify Signature

Signing details
Algorithm
HMAC-SHA256 (base64)
Header
X-Shopify-Hmac-Sha256
Encoding
base64

PHP verification

class WebhookController extends Controller
{
    public function shopify(Request $request)
    {
        $payload = $request->getContent();
        $hmacHeader = $request->header('X-Shopify-Hmac-Sha256', '');
        $calculated = base64_encode(
            hash_hmac('sha256', $payload, env('SHOPIFY_API_SECRET'), true)
        );
        if (!hash_equals($calculated, $hmacHeader)) {
            abort(401);
        }
        $event = json_decode($payload, true);
        return response()->json([
            'ok' => true,
            'topic' => $request->header('X-Shopify-Topic'),
            'id' => $event['id'] ?? null,
        ]);
    }
}

Wire this verification call into the Laravel handler from section 1. The pattern is identical across Laravel versions: read raw body, verify, parse JSON, dispatch.

Watch out: Shopify uses base64 encoding (most other vendors use hex). A common bug is to copy a Stripe-style verifier and forget to switch from `digest('hex')` to `digest('base64')`.

See Shopify's official signing docs for the canonical reference, or the cross-service signature verification guide for the same pattern in Ruby and other languages.

3. Make the Handler Idempotent

Shopify can — and will — send the same event twice. Network blips, your server returning a 5xx mid-processing, deploy windows: any of these triggers a retry, and your handler will see the same event id again. Build for that on day one rather than chasing duplicate-charge bugs in production.

The simplest pattern is a unique constraint on the event id in your database. The handler does the work inside a transaction, and the insert into the events table is the last step — if a retry arrives, the unique-constraint violation tells you the event already committed and you can return 200 without re-running the side effects.

Pattern in any framework:

  1. Read raw body, verify signature.
  2. Begin transaction.
  3. Apply business logic (charge, fulfil, notify, etc.).
  4. Insert event id into processed_events with a unique constraint.
  5. Commit. Return 200.
  6. On unique-constraint violation, return 200 — the event was already processed by a prior delivery.

4. Shopify Retry Behaviour

Retry policy
Max attempts
19
Total window
Up to 48 hours
Backoff
Exponential, ~hours apart between later attempts
Retries on
Non-2xx responses, timeouts
Stops on
Any 2xx response. After all retries fail, the webhook subscription is automatically removed.

Combine the retry numbers above with the idempotency pattern in section 3: aim to acknowledge fast (return 200 under the timeout) and let the idempotency table absorb any duplicates from in-flight retries. The full pattern, including dead-letter queues and replay-from-capture, lives in the Webhook Retry Strategies guide.

5. Test Locally Without Deploying

The fastest iteration loop for any webhook handler is: capture a real Shopify event with HookRay, then replay that captured request against your local Laravel server until the verification + business logic both pass. No need to retrigger the event in Shopify, no need to redeploy.

  1. Get a free webhook URL at hookray.com — no signup.
  2. Paste the URL into your Shopifydashboard's webhook settings.
  3. Trigger a test event. HookRay shows the headers, raw body, and parsed payload in real time.
  4. Use HookRay's replay feature to send the captured request against http://localhost:3000/api/webhooks/shopify (or wherever your Laravel app is listening) — iterate on your code without re-poking the Shopify dashboard.

Deploying the Laravel Handler

Laravel runs comfortably on Forge/Vapor, traditional VPS (Render, DigitalOcean App Platform), and Docker containers. Octane (Swoole / RoadRunner) keeps boot warm for webhook traffic — at scale, an Octane worker handles incoming webhooks several times faster than per-request PHP-FPM. For low volume, classic LAMP / PHP-FPM is fine.

Need a host that boots quickly enough to absorb webhook bursts? DigitalOcean droplets stay warm, support raw-body proxies cleanly, and avoid the cold-start traps of some serverless runtimes.

Capture a real Shopify webhook in 30 seconds

Free webhook URL, real-time payload inspection, one-click replay. No signup required.

Start Testing — Free