DEV Community

Henry Hang
Henry Hang

Posted on • Originally published at hookcap.dev

How to Test Shopify Webhooks with HookCap

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
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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, and X-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 out if 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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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/..."
}
Enter fullscreen mode Exit fullscreen mode

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 });
});
Enter fullscreen mode Exit fullscreen mode

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 });
  }
);
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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');
  }
}
Enter fullscreen mode Exit fullscreen mode

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/create against 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/create twice 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 });
Enter fullscreen mode Exit fullscreen mode

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

  1. Create a HookCap endpoint and register it for your topics in Shopify Admin or via the Admin API
  2. Trigger events using the Shopify test notification button, or by creating test orders/products in your development store
  3. Inspect payloads in HookCap — understand the exact structure before writing your handler
  4. Write your handlers against the actual payload shapes you observed, not Shopify documentation (which sometimes lags behind the API)
  5. Replay events to your local server as you iterate on your handler logic
  6. Test idempotency by replaying the same event multiple times
  7. 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)