Making Shopify webhooks idempotent (and why yours probably are not)
Shopify guarantees at-least-once delivery, which means duplicate and out-of-order webhooks are normal traffic rather than edge cases. Here is the handler structure that survives them.
Almost every Shopify app I have audited has the same latent bug: the webhook handler assumes each event arrives exactly once, in order. Shopify makes neither guarantee. It guarantees at-least-once delivery, retries on any non-2xx response or timeout, and gives no ordering promise between separate topics. On a quiet store you may never notice. On a busy one, duplicates turn into double-charged customers and orphaned records.
Verify the HMAC before anything else
The signature check has to happen against the raw request body. Any middleware that parses JSON before you compute the digest will change the bytes and the comparison will fail, so the raw body must be preserved. Use a timing-safe comparison, not string equality.
$digest = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
if (!hash_equals($digest, $request->header('X-Shopify-Hmac-Sha256', ''))) {
return response('', 401);
}Answer fast, process later
Shopify expects a response within five seconds and treats a timeout as a failure worth retrying. If you do the real work inline, a slow database or a third-party API call turns one event into a retry storm. Acknowledge immediately, then process from a queue.
- Verify the HMAC, persist the raw payload, return 200. Nothing else.
- Do the actual work in a queued job with its own retry and backoff policy.
- Send failures to a dead-letter queue you can inspect, not to a log file nobody reads.
Deduplicate on the event id
Every delivery carries an X-Shopify-Webhook-Id header that stays constant across retries of the same event. Store it with a unique constraint and let the database reject duplicates. This is more reliable than checking whether the work looks done, because it does not race with itself.
// Unique index on webhook_id makes the duplicate a no-op
try {
WebhookEvent::create([
'webhook_id' => $request->header('X-Shopify-Webhook-Id'),
'topic' => $request->header('X-Shopify-Topic'),
'shop' => $request->header('X-Shopify-Shop-Domain'),
'payload' => $rawBody,
]);
} catch (UniqueConstraintViolationException) {
return response('', 200); // already seen, nothing to do
}Handle out-of-order delivery with version checks
Deduplication stops the same event applying twice. It does not stop an older event overwriting a newer one, which happens when a retry of an early update lands after a later update succeeded. The fix is to compare the payload timestamp against what you already stored and drop anything stale.
Rule of thumb: dedupe on webhook id, order on updated_at. You need both. Either one alone leaves a real corruption path open.
Do not trust the payload as the source of truth
Webhook payloads are a notification that something changed, and they can be minutes stale by the time a retry succeeds. For anything financial, treat the webhook as a trigger and re-read the current state from the Admin API before acting on it. That one habit removes an entire category of reconciliation bug.
The mandatory compliance webhooks
Public apps must implement customers/data_request, customers/redact and shop/redact. App review checks that they exist and respond correctly, and this is one of the more common reasons a submission gets bounced. Build them at the start rather than in the week you plan to submit.