Shopify Webhooks 101: Subscribe, Verify, Respond

Posted November 17, 2025 by Karol Polakowski

Webhooks are the backbone of event-driven integrations with Shopify. This guide covers the full lifecycle: subscribing to events, securely verifying incoming webhook requests, and responding in a way that prevents duplicate processing and scales with your app.


How Shopify webhooks work

Shopify sends HTTP POST requests to your endpoint when events occur (orders/create, products/update, app/uninstalled, etc.). Each request includes headers you should use for verification and troubleshooting, notably:

  • x-shopify-topic — the event topic
  • x-shopify-hmac-sha256 — the HMAC signature of the request body (base64)
  • x-shopify-shop-domain — the shop that triggered the event
  • x-shopify-api-version — API version

Shopify expects a 200 OK (or 2xx) response for successful deliveries. If Shopify receives a non-2xx response, it will retry delivery according to its retry schedule.

Subscribe: creating webhook subscriptions

You can register webhooks via the Admin REST API or GraphQL. You can also create them manually in the Shopify Admin (Apps > Manage private apps or via the App Bridge flow for public apps).

REST API example (server-side)

Use your app’s Admin access token to POST a webhook subscription. Replace SHOP, ACCESS_TOKEN, and your callback URL.

const fetch = require('node-fetch');

const SHOP = 'your-shop.myshopify.com';
const ACCESS_TOKEN = 'shpat_...';
const CALLBACK_URL = 'https://your-app.example.com/webhooks/shopify';

async function createWebhook() {
  const url = `https://` + SHOP + `/admin/api/2025-01/webhooks.json`;

  const body = {
    webhook: {
      topic: 'orders/create',
      address: CALLBACK_URL,
      format: 'json'
    }
  };

  const res = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Shopify-Access-Token': ACCESS_TOKEN
    },
    body: JSON.stringify(body)
  });

  if (!res.ok) throw new Error(`Failed to create webhook: ${res.status}`);
  const data = await res.json();
  return data;
}

createWebhook().then(console.log).catch(console.error);

GraphQL example (server-side)

GraphQL subscriptions use the webhookSubscriptionCreate mutation. This example omits full auth plumbing.

const query = `mutation webhookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $callbackUrl: URL!) { webhookSubscriptionCreate(topic: $topic, webhookSubscription: { callbackUrl: $callbackUrl }) { userErrors { field message } webhookSubscription { id } } }`;

// POST to https://{shop}/admin/api/2025-01/graphql.json with X-Shopify-Access-Token

Verify: authenticate incoming webhooks

Shopify signs webhook payloads using your app’s shared secret. Verification ensures requests actually came from Shopify and prevents replayed/forged requests.

The verification process:

  1. Compute the HMAC-SHA256 of the raw request body using your app’s shared secret (not the access token).
  2. Base64-encode the digest.
  3. Compare the result to the value of the x-shopify-hmac-sha256 header.
  4. Use a constant-time comparison to avoid timing attacks.

Node (Express) verification example

This example uses the bodyParser.raw middleware to preserve the raw body needed to compute the HMAC.

const express = require('express');
const crypto = require('crypto');
const bodyParser = require('body-parser');

const app = express();
const SHOPIFY_SECRET = process.env.SHOPIFY_SECRET;

// Use raw bodies for HMAC verification
app.post('/webhooks/shopify', bodyParser.raw({ type: 'application/json' }), (req, res) => {
  const hmacHeader = req.get('x-shopify-hmac-sha256');
  const computed = crypto.createHmac('sha256', SHOPIFY_SECRET).update(req.body).digest('base64');

  // constant-time comparison
  const valid = crypto.timingSafeEqual(Buffer.from(computed, 'utf8'), Buffer.from(hmacHeader || '', 'utf8'));

  if (!valid) {
    console.warn('Invalid webhook HMAC');
    return res.status(401).send('Unauthorized');
  }

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

  // Enqueue or process asynchronously
  enqueueWebhookProcessing(payload);

  // Acknowledge quickly
  res.status(200).send('OK');
});

function enqueueWebhookProcessing(payload) {
  // push to a job queue (Redis, RabbitMQ, etc.) for background processing
  console.log('Enqueue:', payload);
}

app.listen(3000);

Notes:

  • You must compute the HMAC over the raw bytes of the request body, not the parsed object.
  • If you use frameworks or middleware that parse the body before you can access raw data, configure them to expose raw bodies or use a raw parser for webhook routes.

Python (Flask) verification example

import hmac
import hashlib
import base64
from flask import Flask, request, abort

app = Flask(__name__)
SHOPIFY_SECRET = b"your_shopify_shared_secret"

@app.route('/webhooks/shopify', methods=['POST'])
def webhook():
    raw_body = request.get_data()
    hmac_header = request.headers.get('X-Shopify-Hmac-Sha256')

    digest = hmac.new(SHOPIFY_SECRET, raw_body, hashlib.sha256).digest()
    computed = base64.b64encode(digest).decode()

    # constant-time comparison
    if not hmac.compare_digest(computed, hmac_header):
        abort(401)

    payload = request.get_json()
    enqueue_webhook_processing(payload)
    return '', 200

def enqueue_webhook_processing(payload):
    print('Enqueue:', payload)

if __name__ == '__main__':
    app.run(port=3000)

Respond: safe, reliable handling and best practices

  • Acknowledge quickly: Return a 200 (or other 2xx) as soon as possible. Shopify expects a quick ack; long-running synchronous processing may cause timeouts and retries.
  • Use a durable job queue: Enqueue work for background workers (Redis + Bull/Sidekiq, SQS, etc.).
  • Idempotency: Webhooks can be delivered more than once. Make processing idempotent by checking resource versions, storing processed event ids, or using deduplication keys.
  • Handle retries and errors: Shopify will retry failed deliveries. Don’t return 2xx for invalid requests; instead return 401 for signature failures, 410 for permanently rejected topics, and 500 for transient failures.
  • Logging and observability: Log headers like x-shopify-topic, x-shopify-shop-domain, and delivery attempts for debugging. Track latencies and error rates.
  • Validate payload shapes: Don’t trust that the payload exactly matches your expectations — validate required fields before processing.
  • Security hygiene: Use HTTPS for webhook endpoints, rotate secrets periodically, and limit endpoint exposure (e.g., only allow known Shopify IPs if practical).

Example: minimal enqueue pattern (pseudo)

// inside verified webhook handler
res.status(200).send('OK');

// then asynchronously
jobQueue.add({ topic: req.get('x-shopify-topic'), payload: JSON.parse(req.body.toString('utf8')), shop: req.get('x-shopify-shop-domain') });

Troubleshooting

  • 401 Unauthorized: Usually HMAC mismatch — ensure you use the raw body and the correct secret.
  • Duplicate processing: Ensure idempotency strategy in workers.
  • Missing fields: Shopify may change payload shapes between API versions — rely on documented fields or check the topic payload before using deeply nested properties.
  • Webhook not delivered: Check webhook registration, your app’s access token/permissions, and the endpoint URL (must be reachable via HTTPS).

Summary

  • Subscribe using the Admin REST or GraphQL APIs or via the Shopify Admin for manual setups.
  • Verify every incoming request using HMAC-SHA256 computed over the raw body with your app secret.
  • Acknowledge quickly and process asynchronously; design for retries and idempotency.

Following these rules will make your Shopify webhook integration secure, reliable, and production-ready.