Registering a test URL
The quickest route needs no app at all. In your store, open Settings → Notifications, scroll to Webhooks, and press Create webhook. Choose an event, paste the URL from above, pick JSON, and save. Shopify sends nothing on creation, so place a test order — the Bogus Gateway lets you do that on a development store without money moving.
Note the signing secret printed under the webhook list. Admin-created webhooks use that, not your app’s client secret. This is the single most common reason a verification function that works in production fails while you are testing by hand.
For an app, register the subscription in your shopify.app.toml or through the GraphQL Admin API:
mutation {
webhookSubscriptionCreate(
topic: ORDERS_CREATE
webhookSubscription: { callbackUrl: "https://trywebhook.com/w/your-bin-id", format: JSON }
) { userErrors { field message } }
}
Look at the sample above and notice how much lives outside the body:
X-Shopify-Topic: orders/create
X-Shopify-Shop-Domain: example-store.myshopify.com
X-Shopify-API-Version: 2025-07
X-Shopify-Webhook-Id: b1a2c3d4-e5f6-7890-abcd-ef1234567890
X-Shopify-Triggered-At: 2026-06-15T10:04:00.000Z
An orders/create body and an orders/updated body are the same JSON document. If you route on anything other than X-Shopify-Topic, you will treat an edit as a new order. And because an app receives webhooks from every store that installed it through one URL, X-Shopify-Shop-Domain is what tells you whose order this is.
X-Shopify-API-Version is worth logging. Shopify pins each subscription to the version current when it was created, and fields appear and disappear between versions. When a field the docs promise is missing, that header explains it.
Verifying the HMAC
X-Shopify-Hmac-Sha256: aGVsbG8gdGhlcmUsIHRoaXMgaXMgbm90IHJlYWw=
Base64, over the raw body, no timestamp and no prefix. In the Signature tab above, choose Shopify and paste the secret; it computes the digest over the exact bytes that arrived.
Because there is no timestamp inside the signature, a captured Shopify delivery remains valid indefinitely. Deduplicating on X-Shopify-Webhook-Id closes that, and you need it for retries anyway.
One structural point: verify before you parse. Shopify’s raw body uses no whitespace, but JSON.parse followed by JSON.stringify will reorder nothing and still change enough — number formatting, Unicode escapes — to break the digest on some payloads. Keep the bytes.
Money is a string
"total_price": "49.90",
"subtotal_price": "44.90",
"total_tax": "5.00"
Shopify sends decimal strings, not integers of minor units the way Stripe and Paddle do. Parsing them into a float and adding them up is how stores end up a cent out on a reconciliation report. Parse to a decimal type, or multiply to integer cents before you do arithmetic.
Also note id: 5555555555555 — a 64-bit integer. In JavaScript that is beyond Number.MAX_SAFE_INTEGER territory for some Shopify ids, and the admin_graphql_api_id string is the safer key.
Debugging the five-second timeout
If Shopify’s delivery log shows timeouts, the handler is doing work inline. The fix is structural rather than a matter of optimisation:
- Verify the HMAC.
- Write the raw body, the topic, and the webhook id to a queue or table.
- Return 200.
- Process from the queue, where nothing is waiting on you.
Copy a captured request as cURL and time your own endpoint against it. If it takes more than about two seconds locally, it will time out on a store under load.