Shopify webhooks
Capture Shopify webhooks and see why the 5-second timeout keeps failing them.
Get a test URL
claude mcp add --transport http webhook-studio https://webhook-studio.com/mcp \
--header "Authorization: Bearer $(curl -s -X POST https://webhook-studio.com/api/start | jq -r .api_token)"Point Shopify at this URL
- Create a test endpoint below and copy its URL.
- For an app: set the URL in your Partners dashboard under your app → Configuration → Webhooks (or subscribe via the Admin API).
- For a single store: Shopify admin → Settings → Notifications → Webhooks → Create webhook.
- Choose the topic (for example orders/create) and paste the Webhook Studio URL.
- Trigger it — place a test order, or use the "Send test notification" button — and watch it land in the Inbox.
What a Shopify delivery looks like
A representative body, using Shopify’s field names. Your own deliveries appear in the Inbox exactly as sent, headers and raw bytes included.
{
"id": 5123456789012,
"admin_graphql_api_id": "gid://shopify/Order/5123456789012",
"name": "#1001",
"order_number": 1001,
"created_at": "2026-07-21T09:14:02+08:00",
"currency": "USD",
"total_price": "48.00",
"subtotal_price": "44.00",
"total_tax": "4.00",
"financial_status": "paid",
"fulfillment_status": null,
"test": true,
"customer": {
"id": 6234567890123,
"email": "customer@example.com",
"first_name": "Alex",
"last_name": "Rivera"
},
"line_items": [
{
"id": 14234567890123,
"title": "Field Notes — 3 pack",
"quantity": 2,
"price": "22.00",
"sku": "FN-3PK",
"product_id": 8234567890123,
"variant_id": 44234567890123
}
],
"shipping_address": {
"city": "Portland",
"province": "Oregon",
"country": "United States",
"zip": "97205"
}
}Delivery headers
| Header | What it carries |
|---|---|
X-Shopify-Hmac-SHA256 | Base64-encoded HMAC-SHA256 of the raw body, keyed on your app's client secret. Note there is no "sha256=" prefix — unlike GitHub, the header is the bare digest. |
X-Shopify-Topic | The event name, e.g. orders/create. Shopify puts the topic in a header, not the body. |
X-Shopify-Shop-Domain | The shop this delivery belongs to, e.g. example.myshopify.com. Essential once one app serves many shops. |
X-Shopify-Webhook-Id | Stable id for this delivery. The correct idempotency key — it is unchanged across retries of the same event. |
X-Shopify-Event-Id | Correlates separate deliveries produced by one merchant action. |
X-Shopify-API-Version | The API version the payload was serialised with, e.g. 2025-07. |
X-Shopify-Triggered-At | When the event was triggered in Shopify, distinct from when delivery was attempted. |
Event types
The events worth handling first. See Shopify’s own documentation for the complete list.
| Event | Fires when |
|---|---|
orders/create | An order is placed. The one most integrations start with. |
orders/paid | An order is marked paid. Fulfil on this rather than orders/create if payment can be pending. |
orders/cancelled | An order is cancelled, whether by staff or automatically. |
orders/fulfilled | Every line item on an order has been fulfilled. |
products/create | A product is added to the catalogue. |
products/update | A product changes. Fires often — inventory edits count — so filter before acting. |
customers/create | A customer record is created. |
app/uninstalled | Your app is removed from a shop. Handle this one: it is how you learn to stop billing and purge that shop's data. |
checkouts/create | A checkout begins. Used for abandoned-cart flows. |
Verify the X-Shopify-Hmac-SHA256 header
Shopify signs each delivery with HMAC-SHA256 over the raw request body, keyed on your webhook secret. Verify before you parse, and before you act.
import express from 'express';
import crypto from 'crypto';
const app = express();
// express.raw() matters: the HMAC covers the exact bytes Shopify sent. A
// global express.json() upstream would reparse them and every check fails.
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), (req, res) => {
const sent = req.get('X-Shopify-Hmac-SHA256') ?? '';
const digest = crypto
.createHmac('sha256', process.env.SHOPIFY_API_SECRET) // app client secret
.update(req.body) // Buffer, not a string
.digest('base64'); // base64, NOT hex
const a = Buffer.from(digest, 'base64');
const b = Buffer.from(sent, 'base64');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401);
}
// Acknowledge inside 5 seconds or Shopify calls it a failure and retries.
// Queue the work; never do it before responding.
res.sendStatus(200);
const topic = req.get('X-Shopify-Topic'); // 'orders/create'
const deliveryId = req.get('X-Shopify-Webhook-Id'); // dedupe on this
// enqueue({ topic, deliveryId, body: JSON.parse(req.body) })
});Retries and delivery guarantees
Shopify allows a one-second connection timeout and five seconds for the whole request. Anything outside the 200 range — including 3xx redirects — is a failure. Failed deliveries are retried 8 times over roughly 4 hours, and if every attempt fails, a subscription created through the Admin API is deleted automatically and warning emails go to the store's emergency contacts. Losing the subscription entirely, not just the event, is what makes Shopify's timeout stricter in practice than most.
Common pitfalls
GitHub sends sha256=<hex>; Shopify sends bare base64 with no prefix at all. Verifiers written against the GitHub shape, or ported from it, compare a base64 string to a hex one and fail every delivery. Compare decoded bytes, not strings.
The budget is five seconds for the entire request. Any real fulfilment logic — an API call, a slow query — will blow through it under load. Shopify then retries, and after enough failures deletes the subscription, so a slow handler silently turns into no webhooks at all.
Retries reuse X-Shopify-Webhook-Id, so that is the idempotency key. X-Shopify-Event-Id groups the several deliveries one merchant action produces, which is a different thing — keying on it collapses events you wanted to keep.
products/update fires on every inventory change and Shopify makes no ordering guarantee, so two rapid edits can arrive reversed. Read current state from the Admin API when the value matters rather than reconstructing it from the stream.
FAQ
Create a test endpoint on this page and paste its URL into your app configuration or the store's Notifications settings. The full request — every X-Shopify header, the raw body and the HMAC — appears in the Inbox immediately, with no code deployed and no account required.
Two causes account for almost all of it. The header is bare base64 with no "sha256=" prefix, so code ported from GitHub compares mismatched encodings. And the digest covers the raw bytes, so any JSON body-parser mounted ahead of the route invalidates every signature.
Shopify allows five seconds for the whole request. Slower is a failure, retried 8 times over about 4 hours; if all attempts fail, an Admin API subscription is deleted outright. Return 200 first and queue the work — and while you are debugging, point Shopify here so the payloads are captured even when your own server times out.
X-Shopify-Webhook-Id. It stays the same across retries of a delivery, which is exactly what an idempotency key needs. X-Shopify-Event-Id is for correlating the different deliveries a single merchant action produced.
Yes. Add a routing rule on your endpoint pointing at your local tunnel and matching events are forwarded with retries while still being stored here — so you keep the full history even for the deliveries your local server was too slow to answer.