How to Test Shopify Webhooks with HookCap
Shopify webhooks power the integrations that make your store run: order fulfillment triggers, inventory sync, customer data pipelines, ERP and 3PL connections. When your webhook handlers fail silently, orders slip through, inventory counts drift, and customers don't get notifications.
The development problem is the same as every other webhook integration: Shopify needs a public HTTPS URL, but your handler is running on localhost. You need a persistent capture endpoint where you can inspect every delivery, examine payload structure, and replay events without re-creating orders in Shopify.
This guide covers using HookCap as your Shopify webhook testing tool.
Common Shopify Webhook Topics
Shopify organizes webhooks by topic — a resource type plus an action. Here are the topics you will deal with most:
| Topic | When it fires |
|---|---|
orders/create |
A new order is placed |
orders/paid |
An order's payment is captured |
orders/fulfilled |
All line items in an order are fulfilled |
orders/cancelled |
An order is cancelled |
orders/updated |
Any order field changes |
products/create |
A new product is created |
products/update |
A product or variant is updated |
products/delete |
A product is deleted |
customers/create |
A new customer account is created |
customers/update |
A customer record changes |
inventory_levels/update |
Inventory quantity changes at a location |
fulfillments/create |
A fulfillment record is created |
checkouts/create |
A checkout is initiated |
checkouts/update |
A checkout is modified (address, items, etc.) |
app/uninstalled |
Your app is removed from the store |
For most integrations, orders/create, orders/paid, and products/update cover 80% of the work.
Setting Up HookCap for Shopify Webhooks
1. Create a HookCap endpoint
Sign in at hookcap.dev and create a new endpoint. You get a permanent HTTPS URL like:
https://hook.hookcap.dev/ep_a1b2c3d4e5f6
This URL doesn't expire between sessions. Register it once in Shopify and it keeps receiving events.
2. Register the webhook in Shopify
There are two ways to register webhooks: through the Shopify Admin UI or via the Admin API.
Option A: Shopify Admin (simplest for testing)
Go to Settings → Notifications → Webhooks and click Create webhook. Select your topic, choose JSON format, and paste your HookCap endpoint URL.
This works for stores you own or have staff access to. For app development, you want the API approach.
Option B: Admin API (required for apps)
Using the REST Admin API:
curl -X POST \
"https://your-store.myshopify.com/admin/api/2024-01/webhooks.json" \
-H "X-Shopify-Access-Token: YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook": {
"topic": "orders/create",
"address": "https://hook.hookcap.dev/ep_a1b2c3d4e5f6",
"format": "json"
}
}'
Using the GraphQL Admin API:
mutation {
webhookSubscriptionCreate(
topic: ORDERS_CREATE,
webhookSubscription: {
callbackUrl: "https://hook.hookcap.dev/ep_a1b2c3d4e5f6",
format: JSON
}
) {
webhookSubscription {
id
}
userErrors {
field
message
}
}
}
Shopify will start delivering events to your HookCap endpoint immediately after registration.
3. Trigger your first event
Create a test order in your development store, or use Shopify's built-in webhook testing. In the Admin under Settings → Notifications → Webhooks, each registered webhook has a Send test notification button. This fires a sample payload to your endpoint without needing real order activity.
Within seconds, the delivery appears in your HookCap dashboard.
Inspecting Shopify Webhook Payloads
What HookCap shows for each delivery
Every captured Shopify webhook includes:
-
Headers — including
X-Shopify-Hmac-SHA256,X-Shopify-Topic,X-Shopify-Shop-Domain, andX-Shopify-Webhook-Id - Raw body — the complete JSON payload exactly as Shopify sent it
- Delivery timestamp and response latency
-
Status code your endpoint returned (or
timed outif no response)
Real payload examples
orders/create payload (abbreviated):
{
"id": 820982911946154500,
"email": "jon@example.com",
"created_at": "2024-01-01T00:00:00-05:00",
"updated_at": "2024-01-01T00:00:00-05:00",
"number": 234,
"total_price": "409.94",
"subtotal_price": "398.00",
"total_tax": "11.94",
"currency": "USD",
"financial_status": "paid",
"fulfillment_status": null,
"line_items": [
{
"id": 866550311766439000,
"title": "Short Sleeve T-Shirt",
"quantity": 1,
"price": "199.00",
"sku": "SKU-123",
"variant_id": 808950810,
"product_id": 632910392
}
],
"shipping_address": {
"first_name": "Jon",
"last_name": "Snow",
"address1": "123 Main St",
"city": "Ottawa",
"country": "Canada",
"zip": "K2P0V6"
},
"customer": {
"id": 207119551,
"email": "jon@example.com",
"first_name": "Jon",
"last_name": "Snow"
}
}
products/update payload (abbreviated):
{
"id": 632910392,
"title": "IPod Nano - 8GB",
"vendor": "Apple",
"status": "active",
"variants": [
{
"id": 808950810,
"price": "199.00",
"sku": "IPOD2008PINK",
"inventory_quantity": 10,
"inventory_management": "shopify"
}
],
"updated_at": "2024-01-01T00:00:00-05:00"
}
inventory_levels/update payload:
{
"inventory_item_id": 271878346596884000,
"location_id": 905684977,
"available": 5,
"updated_at": "2024-01-01T00:00:00-05:00",
"admin_graphql_api_id": "gid://shopify/InventoryLevel/..."
}
Note that inventory_levels/update gives you available quantity at a specific location, not total across all locations. If you have multi-location inventory, you need to handle each location separately.
The X-Shopify-Topic header
Every Shopify webhook delivery includes X-Shopify-Topic in the headers. If you register a single HookCap endpoint for multiple topics, use this header to see which topic each delivery corresponds to — HookCap displays it alongside each captured event.
This is useful when building a single webhook endpoint that handles multiple topics:
app.post('/webhooks/shopify', (req, res) => {
const topic = req.headers['x-shopify-topic'];
switch (topic) {
case 'orders/create':
return handleOrderCreate(req.body);
case 'orders/paid':
return handleOrderPaid(req.body);
case 'products/update':
return handleProductUpdate(req.body);
default:
console.log(`Unhandled topic: ${topic}`);
}
res.status(200).json({ received: true });
});
Shopify HMAC Signature Verification
Every Shopify webhook delivery includes an X-Shopify-Hmac-SHA256 header. This is a Base64-encoded HMAC-SHA256 signature of the raw request body, computed using your app's shared secret (or your webhook secret for Admin-registered webhooks).
Your handler must verify this signature before processing the payload. Skipping verification means any party can send arbitrary JSON to your endpoint and trigger your business logic.
Verification in Node.js
const crypto = require('crypto');
function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
const computed = crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('base64');
// Use timingSafeEqual to prevent timing attacks
const computedBuffer = Buffer.from(computed);
const receivedBuffer = Buffer.from(hmacHeader);
if (computedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(computedBuffer, receivedBuffer);
}
app.post('/webhooks/shopify',
express.raw({ type: 'application/json' }), // raw body required
(req, res) => {
const hmac = req.headers['x-shopify-hmac-sha256'];
if (!verifyShopifyWebhook(req.body, hmac, process.env.SHOPIFY_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
const data = JSON.parse(req.body);
const topic = req.headers['x-shopify-topic'];
// handle event...
res.status(200).json({ received: true });
}
);
The raw body requirement
Like Stripe, Shopify computes the HMAC over the raw request body as bytes. If your framework parses the body to JSON before your handler runs, you cannot recompute the signature. The fix is consistent: mount the webhook route with a raw body parser, before any middleware that parses JSON globally.
In Express:
// Webhook route uses raw body parser
app.post('/webhooks/shopify', express.raw({ type: 'application/json' }), shopifyWebhookHandler);
// JSON parser for everything else
app.use(express.json());
Verification during replay
When you replay a Shopify webhook from HookCap to your local server, the original X-Shopify-Hmac-SHA256 header is included. Since the payload hasn't changed, the signature is still valid — as long as you use the same webhook secret locally.
For development, you can also skip verification conditionally:
if (process.env.NODE_ENV !== 'development') {
if (!verifyShopifyWebhook(req.body, hmac, process.env.SHOPIFY_WEBHOOK_SECRET)) {
return res.status(401).send('Unauthorized');
}
}
Replaying Shopify Webhooks
The replay workflow for Shopify is the same as other integrations: click any captured event in HookCap and replay it to any URL.
When replay is most useful with Shopify:
- You changed your order processing logic and need to re-run the same
orders/createagainst the updated handler - Your handler failed on a specific payload (edge case: an order with a discount, a product with no variants, an international shipping address) and you want to reproduce it
- You want to test idempotency — replay
orders/createtwice and verify your handler doesn't create duplicate records - You're testing a new webhook topic (
fulfillments/create) and want to send the test payload multiple times while iterating on your handler
One practical workflow: capture real payloads from your development store in HookCap, then use those as your test fixtures. Real Shopify payloads have edge cases you would not think to write yourself — variant IDs, metafields, fulfillment services — and replaying them against your handler is more reliable than hand-crafted JSON.
Common Shopify Webhook Gotchas
Webhook deduplication with X-Shopify-Webhook-Id
Shopify may deliver the same webhook more than once if your endpoint returns a non-2xx status or times out. Each delivery has a unique X-Shopify-Webhook-Id header. Store this ID and check for duplicates before processing:
const webhookId = req.headers['x-shopify-webhook-id'];
const alreadyProcessed = await db.webhooks.findOne({ shopifyWebhookId: webhookId });
if (alreadyProcessed) {
return res.status(200).json({ received: true, duplicate: true });
}
// Process and record
await processWebhook(data);
await db.webhooks.create({ shopifyWebhookId: webhookId, processedAt: new Date() });
res.status(200).json({ received: true });
app/uninstalled must respond quickly
Shopify gives you very little time to respond to app/uninstalled. If your cleanup logic (revoke tokens, delete data, notify users) is slow, the delivery times out and Shopify may retry. Respond with 200 immediately, then process cleanup asynchronously.
orders/updated fires on everything
The orders/updated topic fires on virtually any change to an order: status change, note added, tag added, fulfillment updated. If you subscribe to it, you will receive far more events than you expect. Check the specific fields that changed (financial_status, fulfillment_status, etc.) before triggering downstream logic, or subscribe to more specific topics like orders/paid and orders/fulfilled instead.
Product variants vs. products
products/update fires when a product changes, but the interesting data is often in the variants array. If you are syncing inventory or pricing, you need to iterate over variants in the payload rather than reading top-level product fields. Check variants[n].inventory_quantity and variants[n].price, not product.price (which doesn't exist at the product level).
Multi-location inventory
If your store uses multiple locations, inventory_levels/update fires once per location change, not once per product. A single cart checkout at a warehouse might trigger events for 3 locations. Make sure your handler doesn't assume one event = one product update.
Full Shopify Webhook Development Workflow
- Create a HookCap endpoint and register it for your topics in Shopify Admin or via the Admin API
- Trigger events using the Shopify test notification button, or by creating test orders/products in your development store
- Inspect payloads in HookCap — understand the exact structure before writing your handler
- Write your handlers against the actual payload shapes you observed, not Shopify documentation (which sometimes lags behind the API)
- Replay events to your local server as you iterate on your handler logic
- Test idempotency by replaying the same event multiple times
- Verify signature handling before shipping to production
HookCap's persistent event history means you can capture a complex orders/create payload once and replay it dozens of times during development — no need to keep creating and canceling test orders in Shopify.
Free tier available at hookcap.dev. Paid plans start at $12/month with webhook replay and longer history retention.
Top comments (0)