This is the single most important pattern I apply to every webhook integration I build. It’s also the one most commonly skipped, even by experienced developers. If you only take one thing from this post: respond HTTP 200 immediately after HMAC verification, then queue the actual work.
Why this matters
Shopify retries failed webhooks. If your endpoint takes more than 5 seconds to respond, Shopify treats it as failed and schedules a retry. If your processing involves fetching from another store, computing FX rates, pushing to a third-party API, you will hit that 5-second wall on complex products with many variants.
Worse: when the retries arrive, your endpoint is still processing the original request. Now you’re processing the same event twice. If your handler isn’t idempotent ? and most aren’t ? you’ve created a duplicate state problem.
The pattern
Three steps, in this exact order:
# 1. Verify HMAC. Reject 401 immediately if invalid.
verify_hmac($payload, $secret)
# 2. Write the event to a queue (MySQL row, Redis list, file - any persistent store).
enqueue($event_id, $payload)
# 3. Respond HTTP 200. Shopify is now happy.
respond(200)
# 4. Separately: a cron worker drains the queue and does the actual work.
What this gives you
- Sub-second response time on the webhook endpoint, regardless of processing complexity
- No retry storms when downstream systems are slow or rate-limited
- A natural audit trail of every webhook event received
- The ability to replay events if the worker has a bug ? fix the worker, drain the queue
The trap
The temptation is to do “just one quick thing” inline before responding 200. Maybe just update one field. Maybe just send one email. Don’t. Every inline operation is a potential 5-second timeout. Queue everything. Process everything async. Respond fast.
This pattern is why I’ve run the AAO StarshipIt integration for 5+ years across 150+ daily orders without a single retry storm or duplicate-processing incident.