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:
- Serializes the relevant data into JSON format
- Computes an HMAC-SHA256 signature using your app's client secret
- Sends an HTTP POST request to your configured endpoint URL
- Includes special headers for verification and metadata
- 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.
Method 1: Using the Shopify Admin API (Recommended for Production)
Creating webhooks programmatically gives you full control and allows dynamic subscription management.
Step 1: Get Your API Credentials
- Log in to your Shopify Partner Dashboard at partners.shopify.com
- Navigate to Apps → Select your app
- Go to App setup → API credentials
- Note your API key and API secret key (also called client secret)
- 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 Topic | Description | Common Use Case |
|---|---|---|
orders/create | New order placed | Process orders, send confirmations |
orders/updated | Order modified | Update order status, sync changes |
orders/paid | Payment confirmed | Trigger fulfillment, update accounting |
orders/fulfilled | Order shipped | Send tracking info, update inventory |
orders/cancelled | Order cancelled | Process refunds, restock inventory |
products/create | New product added | Sync to external systems |
products/update | Product modified | Update listings on other platforms |
products/delete | Product removed | Remove from external catalogs |
customers/create | New customer account | Add to CRM, welcome email |
customers/update | Customer info changed | Sync profile updates |
inventory_levels/update | Stock quantity changed | Sync inventory, trigger restocking |
fulfillments/create | Fulfillment record created | Generate shipping labels |
fulfillments/update | Fulfillment status changed | Update tracking information |
carts/create | Shopping cart created | Track cart abandonment |
checkouts/create | Checkout initiated | Identify potential conversions |
app/uninstalled | App removed from store | Clean 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, voidedfulfillment_status- Shipping status: null (unfulfilled), partial, fulfilledline_items- Array of products ordered with quantities and pricingcustomer- Complete customer profile including email and addresscreated_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 identifiervariants- Array of product variations (size, color, etc.) with individual SKUs and pricinginventory_quantity- Current stock level for inventory managementhandle- URL-friendly product slugstatus- 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 identifieremail- Customer email (unique per store)verified_email- Whether customer confirmed their emailaccepts_marketing- Marketing consent statusorders_count/total_spent- Customer lifetime value metricsaddresses- 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 variantlocation_id- Warehouse or store location IDavailable- Current available quantity for saleupdated_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 ordertracking_number/tracking_url- Shipping tracking informationline_items- Which products were included in this shipmentstatus- 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
- Extract the signature from the
X-Shopify-Hmac-SHA256header - Retrieve your app's client secret from environment variables
- Compute expected signature using HMAC-SHA256 with raw body
- Encode result as base64
- Compare computed signature with received signature using timing-safe comparison
- 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()orhmac.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:
- Copy the HTTPS ngrok URL (e.g.,
https://abc123.ngrok.io) - Add
/webhooks/shopify/ordersto the end - Create webhook subscription with
https://abc123.ngrok.io/webhooks/shopify/orders - 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:4040to inspect webhook requests - Remember to update webhook URLs after each restart on free tier
Solution 2: Webhook Payload Generator Tool (Recommended)
For testing without external dependencies or before setting up your endpoint, use our Webhook Payload Generator.
How to use:
-
Visit the tool: Navigate to Webhook Payload Generator
-
Select Shopify: Choose "Shopify" from the provider dropdown
-
Choose event type: Select the webhook topic you want to test (e.g., "orders/create")
-
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
-
Enter your client secret: Provide your app's client secret for signature generation
-
Generate signed payload: The tool creates a properly formatted webhook with valid HMAC-SHA256 signature
-
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
- Raw body parsing - Critical for signature verification;
express.raw()preserves the exact bytes - Timing-safe comparison -
crypto.timingSafeEqual()prevents timing attack vulnerabilities - Idempotency check - Database lookup prevents duplicate processing if Shopify retries
- Queue-based processing - Bull/Redis queue enables fast response (<5s) with reliable background processing
- Error handling - Always returns 200 to prevent unnecessary Shopify retries; logs errors for investigation
- Comprehensive logging - Tracks webhook IDs, processing times, and errors for debugging
- 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-Domainmatches 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-Idheader 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
fieldsparameter 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-Versionheader - ✅ 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()orbody-parsermiddleware - 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
sha256in 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-Idin 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, orshop/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:
- Set up your first webhook - Create a webhook subscription for
orders/createusing the code examples in this guide - Test with our generator - Visit the Webhook Payload Generator, select Shopify, and generate test payloads with valid signatures
- Implement signature verification - Use the Node.js, Python, or PHP examples to verify HMAC-SHA256 signatures
- Deploy to production - Move from ngrok/local testing to a production HTTPS endpoint with proper error handling and queuing
Additional Resources:
- Shopify Webhooks Official Documentation - Comprehensive reference for all webhook topics
- Shopify Admin REST API Reference - REST API webhook endpoints
- Shopify GraphQL Admin API - Modern webhook management (required for new apps)
- Shopify Developer Changelog - Stay updated on webhook changes and new features
- Shopify API Status Page - Check for service issues if webhooks aren't delivering
Related Guides:
- Webhooks Explained: Complete Developer Guide - General webhook concepts and architecture
- Webhook Signature Verification Guide - Deep dive into HMAC signature security
- Stripe Webhooks Guide - Compare payment webhook implementations
- GitHub Webhooks Guide - Learn webhook patterns from another major platform
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:
- Always verify signatures - Use HMAC-SHA256 verification with timing-safe comparison on every webhook to ensure security
- Respond within 5 seconds - Return 200 status immediately and process webhooks asynchronously in background queues
- Process asynchronously - Use queue systems (Bull, RabbitMQ, SQS) to handle time-consuming operations after responding
- 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:
- Shopify Webhooks Official Documentation
- Shopify Webhook HMAC Verification Guide
- Implementing Shopify Order Webhooks - HulkApps
- How to Create Shopify Webhooks - Hookdeck
- Shopify Webhooks Complete Guide - Software Engineering Standard
- Shopify Webhook Best Practices
- Guide to Shopify Webhooks Features - Hookdeck
- Shopify Webhook Retry Mechanism Updates