Home/Blog/Shopify Webhooks: Complete Guide with Payload Examples [2025]
Developer Tools

Shopify Webhooks: Complete Guide with Payload Examples [2025]

Complete guide to Shopify webhooks with setup instructions, payload examples, HMAC-SHA256 signature verification, and implementation code. Learn how to integrate Shopify webhooks into your e-commerce application with step-by-step tutorials for orders, products, customers, and inventory events.

By Inventive HQ Team
Shopify Webhooks: Complete Guide with Payload Examples [2025]

When a customer places an order on your Shopify store at 2 AM, you need to know immediately—not when your polling script runs again in 5 minutes. Shopify webhooks solve this problem by sending real-time HTTP notifications to your server the moment events occur, enabling you to automate order fulfillment, sync inventory across platforms, send personalized customer emails, update analytics dashboards, and trigger complex business workflows instantly.

Shopify webhooks are HTTP callbacks that Shopify sends to your application when specific events occur in a merchant's store. Instead of continuously polling the Shopify API to check for changes (which wastes resources and creates delays), webhooks push data to you in real-time.

Common use cases for Shopify webhooks:

  • Order automation - Process new orders, update fulfillment status, sync with shipping providers
  • Inventory management - Update stock levels across multiple sales channels in real-time
  • Customer engagement - Send personalized emails, SMS notifications, or trigger marketing campaigns
  • Analytics and reporting - Feed real-time data into business intelligence dashboards
  • Fraud detection - Analyze orders immediately for suspicious patterns
  • Multi-channel sync - Keep product information consistent across marketplaces

This comprehensive guide walks you through everything you need to know about Shopify webhooks: from initial setup and event types to signature verification, production-ready implementation, and troubleshooting common issues. We'll cover code examples in Node.js, Python, and PHP, and show you how to test webhooks using our Webhook Payload Generator tool.

What Are Shopify Webhooks?

Shopify webhooks are automated notifications sent from Shopify's servers to your application via HTTP POST requests whenever specific events occur in a merchant's store. Think of them as "reverse API calls"—instead of your app asking Shopify for updates, Shopify proactively tells your app when something important happens.

How Shopify Webhooks Work

Here's the basic architecture:

[Event in Shopify Store] → [Shopify Webhook System] → [Your Webhook Endpoint] → [Your Application Logic]

When a triggering event occurs (like a new order or product update), Shopify:

  1. Serializes the relevant data into JSON format
  2. Computes an HMAC-SHA256 signature using your app's client secret
  3. Sends an HTTP POST request to your configured endpoint URL
  4. Includes special headers for verification and metadata
  5. Waits up to 5 seconds for your response

Key Benefits of Shopify Webhooks

Real-time updates: Receive notifications within seconds of events occurring, enabling immediate business responses instead of waiting for periodic polling intervals.

Reduced API calls: Eliminate constant polling that wastes API rate limits. Shopify's API has rate limits of 2 requests per second for REST and 1000 points per 60 seconds for GraphQL—webhooks help you stay well under these limits.

Lower latency: Process critical events like high-value orders or inventory stockouts immediately instead of experiencing polling delays.

Resource efficiency: Your servers only process requests when actual events occur, reducing computational overhead and infrastructure costs.

Prerequisites

Before implementing Shopify webhooks, you'll need:

  • A Shopify Partner account (create one at partners.shopify.com)
  • A development store or production Shopify store
  • A publicly accessible HTTPS endpoint (webhooks require SSL)
  • Your app's client secret (different from API key) for signature verification

Setting Up Shopify Webhooks

Shopify provides multiple ways to create webhook subscriptions: through the Partner Dashboard, via the REST Admin API, or using the GraphQL Admin API. We'll cover the most common methods.

Creating webhooks programmatically gives you full control and allows dynamic subscription management.

Step 1: Get Your API Credentials

  1. Log in to your Shopify Partner Dashboard at partners.shopify.com
  2. Navigate to Apps → Select your app
  3. Go to App setupAPI credentials
  4. Note your API key and API secret key (also called client secret)
  5. Ensure your app has the required access scopes for the events you want to subscribe to

Step 2: Create Webhook Subscription via REST API

curl -X POST "https://your-shop.myshopify.com/admin/api/2025-01/webhooks.json" \
  -H "X-Shopify-Access-Token: YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhook": {
      "topic": "orders/create",
      "address": "https://yourdomain.com/webhooks/shopify/orders",
      "format": "json"
    }
  }'

Step 3: Verify Webhook Creation

curl -X GET "https://your-shop.myshopify.com/admin/api/2025-01/webhooks.json" \
  -H "X-Shopify-Access-Token: YOUR_ACCESS_TOKEN"

Method 2: Using GraphQL Admin API

For new applications (required after April 1, 2025), use GraphQL:

mutation {
  webhookSubscriptionCreate(
    topic: ORDERS_CREATE
    webhookSubscription: {
      format: JSON,
      callbackUrl: "https://yourdomain.com/webhooks/shopify/orders"
    }
  ) {
    webhookSubscription {
      id
      topic
      format
      endpoint {
        __typename
        ... on WebhookHttpEndpoint {
          callbackUrl
        }
      }
    }
    userErrors {
      field
      message
    }
  }
}

Method 3: Shopify App Configuration File

For Shopify CLI-based apps, configure webhooks in your shopify.app.toml:

[webhooks]
api_version = "2025-01"

[[webhooks.subscriptions]]
topics = ["orders/create", "orders/updated", "orders/paid"]
uri = "https://yourdomain.com/webhooks/shopify/orders"

[[webhooks.subscriptions]]
topics = ["products/create", "products/update", "products/delete"]
uri = "https://yourdomain.com/webhooks/shopify/products"

Important Configuration Notes

Webhook URL Requirements:

  • Must use HTTPS (HTTP is not supported)
  • Must be publicly accessible (no localhost)
  • Should return responses within 5 seconds
  • Must return 2xx status code for success

Selective Field Delivery: You can reduce payload size by requesting only specific fields:

{
  "webhook": {
    "topic": "orders/create",
    "address": "https://yourdomain.com/webhooks/shopify/orders",
    "format": "json",
    "fields": ["id", "email", "total_price", "line_items"]
  }
}

Pro Tips:

  • Create separate webhook subscriptions for test and production environments
  • Use different endpoint URLs for different event types to organize your code
  • Store your client secret securely in environment variables (never commit to version control)
  • Webhooks created through the Shopify admin UI won't appear in API calls
  • Each webhook subscription is scoped only to the app that created it

Shopify Webhook Events & Payloads

Shopify offers over 50 webhook topics covering every aspect of e-commerce operations. Here are the most important categories and events.

Complete Event Reference Table

Event TopicDescriptionCommon Use Case
orders/createNew order placedProcess orders, send confirmations
orders/updatedOrder modifiedUpdate order status, sync changes
orders/paidPayment confirmedTrigger fulfillment, update accounting
orders/fulfilledOrder shippedSend tracking info, update inventory
orders/cancelledOrder cancelledProcess refunds, restock inventory
products/createNew product addedSync to external systems
products/updateProduct modifiedUpdate listings on other platforms
products/deleteProduct removedRemove from external catalogs
customers/createNew customer accountAdd to CRM, welcome email
customers/updateCustomer info changedSync profile updates
inventory_levels/updateStock quantity changedSync inventory, trigger restocking
fulfillments/createFulfillment record createdGenerate shipping labels
fulfillments/updateFulfillment status changedUpdate tracking information
carts/createShopping cart createdTrack cart abandonment
checkouts/createCheckout initiatedIdentify potential conversions
app/uninstalledApp removed from storeClean up data, handle offboarding

Detailed Payload Examples

Let's examine the most commonly used webhook events with complete payload structures.

Event: orders/create

Description: Triggered immediately when a customer completes checkout and a new order is created.

Payload Structure:

{
  "id": 5231234567890,
  "admin_graphql_api_id": "gid://shopify/Order/5231234567890",
  "app_id": 1234567,
  "browser_ip": "192.168.1.1",
  "buyer_accepts_marketing": true,
  "cancel_reason": null,
  "cancelled_at": null,
  "cart_token": "c1-1234567890abcdef",
  "checkout_id": 34567890123456,
  "checkout_token": "1234567890abcdef",
  "client_details": {
    "accept_language": "en-US,en;q=0.9",
    "browser_height": 1080,
    "browser_ip": "192.168.1.1",
    "browser_width": 1920,
    "session_hash": null,
    "user_agent": "Mozilla/5.0..."
  },
  "closed_at": null,
  "confirmation_number": "ABC123456",
  "confirmed": true,
  "contact_email": "[email protected]",
  "created_at": "2025-01-24T10:30:00-05:00",
  "currency": "USD",
  "current_subtotal_price": "59.99",
  "current_total_discounts": "10.00",
  "current_total_price": "54.99",
  "current_total_tax": "5.00",
  "customer": {
    "id": 6234567890123,
    "email": "[email protected]",
    "first_name": "John",
    "last_name": "Doe",
    "phone": "+1234567890",
    "verified_email": true,
    "default_address": {
      "address1": "123 Main St",
      "address2": "Apt 4",
      "city": "San Francisco",
      "province": "California",
      "country": "United States",
      "zip": "94102",
      "phone": "+1234567890"
    }
  },
  "customer_locale": "en",
  "email": "[email protected]",
  "financial_status": "paid",
  "fulfillment_status": null,
  "line_items": [
    {
      "id": 12345678901234,
      "admin_graphql_api_id": "gid://shopify/LineItem/12345678901234",
      "fulfillment_service": "manual",
      "fulfillment_status": null,
      "grams": 500,
      "price": "29.99",
      "product_id": 7890123456789,
      "quantity": 2,
      "requires_shipping": true,
      "sku": "TSHIRT-BLK-L",
      "title": "Awesome T-Shirt",
      "variant_id": 40123456789012,
      "variant_title": "Black / Large",
      "vendor": "Awesome Brand",
      "name": "Awesome T-Shirt - Black / Large",
      "properties": []
    }
  ],
  "note": "Please gift wrap this order",
  "note_attributes": [],
  "number": 1234,
  "order_number": 1234,
  "order_status_url": "https://store.myshopify.com/12345678/orders/abc123/authenticate?key=def456",
  "phone": "+1234567890",
  "presentment_currency": "USD",
  "processed_at": "2025-01-24T10:30:00-05:00",
  "shipping_address": {
    "first_name": "John",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4",
    "city": "San Francisco",
    "province": "California",
    "country": "United States",
    "zip": "94102",
    "phone": "+1234567890"
  },
  "shipping_lines": [
    {
      "id": 3456789012345,
      "code": "Standard",
      "price": "5.00",
      "source": "shopify",
      "title": "Standard Shipping"
    }
  ],
  "subtotal_price": "59.98",
  "tags": "wholesale, vip",
  "total_discounts": "10.00",
  "total_line_items_price": "59.98",
  "total_price": "54.98",
  "total_tax": "5.00",
  "total_weight": 1000,
  "updated_at": "2025-01-24T10:30:00-05:00"
}

Key Fields:

  • id - Unique order identifier (use for idempotency)
  • financial_status - Payment status: pending, authorized, paid, refunded, voided
  • fulfillment_status - Shipping status: null (unfulfilled), partial, fulfilled
  • line_items - Array of products ordered with quantities and pricing
  • customer - Complete customer profile including email and address
  • created_at / updated_at - ISO 8601 timestamps for ordering events

Event: products/create

Description: Fired when a new product is added to the Shopify store.

Payload Structure:

{
  "id": 8901234567890,
  "title": "Premium Wireless Headphones",
  "body_html": "<p>High-quality wireless headphones with noise cancellation.</p>",
  "vendor": "TechBrand",
  "product_type": "Electronics",
  "created_at": "2025-01-24T11:00:00-05:00",
  "updated_at": "2025-01-24T11:00:00-05:00",
  "published_at": "2025-01-24T11:00:00-05:00",
  "handle": "premium-wireless-headphones",
  "status": "active",
  "tags": "audio, wireless, premium",
  "variants": [
    {
      "id": 45678901234567,
      "product_id": 8901234567890,
      "title": "Black",
      "price": "199.99",
      "sku": "HEADPHONE-BLK",
      "position": 1,
      "inventory_policy": "deny",
      "compare_at_price": "299.99",
      "fulfillment_service": "manual",
      "inventory_management": "shopify",
      "option1": "Black",
      "option2": null,
      "option3": null,
      "created_at": "2025-01-24T11:00:00-05:00",
      "updated_at": "2025-01-24T11:00:00-05:00",
      "taxable": true,
      "barcode": "123456789012",
      "grams": 250,
      "weight": 0.25,
      "weight_unit": "kg",
      "inventory_quantity": 100,
      "requires_shipping": true
    }
  ],
  "options": [
    {
      "id": 10123456789012,
      "product_id": 8901234567890,
      "name": "Color",
      "position": 1,
      "values": ["Black", "Silver", "White"]
    }
  ],
  "images": [
    {
      "id": 30123456789012,
      "product_id": 8901234567890,
      "position": 1,
      "created_at": "2025-01-24T11:00:00-05:00",
      "updated_at": "2025-01-24T11:00:00-05:00",
      "width": 2048,
      "height": 2048,
      "src": "https://cdn.shopify.com/s/files/1/0000/0000/0000/products/headphones.jpg",
      "variant_ids": [45678901234567]
    }
  ]
}

Key Fields:

  • id - Unique product identifier
  • variants - Array of product variations (size, color, etc.) with individual SKUs and pricing
  • inventory_quantity - Current stock level for inventory management
  • handle - URL-friendly product slug
  • status - Product visibility: active, archived, draft

Event: customers/create

Description: Triggered when a new customer account is created (either through checkout or manual registration).

Payload Structure:

{
  "id": 7123456789012,
  "email": "[email protected]",
  "first_name": "Jane",
  "last_name": "Smith",
  "phone": "+19876543210",
  "created_at": "2025-01-24T12:00:00-05:00",
  "updated_at": "2025-01-24T12:00:00-05:00",
  "state": "enabled",
  "verified_email": true,
  "accepts_marketing": true,
  "accepts_marketing_updated_at": "2025-01-24T12:00:00-05:00",
  "marketing_opt_in_level": "single_opt_in",
  "tax_exempt": false,
  "tags": "newsletter, vip",
  "currency": "USD",
  "orders_count": 0,
  "total_spent": "0.00",
  "last_order_id": null,
  "note": "VIP customer from trade show",
  "addresses": [
    {
      "id": 8234567890123,
      "customer_id": 7123456789012,
      "first_name": "Jane",
      "last_name": "Smith",
      "company": "Tech Startup Inc",
      "address1": "456 Market St",
      "address2": "Suite 200",
      "city": "New York",
      "province": "New York",
      "country": "United States",
      "zip": "10001",
      "phone": "+19876543210",
      "default": true
    }
  ],
  "default_address": {
    "id": 8234567890123,
    "customer_id": 7123456789012,
    "first_name": "Jane",
    "last_name": "Smith",
    "address1": "456 Market St",
    "city": "New York",
    "province": "New York",
    "country": "United States",
    "zip": "10001",
    "default": true
  }
}

Key Fields:

  • id - Unique customer identifier
  • email - Customer email (unique per store)
  • verified_email - Whether customer confirmed their email
  • accepts_marketing - Marketing consent status
  • orders_count / total_spent - Customer lifetime value metrics
  • addresses - Array of saved shipping/billing addresses

Event: inventory_levels/update

Description: Fires when inventory quantity changes for any product variant at any location.

Payload Structure:

{
  "inventory_item_id": 45678901234567,
  "location_id": 67890123456789,
  "available": 47,
  "updated_at": "2025-01-24T13:00:00-05:00"
}

Key Fields:

  • inventory_item_id - References the specific product variant
  • location_id - Warehouse or store location ID
  • available - Current available quantity for sale
  • updated_at - Timestamp of inventory change

Event: fulfillments/create

Description: Sent when an order fulfillment record is created (typically when items are shipped).

Payload Structure:

{
  "id": 4567890123456,
  "order_id": 5231234567890,
  "status": "success",
  "created_at": "2025-01-24T14:00:00-05:00",
  "service": "manual",
  "updated_at": "2025-01-24T14:00:00-05:00",
  "tracking_company": "FedEx",
  "tracking_number": "1234567890",
  "tracking_numbers": ["1234567890"],
  "tracking_url": "https://www.fedex.com/track?number=1234567890",
  "tracking_urls": ["https://www.fedex.com/track?number=1234567890"],
  "receipt": {},
  "line_items": [
    {
      "id": 12345678901234,
      "variant_id": 40123456789012,
      "title": "Awesome T-Shirt",
      "quantity": 2,
      "sku": "TSHIRT-BLK-L",
      "fulfillment_service": "manual",
      "fulfillment_status": "fulfilled"
    }
  ]
}

Key Fields:

  • order_id - Links fulfillment to original order
  • tracking_number / tracking_url - Shipping tracking information
  • line_items - Which products were included in this shipment
  • status - Fulfillment result: success, cancelled, error

Webhook Signature Verification

Why signature verification matters: Without verification, malicious actors could send fake webhooks to your endpoint, potentially triggering unauthorized actions like fraudulent order processing, inventory manipulation, or customer data exposure. Shopify's HMAC-SHA256 signature ensures webhooks genuinely originated from Shopify.

Shopify's Signature Method

Shopify uses HMAC-SHA256 (Hash-based Message Authentication Code with SHA-256 algorithm) to sign webhooks:

Algorithm: HMAC-SHA256 Header name: X-Shopify-Hmac-SHA256 Encoding: Base64 Key: Your app's client secret (API secret key) Data: Raw request body (before any parsing)

Additional headers Shopify includes:

  • X-Shopify-Shop-Domain - The shop that triggered the webhook (e.g., "example.myshopify.com")
  • X-Shopify-Topic - The webhook event type (e.g., "orders/create")
  • X-Shopify-API-Version - API version used (e.g., "2025-01")
  • X-Shopify-Webhook-Id - Unique identifier for this webhook delivery (use for idempotency)

Step-by-Step Verification Process

  1. Extract the signature from the X-Shopify-Hmac-SHA256 header
  2. Retrieve your app's client secret from environment variables
  3. Compute expected signature using HMAC-SHA256 with raw body
  4. Encode result as base64
  5. Compare computed signature with received signature using timing-safe comparison
  6. Reject request if signatures don't match (return 401 Unauthorized)

Code Examples

Node.js / Express

const crypto = require('crypto');
const express = require('express');
const app = express();

// CRITICAL: Use raw body parser for signature verification
// Must be applied BEFORE any JSON parsing middleware
app.use('/webhooks/shopify', express.raw({ type: 'application/json' }));

app.post('/webhooks/shopify/orders', (req, res) => {
  try {
    // 1. Extract signature from header
    const hmacHeader = req.headers['x-shopify-hmac-sha256'];

    if (!hmacHeader) {
      console.error('Missing HMAC signature header');
      return res.status(401).send('Unauthorized: No signature');
    }

    // 2. Get your app's client secret from environment
    const clientSecret = process.env.SHOPIFY_CLIENT_SECRET;

    if (!clientSecret) {
      console.error('SHOPIFY_CLIENT_SECRET not configured');
      return res.status(500).send('Server configuration error');
    }

    // 3. Compute expected HMAC signature
    const hash = crypto
      .createHmac('sha256', clientSecret)
      .update(req.body, 'utf8')
      .digest('base64');

    // 4. Compare using timing-safe comparison to prevent timing attacks
    const hmacHeaderBuffer = Buffer.from(hmacHeader, 'base64');
    const hashBuffer = Buffer.from(hash, 'base64');

    if (!crypto.timingSafeEqual(hmacHeaderBuffer, hashBuffer)) {
      console.error('HMAC verification failed');
      console.error('Expected:', hash);
      console.error('Received:', hmacHeader);
      return res.status(401).send('Unauthorized: Invalid signature');
    }

    // 5. Signature verified! Parse the payload
    const payload = JSON.parse(req.body.toString('utf8'));

    // Extract metadata from headers
    const shopDomain = req.headers['x-shopify-shop-domain'];
    const topic = req.headers['x-shopify-topic'];
    const webhookId = req.headers['x-shopify-webhook-id'];

    console.log(`Verified webhook from ${shopDomain}: ${topic} (ID: ${webhookId})`);

    // 6. Return 200 immediately to acknowledge receipt
    res.status(200).send('Webhook received');

    // 7. Process webhook asynchronously (don't block response)
    processShopifyWebhookAsync(payload, topic, shopDomain, webhookId);

  } catch (error) {
    console.error('Webhook processing error:', error);
    // Return 200 even on our internal errors to prevent Shopify retries
    res.status(200).send('Webhook received with errors');
  }
});

async function processShopifyWebhookAsync(payload, topic, shopDomain, webhookId) {
  // Your business logic here - runs after response is sent
  // Implement queue-based processing for production
}

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Shopify webhook server listening on port ${PORT}`);
});

Python / Flask

import hmac
import hashlib
import base64
import os
from flask import Flask, request, jsonify

app = Flask(__name__)

# Load client secret from environment
CLIENT_SECRET = os.getenv('SHOPIFY_CLIENT_SECRET')

@app.route('/webhooks/shopify/orders', methods=['POST'])
def shopify_orders_webhook():
    try:
        # 1. Extract signature from header
        hmac_header = request.headers.get('X-Shopify-Hmac-SHA256')

        if not hmac_header:
            app.logger.error('Missing HMAC signature header')
            return 'Unauthorized: No signature', 401

        if not CLIENT_SECRET:
            app.logger.error('SHOPIFY_CLIENT_SECRET not configured')
            return 'Server configuration error', 500

        # 2. Get raw request body (before any parsing)
        raw_body = request.get_data()

        # 3. Compute expected HMAC signature
        hash_obj = hmac.new(
            CLIENT_SECRET.encode('utf-8'),
            raw_body,
            hashlib.sha256
        )
        computed_hmac = base64.b64encode(hash_obj.digest()).decode('utf-8')

        # 4. Compare signatures using timing-safe comparison
        if not hmac.compare_digest(computed_hmac, hmac_header):
            app.logger.error('HMAC verification failed')
            app.logger.error(f'Expected: {computed_hmac}')
            app.logger.error(f'Received: {hmac_header}')
            return 'Unauthorized: Invalid signature', 401

        # 5. Signature verified! Parse JSON payload
        payload = request.get_json()

        # Extract metadata from headers
        shop_domain = request.headers.get('X-Shopify-Shop-Domain')
        topic = request.headers.get('X-Shopify-Topic')
        webhook_id = request.headers.get('X-Shopify-Webhook-Id')

        app.logger.info(f'Verified webhook from {shop_domain}: {topic} (ID: {webhook_id})')

        # 6. Return 200 immediately
        response = jsonify({'status': 'received'})
        response.status_code = 200

        # 7. Process webhook asynchronously
        # In production, use Celery, RQ, or similar queue system
        process_shopify_webhook_async(payload, topic, shop_domain, webhook_id)

        return response

    except Exception as e:
        app.logger.error(f'Webhook processing error: {str(e)}')
        # Return 200 even on internal errors to prevent Shopify retries
        return jsonify({'status': 'received', 'error': True}), 200

def process_shopify_webhook_async(payload, topic, shop_domain, webhook_id):
    # Your business logic here - runs after response
    # Queue this function call in production
    pass

if __name__ == '__main__':
    port = int(os.getenv('PORT', 3000))
    app.run(host='0.0.0.0', port=port)

PHP

<?php
// Get client secret from environment
$clientSecret = getenv('SHOPIFY_CLIENT_SECRET');

if (!$clientSecret) {
    error_log('SHOPIFY_CLIENT_SECRET not configured');
    http_response_code(500);
    die('Server configuration error');
}

// 1. Extract signature from header
// PHP converts X-Shopify-Hmac-SHA256 to HTTP_X_SHOPIFY_HMAC_SHA256
$hmacHeader = $_SERVER['HTTP_X_SHOPIFY_HMAC_SHA256'] ?? '';

if (empty($hmacHeader)) {
    error_log('Missing HMAC signature header');
    http_response_code(401);
    die('Unauthorized: No signature');
}

// 2. Get raw POST body (before any parsing)
$rawBody = file_get_contents('php://input');

// 3. Compute expected HMAC signature
$computedHmac = base64_encode(
    hash_hmac('sha256', $rawBody, $clientSecret, true)
);

// 4. Compare signatures using timing-safe comparison
if (!hash_equals($computedHmac, $hmacHeader)) {
    error_log('HMAC verification failed');
    error_log('Expected: ' . $computedHmac);
    error_log('Received: ' . $hmacHeader);
    http_response_code(401);
    die('Unauthorized: Invalid signature');
}

// 5. Signature verified! Parse JSON payload
$payload = json_decode($rawBody, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    error_log('Invalid JSON payload: ' . json_last_error_msg());
    http_response_code(200); // Return 200 to prevent retries
    die('Webhook received with errors');
}

// Extract metadata from headers
$shopDomain = $_SERVER['HTTP_X_SHOPIFY_SHOP_DOMAIN'] ?? '';
$topic = $_SERVER['HTTP_X_SHOPIFY_TOPIC'] ?? '';
$webhookId = $_SERVER['HTTP_X_SHOPIFY_WEBHOOK_ID'] ?? '';

error_log("Verified webhook from $shopDomain: $topic (ID: $webhookId)");

// 6. Return 200 immediately to acknowledge receipt
http_response_code(200);
echo 'Webhook received';

// 7. Process webhook asynchronously
// In production, queue this using Laravel Queue, Symfony Messenger, etc.
processShopifyWebhookAsync($payload, $topic, $shopDomain, $webhookId);

function processShopifyWebhookAsync($payload, $topic, $shopDomain, $webhookId) {
    // Your business logic here
    // Queue this function in production environments
}
?>

Common Verification Errors

Error 1: Parsing JSON before verification

  • Wrong: Using express.json() before signature verification modifies the body
  • Correct: Use express.raw() for webhook routes, parse JSON after verification

Error 2: Using wrong secret

  • Wrong: Using API key instead of client secret
  • Correct: Use the "API secret key" (also called client secret) from app credentials

Error 3: Not using timing-safe comparison

  • Wrong: if (computed === received) vulnerable to timing attacks
  • Correct: Use crypto.timingSafeEqual() or hmac.compare_digest()

Error 4: Wrong encoding

  • Wrong: Hex encoding the HMAC result
  • Correct: Base64 encoding (Shopify's format)

Error 5: Testing with wrong environment

  • Wrong: Using production secret for development store webhooks
  • Correct: Each app/environment has its own unique client secret

Testing Shopify Webhooks

Testing webhooks during development presents unique challenges since Shopify's servers can't reach your local localhost environment. Here are the best solutions.

Local Development Challenges

The problem:

  • Shopify needs a publicly accessible HTTPS URL
  • Your local development server runs on localhost:3000
  • Shopify webhooks require valid SSL certificates
  • You need to test signature verification before deploying

Solution 1: ngrok (Quick Local Testing)

ngrok creates a secure tunnel from a public URL to your local machine.

Installation:

# macOS (using Homebrew)
brew install ngrok

# Or download from https://ngrok.com/download

Usage:

# 1. Start your local server
npm start
# Server running on localhost:3000

# 2. In a new terminal, start ngrok tunnel
ngrok http 3000

# Output shows your public URLs:
# Forwarding  https://abc123.ngrok.io -> http://localhost:3000

Configure in Shopify:

  1. Copy the HTTPS ngrok URL (e.g., https://abc123.ngrok.io)
  2. Add /webhooks/shopify/orders to the end
  3. Create webhook subscription with https://abc123.ngrok.io/webhooks/shopify/orders
  4. Shopify will now send webhooks to this URL, which tunnels to your localhost

ngrok Tips:

  • Free tier provides temporary URLs that change each restart
  • Paid tier ($8/month) gives permanent URLs
  • Use ngrok's web interface at http://localhost:4040 to inspect webhook requests
  • Remember to update webhook URLs after each restart on free tier

For testing without external dependencies or before setting up your endpoint, use our Webhook Payload Generator.

How to use:

  1. Visit the tool: Navigate to Webhook Payload Generator

  2. Select Shopify: Choose "Shopify" from the provider dropdown

  3. Choose event type: Select the webhook topic you want to test (e.g., "orders/create")

  4. Customize payload: Modify field values to match your test scenarios:

    • Change customer emails
    • Adjust product IDs and SKUs
    • Modify order totals and quantities
    • Add specific tags or notes
  5. Enter your client secret: Provide your app's client secret for signature generation

  6. Generate signed payload: The tool creates a properly formatted webhook with valid HMAC-SHA256 signature

  7. Test locally: Copy the generated payload and send it to your local endpoint using curl or Postman

Example using curl:

# Generated from Webhook Payload Generator
curl -X POST http://localhost:3000/webhooks/shopify/orders \
  -H "Content-Type: application/json" \
  -H "X-Shopify-Hmac-SHA256: nE9nKOHcXsBBEgGYXzqR8H3q6RJZKZb5dLVPVVGdU5M=" \
  -H "X-Shopify-Shop-Domain: test-store.myshopify.com" \
  -H "X-Shopify-Topic: orders/create" \
  -H "X-Shopify-API-Version: 2025-01" \
  -H "X-Shopify-Webhook-Id: b1234567-89ab-cdef-0123-456789abcdef" \
  -d '{"id": 5231234567890, "email": "[email protected]", ... }'

Benefits of using the generator:

  • ✅ No tunneling or public URLs required
  • ✅ Test signature verification logic thoroughly
  • ✅ Customize payloads for edge cases and error scenarios
  • ✅ Test different event types quickly
  • ✅ Works completely offline after initial page load
  • ✅ Generates valid HMAC signatures automatically
  • ✅ Perfect for unit testing webhook handlers

Solution 3: Shopify CLI Development Store

For comprehensive testing, Shopify CLI can create a development store with webhook testing features.

Setup:

# Install Shopify CLI
npm install -g @shopify/cli

# Create new app (or connect existing)
shopify app init

# Start development server with tunnel
shopify app dev

The CLI automatically creates a tunnel and configures webhook URLs.

Testing Checklist

Before deploying to production, verify:

Signature Verification:

  • Valid signatures accepted (returns 200)
  • Invalid signatures rejected (returns 401)
  • Missing signature header rejected (returns 401)
  • Timing-safe comparison used (no string comparison)

Response Requirements:

  • Endpoint returns 200 within 5 seconds
  • Response sent before processing begins
  • No long-running operations block the response

Idempotency:

  • Duplicate webhook IDs detected and skipped
  • Same event processed multiple times doesn't cause issues
  • Database constraints prevent duplicate processing

Error Handling:

  • Malformed JSON payloads handled gracefully
  • Missing required fields don't crash endpoint
  • Internal errors still return 200 (to prevent retries)
  • Errors logged for debugging

Security:

  • Client secret stored in environment variables
  • Raw body used for signature verification
  • HTTPS enforced in production
  • Rate limiting implemented

Implementation Example

Let's build a complete, production-ready Shopify webhook endpoint that follows all best practices.

Requirements

Your webhook implementation should:

  • Respond within 5 seconds - Shopify enforces strict timeouts
  • Return 200 status code - Even if processing fails later
  • Process asynchronously - Queue webhooks for background processing
  • Handle retries gracefully - Implement idempotency checks
  • Log comprehensively - Track all webhooks for debugging
  • Verify signatures - Always authenticate webhook origin

Complete Node.js Production Example

const express = require('express');
const crypto = require('crypto');
const Queue = require('bull'); // Redis-backed queue
const { Client } = require('pg'); // PostgreSQL client

const app = express();

// Initialize Redis queue for async processing
const shopifyWebhookQueue = new Queue('shopify-webhooks', {
  redis: {
    host: process.env.REDIS_HOST || 'localhost',
    port: process.env.REDIS_PORT || 6379,
  }
});

// PostgreSQL connection for idempotency tracking
const dbClient = new Client({
  connectionString: process.env.DATABASE_URL
});
dbClient.connect();

// CRITICAL: Use raw body parser for webhook routes
// Must be before any other body parsing middleware
app.use('/webhooks/shopify', express.raw({ type: 'application/json' }));

// Other routes can use JSON parser
app.use(express.json());

/**
 * Verify Shopify webhook HMAC signature
 * @param {Buffer} rawBody - Raw request body
 * @param {string} hmacHeader - HMAC signature from header
 * @returns {boolean} - True if signature is valid
 */
function verifyShopifyWebhook(rawBody, hmacHeader) {
  const clientSecret = process.env.SHOPIFY_CLIENT_SECRET;

  if (!clientSecret || !hmacHeader) {
    return false;
  }

  const hash = crypto
    .createHmac('sha256', clientSecret)
    .update(rawBody, 'utf8')
    .digest('base64');

  try {
    return crypto.timingSafeEqual(
      Buffer.from(hash, 'base64'),
      Buffer.from(hmacHeader, 'base64')
    );
  } catch (error) {
    console.error('Signature comparison error:', error);
    return false;
  }
}

/**
 * Check if webhook was already processed (idempotency)
 * @param {string} webhookId - Unique webhook identifier
 * @returns {Promise<boolean>} - True if already processed
 */
async function isWebhookProcessed(webhookId) {
  try {
    const result = await dbClient.query(
      'SELECT 1 FROM processed_webhooks WHERE webhook_id = $1',
      [webhookId]
    );
    return result.rows.length > 0;
  } catch (error) {
    console.error('Database check error:', error);
    // Fail open - allow processing if DB check fails
    return false;
  }
}

/**
 * Mark webhook as processing to prevent duplicates
 * @param {string} webhookId - Unique webhook identifier
 * @param {string} topic - Webhook event type
 * @param {string} shopDomain - Shop that sent webhook
 */
async function markWebhookProcessing(webhookId, topic, shopDomain) {
  try {
    await dbClient.query(
      `INSERT INTO processed_webhooks (webhook_id, topic, shop_domain, status, created_at)
       VALUES ($1, $2, $3, 'processing', NOW())
       ON CONFLICT (webhook_id) DO NOTHING`,
      [webhookId, topic, shopDomain]
    );
  } catch (error) {
    console.error('Failed to mark webhook as processing:', error);
  }
}

/**
 * Main Shopify webhook endpoint
 */
app.post('/webhooks/shopify/:topic', async (req, res) => {
  const startTime = Date.now();

  try {
    // 1. Extract and verify signature
    const hmacHeader = req.headers['x-shopify-hmac-sha256'];

    if (!verifyShopifyWebhook(req.body, hmacHeader)) {
      console.error('Invalid signature from', req.headers['x-shopify-shop-domain']);
      return res.status(401).json({
        error: 'Invalid signature',
        timestamp: new Date().toISOString()
      });
    }

    // 2. Extract metadata
    const shopDomain = req.headers['x-shopify-shop-domain'];
    const topic = req.headers['x-shopify-topic'];
    const apiVersion = req.headers['x-shopify-api-version'];
    const webhookId = req.headers['x-shopify-webhook-id'];

    if (!webhookId) {
      console.error('Missing webhook ID');
      return res.status(400).json({ error: 'Missing webhook ID' });
    }

    // 3. Check for duplicate (idempotency)
    const alreadyProcessed = await isWebhookProcessed(webhookId);

    if (alreadyProcessed) {
      console.log(`Webhook ${webhookId} already processed, skipping`);
      return res.status(200).json({
        received: true,
        duplicate: true,
        webhookId
      });
    }

    // 4. Parse payload after verification
    const payload = JSON.parse(req.body.toString('utf8'));

    // 5. Mark as processing (prevents race conditions)
    await markWebhookProcessing(webhookId, topic, shopDomain);

    // 6. Queue for async processing
    await shopifyWebhookQueue.add(
      {
        webhookId,
        topic,
        shopDomain,
        apiVersion,
        payload,
        receivedAt: new Date().toISOString()
      },
      {
        attempts: 3, // Retry failed jobs 3 times
        backoff: {
          type: 'exponential',
          delay: 2000 // Start with 2 second delay
        },
        removeOnComplete: 100, // Keep last 100 completed jobs
        removeOnFail: false // Keep failed jobs for debugging
      }
    );

    // 7. Return 200 immediately (within 5 second timeout)
    const processingTime = Date.now() - startTime;

    res.status(200).json({
      received: true,
      webhookId,
      processingTime: `${processingTime}ms`
    });

    // 8. Logging
    console.log(`✓ Queued ${topic} webhook from ${shopDomain} (ID: ${webhookId}) in ${processingTime}ms`);

  } catch (error) {
    console.error('Webhook endpoint error:', error);

    // Still return 200 to prevent Shopify retries for our errors
    // Log error for investigation
    res.status(200).json({
      received: true,
      error: true,
      message: 'Webhook received but encountered processing error'
    });
  }
});

/**
 * Process webhooks from queue (runs in background)
 */
shopifyWebhookQueue.process(async (job) => {
  const { webhookId, topic, shopDomain, payload } = job.data;

  console.log(`Processing ${topic} webhook ${webhookId} from ${shopDomain}`);

  try {
    // Route to appropriate handler based on topic
    switch (topic) {
      case 'orders/create':
        await handleOrderCreated(payload, shopDomain);
        break;

      case 'orders/paid':
        await handleOrderPaid(payload, shopDomain);
        break;

      case 'orders/fulfilled':
        await handleOrderFulfilled(payload, shopDomain);
        break;

      case 'products/create':
        await handleProductCreated(payload, shopDomain);
        break;

      case 'products/update':
        await handleProductUpdated(payload, shopDomain);
        break;

      case 'customers/create':
        await handleCustomerCreated(payload, shopDomain);
        break;

      case 'inventory_levels/update':
        await handleInventoryUpdated(payload, shopDomain);
        break;

      case 'app/uninstalled':
        await handleAppUninstalled(shopDomain);
        break;

      // Mandatory GDPR webhooks
      case 'customers/data_request':
        await handleCustomerDataRequest(payload, shopDomain);
        break;

      case 'customers/redact':
        await handleCustomerRedact(payload, shopDomain);
        break;

      case 'shop/redact':
        await handleShopRedact(shopDomain);
        break;

      default:
        console.warn(`Unhandled webhook topic: ${topic}`);
    }

    // Mark as completed
    await dbClient.query(
      `UPDATE processed_webhooks
       SET status = 'completed', completed_at = NOW()
       WHERE webhook_id = $1`,
      [webhookId]
    );

    console.log(`✓ Successfully processed ${topic} webhook ${webhookId}`);

  } catch (error) {
    console.error(`Failed to process webhook ${webhookId}:`, error);

    // Mark as failed
    await dbClient.query(
      `UPDATE processed_webhooks
       SET status = 'failed', error_message = $2, failed_at = NOW()
       WHERE webhook_id = $1`,
      [webhookId, error.message]
    );

    // Re-throw to trigger Bull queue retry
    throw error;
  }
});

/**
 * Business logic handlers
 */
async function handleOrderCreated(order, shopDomain) {
  console.log(`New order #${order.order_number} from ${shopDomain}`);
  console.log(`Customer: ${order.email}`);
  console.log(`Total: ${order.currency} ${order.total_price}`);

  // Example: Send to fulfillment system
  await sendToFulfillmentSystem({
    orderId: order.id,
    orderNumber: order.order_number,
    items: order.line_items.map(item => ({
      sku: item.sku,
      quantity: item.quantity,
      name: item.name
    })),
    shippingAddress: order.shipping_address
  });

  // Example: Send confirmation email
  await sendOrderConfirmationEmail({
    to: order.email,
    orderNumber: order.order_number,
    items: order.line_items,
    total: order.total_price
  });
}

async function handleOrderPaid(order, shopDomain) {
  console.log(`Order #${order.order_number} paid: ${order.currency} ${order.total_price}`);

  // Example: Update accounting system
  await updateAccountingSystem({
    orderId: order.id,
    amount: order.total_price,
    currency: order.currency,
    paymentMethod: order.payment_gateway_names[0]
  });
}

async function handleOrderFulfilled(order, shopDomain) {
  console.log(`Order #${order.order_number} fulfilled`);

  // Example: Send tracking email
  const fulfillments = order.fulfillments || [];

  for (const fulfillment of fulfillments) {
    await sendTrackingEmail({
      to: order.email,
      orderNumber: order.order_number,
      trackingNumber: fulfillment.tracking_number,
      trackingUrl: fulfillment.tracking_url,
      carrier: fulfillment.tracking_company
    });
  }
}

async function handleProductCreated(product, shopDomain) {
  console.log(`New product created: ${product.title}`);

  // Example: Sync to other sales channels
  await syncProductToChannels({
    productId: product.id,
    title: product.title,
    description: product.body_html,
    variants: product.variants,
    images: product.images
  });
}

async function handleProductUpdated(product, shopDomain) {
  console.log(`Product updated: ${product.title}`);

  // Example: Update search index
  await updateSearchIndex({
    productId: product.id,
    title: product.title,
    description: product.body_html,
    tags: product.tags
  });
}

async function handleCustomerCreated(customer, shopDomain) {
  console.log(`New customer: ${customer.email}`);

  // Example: Add to CRM
  await addToCRM({
    customerId: customer.id,
    email: customer.email,
    firstName: customer.first_name,
    lastName: customer.last_name,
    acceptsMarketing: customer.accepts_marketing,
    tags: customer.tags
  });

  // Example: Send welcome email
  if (customer.accepts_marketing) {
    await sendWelcomeEmail({
      to: customer.email,
      firstName: customer.first_name
    });
  }
}

async function handleInventoryUpdated(inventory, shopDomain) {
  console.log(`Inventory updated: Item ${inventory.inventory_item_id} = ${inventory.available}`);

  // Example: Sync inventory to other platforms
  await syncInventoryToMarketplaces({
    inventoryItemId: inventory.inventory_item_id,
    locationId: inventory.location_id,
    available: inventory.available
  });

  // Example: Low stock alert
  if (inventory.available < 10) {
    await sendLowStockAlert({
      inventoryItemId: inventory.inventory_item_id,
      available: inventory.available
    });
  }
}

async function handleAppUninstalled(shopDomain) {
  console.log(`App uninstalled from ${shopDomain}`);

  // Example: Clean up shop data
  await dbClient.query(
    'UPDATE shops SET status = $1, uninstalled_at = NOW() WHERE domain = $2',
    ['uninstalled', shopDomain]
  );
}

// Mandatory GDPR webhooks
async function handleCustomerDataRequest(payload, shopDomain) {
  console.log(`Customer data request for shop ${shopDomain}`);
  // Implement: Gather and provide customer data
}

async function handleCustomerRedact(payload, shopDomain) {
  console.log(`Customer redaction request for shop ${shopDomain}`);
  // Implement: Delete customer data
}

async function handleShopRedact(shopDomain) {
  console.log(`Shop redaction request for ${shopDomain}`);
  // Implement: Delete all shop data
}

/**
 * Placeholder functions for external systems
 * Replace with your actual integrations
 */
async function sendToFulfillmentSystem(data) { /* Your implementation */ }
async function sendOrderConfirmationEmail(data) { /* Your implementation */ }
async function updateAccountingSystem(data) { /* Your implementation */ }
async function sendTrackingEmail(data) { /* Your implementation */ }
async function syncProductToChannels(data) { /* Your implementation */ }
async function updateSearchIndex(data) { /* Your implementation */ }
async function addToCRM(data) { /* Your implementation */ }
async function sendWelcomeEmail(data) { /* Your implementation */ }
async function syncInventoryToMarketplaces(data) { /* Your implementation */ }
async function sendLowStockAlert(data) { /* Your implementation */ }

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString()
  });
});

// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Shopify webhook server listening on port ${PORT}`);
});

// Graceful shutdown
process.on('SIGTERM', async () => {
  console.log('SIGTERM received, closing connections...');
  await shopifyWebhookQueue.close();
  await dbClient.end();
  process.exit(0);
});

Database Schema for Idempotency

Create this PostgreSQL table to track processed webhooks:

CREATE TABLE processed_webhooks (
  webhook_id VARCHAR(255) PRIMARY KEY,
  topic VARCHAR(100) NOT NULL,
  shop_domain VARCHAR(255) NOT NULL,
  status VARCHAR(50) NOT NULL, -- 'processing', 'completed', 'failed'
  error_message TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT NOW(),
  completed_at TIMESTAMP,
  failed_at TIMESTAMP,
  INDEX idx_shop_domain (shop_domain),
  INDEX idx_created_at (created_at),
  INDEX idx_status (status)
);

-- Optional: Auto-cleanup old records (keep last 30 days)
CREATE OR REPLACE FUNCTION cleanup_old_webhooks() RETURNS void AS $$
BEGIN
  DELETE FROM processed_webhooks
  WHERE created_at < NOW() - INTERVAL '30 days';
END;
$$ LANGUAGE plpgsql;

Key Implementation Details

  1. Raw body parsing - Critical for signature verification; express.raw() preserves the exact bytes
  2. Timing-safe comparison - crypto.timingSafeEqual() prevents timing attack vulnerabilities
  3. Idempotency check - Database lookup prevents duplicate processing if Shopify retries
  4. Queue-based processing - Bull/Redis queue enables fast response (<5s) with reliable background processing
  5. Error handling - Always returns 200 to prevent unnecessary Shopify retries; logs errors for investigation
  6. Comprehensive logging - Tracks webhook IDs, processing times, and errors for debugging
  7. Graceful degradation - Continues processing even if individual handlers fail

Best Practices

Security

Always verify signatures:

  • ✅ Verify HMAC-SHA256 signature on every webhook
  • ✅ Use timing-safe comparison functions to prevent timing attacks
  • ✅ Reject webhooks with invalid or missing signatures immediately

Use HTTPS endpoints only:

  • ✅ Shopify requires SSL/TLS for all webhook endpoints
  • ✅ Use valid SSL certificates (not self-signed)
  • ✅ Keep certificates up to date to prevent delivery failures

Store secrets securely:

  • ✅ Keep client secret in environment variables or secrets manager
  • ✅ Never commit secrets to version control (add to .gitignore)
  • ✅ Use different secrets for development and production
  • ✅ Rotate secrets periodically (note: 1-hour propagation delay)

Validate webhook metadata:

  • ✅ Check X-Shopify-Shop-Domain matches expected shops
  • ✅ Validate shop is active in your database
  • ✅ Verify API version compatibility

Implement rate limiting:

  • ✅ Rate limit webhook endpoints to prevent abuse
  • ✅ Use IP-based rate limiting (Shopify IPs are documented)
  • ✅ Monitor for unusual webhook volumes

Performance

Respond within 5 seconds:

  • ✅ Shopify enforces strict 5-second total timeout
  • ✅ 1-second connection timeout requirement
  • ✅ Return 200 immediately, process asynchronously
  • ✅ Never perform long operations in webhook endpoint

Use queue systems:

  • ✅ Queue webhooks for background processing (Bull, RabbitMQ, AWS SQS)
  • ✅ Separate fast and slow processing queues
  • ✅ Implement exponential backoff for retries
  • ✅ Monitor queue depth and processing times

Optimize database operations:

  • ✅ Use indexes on webhook_id for fast idempotency checks
  • ✅ Consider caching frequently accessed data (shop configurations)
  • ✅ Use connection pooling for database clients
  • ✅ Implement database query timeouts

Monitor webhook processing:

  • ✅ Track webhook endpoint response times
  • ✅ Alert on slow processing or queue buildup
  • ✅ Monitor signature verification failure rates
  • ✅ Set up health checks for webhook infrastructure

Reliability

Implement idempotency:

  • ✅ Use X-Shopify-Webhook-Id header for deduplication
  • ✅ Store processed webhook IDs in database with unique constraint
  • ✅ Check idempotency before processing (fast database lookup)
  • ✅ Design all webhook handlers to be idempotent (safe to run multiple times)

Handle duplicate webhooks:

  • ✅ Shopify may send same webhook multiple times
  • ✅ Network issues or retries can cause duplicates
  • ✅ Return 200 for already-processed webhooks
  • ✅ Use database constraints to prevent duplicate processing

Implement retry logic:

  • ✅ Retry failed processing (not delivery - Shopify handles that)
  • ✅ Use exponential backoff for retries (2s, 4s, 8s, etc.)
  • ✅ Set maximum retry attempts (e.g., 3-5 attempts)
  • ✅ Move permanently failed webhooks to dead letter queue

Don't rely solely on webhooks:

  • ✅ Implement periodic reconciliation jobs
  • ✅ Compare webhook data with API queries daily
  • ✅ Handle missing webhooks gracefully
  • ✅ Webhook subscriptions can be removed after failures

Comprehensive logging:

  • ✅ Log all incoming webhooks with metadata
  • ✅ Track processing success/failure rates
  • ✅ Store webhook payloads temporarily for debugging
  • ✅ Correlate logs with webhook IDs for tracing

Shopify-Specific Best Practices

Webhook ordering:

  • ⚠️ Shopify does NOT guarantee webhook delivery order
  • ✅ Use timestamp fields (created_at, updated_at) to determine sequence
  • ✅ Design handlers to work regardless of order
  • ✅ Handle out-of-sequence updates gracefully (e.g., "fulfilled" before "paid")

Selective field subscription:

  • ✅ Use fields parameter to reduce payload size
  • ✅ Only request fields you actually need
  • ✅ Reduces bandwidth and parsing overhead
  • ✅ Faster webhook delivery from Shopify

Mandatory compliance webhooks:

  • ✅ All apps MUST implement customers/data_request
  • ✅ All apps MUST implement customers/redact
  • ✅ All apps MUST implement shop/redact
  • ✅ Required for GDPR compliance and app approval

Webhook subscription management:

  • ✅ Webhooks created via API are scoped to your app only
  • ✅ Admin-created webhooks don't appear in API calls
  • ✅ After 8 failed retries over 4 hours, subscriptions are auto-removed
  • ✅ Regularly verify webhook subscriptions still exist

API version awareness:

  • ✅ Check X-Shopify-API-Version header
  • ✅ Handle multiple API versions if supporting old versions
  • ✅ Shopify maintains API versions for at least 12 months
  • ✅ Plan for API version upgrades in your webhook handlers

Common Issues & Troubleshooting

Issue 1: Signature Verification Failing

Symptoms:

  • 401 Unauthorized errors in Shopify webhook logs
  • "Invalid signature" errors in your application logs
  • Webhooks never processed successfully

Causes & Solutions:

Using wrong secret:

  • Check you're using the API secret key (client secret), not the API key
  • Verify secret matches the app that created the webhook subscription
  • ✅ Double-check secret from Shopify Partner Dashboard → Apps → Your App → API credentials

Parsing JSON before verification:

  • Common with express.json() or body-parser middleware
  • Parsing modifies the body, breaking signature verification
  • ✅ Use express.raw({ type: 'application/json' }) for webhook routes only

Incorrect algorithm:

  • Shopify uses HMAC-SHA256, not SHA1 or SHA512
  • Ensure you're using sha256 in your hash function
  • ✅ Verify: crypto.createHmac('sha256', secret)

Wrong encoding:

  • Shopify encodes signatures as base64, not hex
  • ✅ Use .digest('base64'), not .digest('hex')

Comparing strings instead of buffers:

  • Direct string comparison vulnerable to timing attacks
  • ✅ Use crypto.timingSafeEqual() with buffers

Debugging steps:

// Add debug logging to see exact values
console.log('Received signature:', req.headers['x-shopify-hmac-sha256']);
console.log('Computed signature:', hash);
console.log('Body length:', req.body.length);
console.log('Secret length:', clientSecret.length);

Issue 2: Webhook Timeouts

Symptoms:

  • Shopify webhook delivery logs show timeout errors
  • Your endpoint logs show requests taking >5 seconds
  • Webhooks automatically retry and eventually subscription removed

Causes & Solutions:

Slow database queries:

  • Complex queries or table scans block response
  • ✅ Return 200 immediately, queue database operations
  • ✅ Add database indexes on frequently queried fields
  • ✅ Use database query timeouts

External API calls:

  • Waiting for third-party services (email, CRM, etc.)
  • Third-party timeouts cause your webhook to timeout
  • ✅ Queue external API calls for background processing
  • ✅ Never wait for external services in webhook endpoint

Heavy computation:

  • Complex calculations or data transformations
  • Large payload processing
  • ✅ Move computation to background jobs
  • ✅ Process in chunks if handling large datasets

No async processing:

  • Trying to complete all work before responding
  • ✅ Use queue systems (Bull, RabbitMQ, AWS SQS)
  • ✅ Follow pattern: receive → verify → queue → respond → process

Example fix:

// ❌ WRONG: Blocks response
app.post('/webhooks/shopify', async (req, res) => {
  verifySignature(req);
  await processOrder(req.body); // SLOW
  res.status(200).send('OK');
});

// ✅ CORRECT: Immediate response
app.post('/webhooks/shopify', async (req, res) => {
  verifySignature(req);
  await queue.add(req.body); // FAST
  res.status(200).send('OK');
  // Processing happens in background
});

Issue 3: Duplicate Events

Symptoms:

  • Same webhook processed multiple times
  • Duplicate orders created or double charges
  • Data inconsistencies in your database

Causes & Solutions:

No idempotency check:

  • Not tracking which webhooks already processed
  • ✅ Store X-Shopify-Webhook-Id in database with unique constraint
  • ✅ Check if webhook ID exists before processing

Network retries:

  • Shopify retries when no 200 response received
  • Your processing completed but response never sent
  • ✅ Return 200 before starting processing
  • ✅ Make handlers idempotent (safe to run multiple times)

Race conditions:

  • Multiple workers processing same webhook simultaneously
  • ✅ Use database transactions
  • ✅ Implement row-level locking
  • ✅ Use unique constraints on webhook_id

Idempotency implementation:

// Check if already processed
const exists = await db.query(
  'SELECT 1 FROM processed_webhooks WHERE webhook_id = $1',
  [webhookId]
);

if (exists.rows.length > 0) {
  console.log('Already processed:', webhookId);
  return res.status(200).json({ duplicate: true });
}

// Mark as processing (atomic operation)
await db.query(
  `INSERT INTO processed_webhooks (webhook_id, status, created_at)
   VALUES ($1, 'processing', NOW())
   ON CONFLICT (webhook_id) DO NOTHING`,
  [webhookId]
);

Issue 4: Missing Webhooks

Symptoms:

  • Expected webhooks not arriving
  • Shopify logs show successful delivery but endpoint never hit
  • Event occurred in shop but no webhook received

Causes & Solutions:

Firewall blocking:

  • Server firewall blocking Shopify's IP ranges
  • ✅ Whitelist Shopify's IPs (check Shopify documentation for current ranges)
  • ✅ Verify port 443 (HTTPS) is open
  • ✅ Check cloud provider security groups (AWS, GCP, Azure)

Wrong URL:

  • Typo in webhook subscription URL
  • URL changed after webhook created
  • ✅ List webhooks via API to verify URLs
  • ✅ Test endpoint with curl or Postman
  • ✅ Check for trailing slashes or path mismatches

SSL certificate issues:

  • Expired or invalid SSL certificate
  • Self-signed certificate (not accepted by Shopify)
  • ✅ Verify certificate validity: https://www.sslshopper.com/ssl-checker.html
  • ✅ Use Let's Encrypt or commercial CA
  • ✅ Enable automatic certificate renewal

Webhook subscription removed:

  • After 8 failed delivery attempts, Shopify auto-removes subscription
  • ✅ Check webhook subscriptions regularly via API
  • ✅ Implement monitoring to detect removed webhooks
  • ✅ Automatically recreate subscriptions if missing

Event not subscribed:

  • Webhook subscription doesn't include the event type
  • ✅ Verify subscribed topics: GET /admin/api/2025-01/webhooks.json
  • ✅ Create subscriptions for all needed events

Verification script:

// List all webhook subscriptions
const response = await fetch(
  `https://${shop}.myshopify.com/admin/api/2025-01/webhooks.json`,
  {
    headers: {
      'X-Shopify-Access-Token': accessToken
    }
  }
);

const webhooks = await response.json();
console.log('Active webhooks:', webhooks.webhooks.length);

webhooks.webhooks.forEach(wh => {
  console.log(`- ${wh.topic} → ${wh.address}`);
});

Issue 5: GDPR Webhook Failures

Symptoms:

  • App review rejection citing missing GDPR webhooks
  • Compliance warnings in Shopify Partner Dashboard

Causes & Solutions:

Not implementing mandatory webhooks:

  • Missing customers/data_request, customers/redact, or shop/redact
  • ✅ Implement all three mandatory GDPR webhooks
  • ✅ Return 200 status even if no action taken
  • ✅ Log requests for audit trail

Incomplete implementation:

  • Webhook accepts request but doesn't actually delete data
  • customers/data_request: Provide customer data export within 30 days
  • customers/redact: Delete all customer PII within 30 days
  • shop/redact: Delete all shop data within 48 hours after app uninstall

Example implementation:

// Customer data request
app.post('/webhooks/shopify/gdpr/customers_data_request', async (req, res) => {
  verifySignature(req);
  const { customer, shop_domain } = JSON.parse(req.body);

  res.status(200).send('Request received');

  // Queue job to gather and send customer data
  await gatherCustomerData(customer.id, shop_domain);
});

// Customer redaction
app.post('/webhooks/shopify/gdpr/customers_redact', async (req, res) => {
  verifySignature(req);
  const { customer, shop_domain } = JSON.parse(req.body);

  res.status(200).send('Request received');

  // Queue job to delete customer data
  await deleteCustomerData(customer.id, shop_domain);
});

// Shop redaction
app.post('/webhooks/shopify/gdpr/shop_redact', async (req, res) => {
  verifySignature(req);
  const { shop_domain } = JSON.parse(req.body);

  res.status(200).send('Request received');

  // Queue job to delete all shop data
  await deleteShopData(shop_domain);
});

Debugging Checklist

When troubleshooting webhook issues, work through this checklist:

Webhook Delivery:

  • Check Shopify webhook delivery logs in Partner Dashboard
  • Verify webhook subscription exists via API
  • Test endpoint is publicly accessible (use curl from external server)
  • Verify SSL certificate is valid and not expired
  • Check server firewall and security group rules

Signature Verification:

  • Confirm using correct client secret (not API key)
  • Verify using raw body before JSON parsing
  • Check HMAC algorithm is SHA256
  • Ensure base64 encoding (not hex)
  • Use timing-safe comparison function

Performance:

  • Measure endpoint response time (<5 seconds required)
  • Verify 200 response sent before processing
  • Check queue system is working
  • Monitor database query times

Testing:

  • Test locally with Webhook Payload Generator
  • Verify signature verification with known-good payload
  • Test idempotency by sending duplicate webhook IDs
  • Simulate failures to test error handling

Frequently Asked Questions

Q: How often does Shopify send webhooks?

A: Shopify sends webhooks immediately when events occur, typically within seconds. There's no batching or delay. If delivery fails, Shopify retries 8 times over 4 hours using exponential backoff (approximately: 1 min, 5 min, 15 min, 30 min, 1 hour, 2 hours, 4 hours). After all retries fail, the webhook subscription is automatically removed.

Q: Can I receive webhooks for past events?

A: No, Shopify webhooks only deliver events that occur after the webhook subscription is created. You cannot receive webhooks for historical events. To get past data, use Shopify's REST or GraphQL Admin APIs to query historical orders, products, customers, etc. Implement a one-time sync when setting up webhooks to catch up on existing data.

Q: What happens if my endpoint is down?

A: Shopify will retry failed webhook deliveries 8 times over 4 hours with exponential backoff. After the final retry fails, Shopify automatically removes the webhook subscription. You'll need to recreate the subscription once your endpoint is back online. Implement monitoring to detect removed subscriptions and automatically recreate them. Use the API to periodically verify your webhook subscriptions still exist.

Q: Do I need different endpoints for test and production?

A: Yes, it's highly recommended to use separate webhook URLs for development stores and production stores. Each app installation (development or production) has its own unique client secret, so you'll need different secrets for signature verification. This prevents test data from mixing with production data and allows safe testing without affecting live operations.

Q: How do I handle webhook ordering?

A: Shopify does NOT guarantee webhooks will arrive in chronological order. Due to retries and network conditions, you might receive orders/fulfilled before orders/paid. Best practice: Use timestamp fields (created_at, updated_at) in the payload to determine actual event sequence. Design all webhook handlers to be idempotent and handle events correctly regardless of order. Never assume webhooks arrive in sequence.

Q: Can I filter which events I receive?

A: Yes, webhook subscriptions are topic-specific—you only receive events for the topics you explicitly subscribe to. When creating a webhook, specify the exact topic (e.g., orders/create, not all order events). You can also use the fields parameter to receive only specific fields from resources, reducing payload size. Create multiple webhook subscriptions if you need different processing logic for different events.

Q: Do webhooks work in development mode?

A: Yes, webhooks work with Shopify development stores just like production stores. However, your local localhost isn't publicly accessible, so you'll need to use ngrok, localtunnel, or similar tools to expose your local endpoint. Alternatively, use our Webhook Payload Generator to test webhook processing logic without needing a public URL during development.

Q: How do I test webhook signature verification?

A: Use our Webhook Payload Generator to create test webhooks with valid HMAC-SHA256 signatures. Enter your app's client secret, select "Shopify" as the provider, choose an event type, customize the payload, and generate a properly signed webhook. Send the generated webhook to your local endpoint to verify your signature verification logic works correctly before deploying.

Q: What's the difference between REST and GraphQL webhooks?

A: As of April 1, 2025, new public apps must use GraphQL Admin API for webhook management. REST webhooks are legacy but still supported. The webhook delivery format (JSON payload with HMAC signature) is the same regardless of how you created the subscription. GraphQL offers better webhook management features like batched operations and precise field selection. Existing REST webhooks continue working.

Q: Are there rate limits for webhooks?

A: Shopify doesn't rate limit webhook deliveries TO your endpoint—you'll receive webhooks as fast as events occur in the shop. However, there are rate limits for CREATING/MANAGING webhooks via the API: 2 requests/second for REST API and 1000 points per 60 seconds for GraphQL. Once webhooks are set up, there's no limit on incoming webhook volume. Ensure your infrastructure can handle high-volume shops (thousands of orders per hour during sales).

Next Steps & Resources

Ready to implement Shopify webhooks? Here's your action plan:

Try It Yourself:

  1. Set up your first webhook - Create a webhook subscription for orders/create using the code examples in this guide
  2. Test with our generator - Visit the Webhook Payload Generator, select Shopify, and generate test payloads with valid signatures
  3. Implement signature verification - Use the Node.js, Python, or PHP examples to verify HMAC-SHA256 signatures
  4. Deploy to production - Move from ngrok/local testing to a production HTTPS endpoint with proper error handling and queuing

Additional Resources:

Related Guides:

Need Help?

  • Test your integration: Use our Webhook Payload Generator to create realistic test data
  • Shopify Community: Ask questions in the Shopify Community Forums
  • Partner Support: Contact Shopify Partner Support if you encounter API issues
  • Implementation questions: Comment below or contact our team for webhook integration assistance

Developer Tools:

  • Shopify CLI - Command-line tool for app development with automatic webhook configuration
  • ngrok - Expose local development servers for webhook testing
  • Postman - Test webhook endpoints and API calls
  • Shopify App Bridge - Build embedded apps with webhook integration

Conclusion

Shopify webhooks provide a powerful, efficient way to build real-time e-commerce integrations without constantly polling the API. By following this guide, you now know how to:

Set up Shopify webhooks - Create subscriptions via REST API, GraphQL, or Shopify CLI with proper event topics and field selection

Verify HMAC-SHA256 signatures - Implement secure signature verification to authenticate webhook origins and prevent spoofing attacks

Implement production-ready endpoints - Build webhook handlers that respond within 5 seconds, process asynchronously, and handle retries gracefully

Handle common issues - Troubleshoot signature verification failures, timeout problems, duplicate events, and missing webhooks

Test effectively - Use ngrok for local testing and our Webhook Payload Generator to create properly signed test webhooks

Remember the key principles:

  1. Always verify signatures - Use HMAC-SHA256 verification with timing-safe comparison on every webhook to ensure security
  2. Respond within 5 seconds - Return 200 status immediately and process webhooks asynchronously in background queues
  3. Process asynchronously - Use queue systems (Bull, RabbitMQ, SQS) to handle time-consuming operations after responding
  4. Implement idempotency - Track webhook IDs in your database to prevent duplicate processing when Shopify retries

Start building with Shopify webhooks today:

Whether you're automating order fulfillment, syncing inventory across multiple sales channels, powering personalized customer experiences, or building analytics dashboards, Shopify webhooks give you real-time access to every event in your merchants' stores.

Use our Webhook Payload Generator to test your implementation with realistic, properly signed webhook payloads before deploying to production. Select Shopify as the provider, choose your event type, customize the payload to match your test scenarios, and generate webhooks with valid HMAC-SHA256 signatures.

Have questions or run into issues? Drop a comment below, check the Shopify Community Forums, or contact us for help with your webhook integration.


Sources:

Let's turn this knowledge into action

Get a free 30-minute consultation with our experts. We'll help you apply these insights to your specific situation.