Author picture Phil Leggetter

How to Configure Shopify Webhooks and Fix Duplicate Webhook Subscriptions

Published


One of the most common issues developers face when building Shopify embedded apps is webhook configuration problems. These issues often manifest as webhooks not firing, duplicate webhook calls, or subscription failures. The root cause? Defining webhooks in two places at once.

This tutorial explains the common mistake of duplicating webhook definitions between shopify.app.toml and the shopifyApp() function, and shows you the correct way to configure webhooks using only the TOML file.

The Problem: Duplicate Webhook Definitions

Many developers starting with Shopify embedded apps naturally try to define webhooks in both configuration locations, thinking this ensures proper setup. Unfortunately, this causes conflicts and unpredictable behavior.

❌ Common Mistake - Don't Do This

Here's what not to do:

# shopify.app.toml
[[webhooks.subscriptions]]
topics = [ "orders/updated" ]
uri = "/webhooks"

AND ALSO in your app initialization:

// app.js or similar
const shopify = shopifyApp({
  webhooks: {
    webhooks: [
      {
        topic: 'orders/updated',
        deliveryMethod: DeliveryMethod.Http,
        callbackUrl: '/webhooks',
      },
    ],
  },
});

This creates duplicate webhook subscriptions that conflict with each other, leading to:

  • Webhooks not being delivered
  • Inconsistent behavior between environments
  • Difficult-to-debug subscription issues
  • Potential rate limit problems

The Solution: TOML-Only Configuration

The correct approach is to define your webhooks only once in the shopify.app.toml file.

✅ Correct Implementation

Step 1: Define webhooks in shopify.app.toml

# shopify.app.toml
[webhooks]
api_version = "2024-10"

  [[webhooks.subscriptions]]
  topics = [ "orders/updated" ]
  uri = "/webhooks"  # Can be relative or absolute URL

Step 2: Initialize shopifyApp() without webhook definitions

// app.js or similar
const shopify = shopifyApp({
  api: {
    apiVersion: LATEST_API_VERSION,
    restResources,
    billing: billingConfig,
  },
  // NO webhooks section when using TOML
});

Why This Works

When you define webhooks in shopify.app.toml, the Shopify CLI automatically manages webhook subscriptions during app installation and updates. The TOML configuration is the single source of truth, and Shopify's tooling handles the rest.

By removing the duplicate definition from shopifyApp(), you:

  • Eliminate configuration conflicts
  • Follow Shopify's recommended best practices
  • Simplify your codebase
  • Make webhook management more maintainable

Key Takeaways

  1. Choose one configuration method: Use shopify.app.toml for embedded apps
  2. Choose appropriate webhook URL format: Use relative paths for embedded apps receiving webhooks directly (e.g., /webhooks), or absolute URLs when using webhook infrastructure like Hookdeck
  3. Remove duplicate definitions: If you have webhooks defined in both places, remove them from shopifyApp()
  4. Test after changes: Always test your webhook configuration in development before deploying

Common Gotchas

Even with correct configuration, there are two common issues that can cause webhook failures. Understanding these upfront can save hours of debugging.

Protected Customer Data Requirements

If you're using the orders/updated webhook topic (or other webhooks that include customer data like orders/create), you may encounter webhook delivery failures even when your configuration is correct. This is because Shopify requires special permissions for protected customer data.

Why this happens:

  • Both orders/create and orders/updated webhooks require access to protected customer data
  • Shopify considers certain customer information (like email addresses and phone numbers) as protected data
  • Even if your app has the necessary scopes, you must explicitly request access to protected customer data
  • This is separate from your app's OAuth scopes and requires additional approval

How to fix it:

  1. Navigate to your app's settings in the Shopify Partner Dashboard
  2. Request access to protected customer data via the Protected Customer Data form
  3. Explain your use case for accessing this data
  4. Wait for Shopify's review and approval

Important: You can have read_orders scope but still lack access to protected customer data. If you're seeing webhook delivery failures or missing customer data in your webhook payloads, this is likely the cause.

Trailing Slash URL Issues

Shopify automatically adds a trailing slash (/) to webhook URLs, which can cause 404 errors if your server isn't configured to handle both formats. This is a subtle but common source of webhook failures.

The problem:

  • You configure the webhook URL as https://yourapp.com/webhooks
  • Shopify sends requests to https://yourapp.com/webhooks/ (with trailing slash)
  • Your Express.js route only matches /webhooks (without trailing slash)
  • Result: 404 error and failed webhook delivery

The solution: Configure your Express.js application to handle both URL formats:

// Handle both /webhooks and /webhooks/ to account for Shopify's trailing slash
app.post(['/webhooks', '/webhooks/'], express.raw({ type: 'application/json' }), async (req, res) => {
  try {
    const hmac = req.get('X-Shopify-Hmac-Sha256');
    const topic = req.get('X-Shopify-Topic');
    const shop = req.get('X-Shopify-Shop-Domain');
    
    // Verify the webhook is from Shopify
    const verified = verifyShopifyWebhook(req.body, hmac);
    
    if (!verified) {
      console.error('Webhook verification failed');
      return res.status(401).send('Unauthorized');
    }
    
    // Process the webhook
    console.log(`Received ${topic} webhook from ${shop}`);
    
    // Send success response immediately
    res.status(200).send('Webhook received');
    
    // Process webhook data asynchronously
    await processWebhook(topic, req.body);
    
  } catch (error) {
    console.error('Webhook processing error:', error);
    res.status(500).send('Internal server error');
  }
});

Key points:

  • Use an array ['/webhooks', '/webhooks/'] to match both URL formats
  • This ensures your app responds successfully regardless of which format Shopify uses
  • Always return a 200 status code quickly to acknowledge receipt
  • Process the webhook data asynchronously after responding

Conclusion

Following this guide will help you properly configure webhook delivery from Shopify to your embedded app. By understanding the proper TOML-only configuration approach and being aware of common gotchas like protected customer data requirements and trailing slash issues, you can avoid the most common webhook pitfalls and build a more robust integration.

Simplify Shopify Webhook Management with Hookdeck

Hookdeck provides complete webhook infrastructure for Shopify apps in both development and production. Get reliable delivery, automatic retries, powerful debugging tools, local testing with the CLI, and production-grade monitoring. Get started for free to simplify your Shopify webhook integration.


Author picture

Phil Leggetter

Head of Developer Relations

Phil is Head of Developer Relations at Hookdeck with expertise in real-time web technologies, co-author of 'Realtime Web Apps', and creator of the AAARRRP Developer Relations strategy framework.