WooCommerce webhook signature mismatch (X-WC-Webhook-Signature): causes and fixes

WooCommerce signs each webhook delivery with the Secret you set on the webhook (WooCommerce → Settings → Advanced → Webhooks). The header X-WC-Webhook-Signature is the base64-encoded HMAC-SHA256 of the raw request body, keyed with that secret. Other headers tell you what arrived: X-WC-Webhook-Topic, X-WC-Webhook-Resource, X-WC-Webhook-Event, X-WC-Webhook-ID, X-WC-Webhook-Delivery-ID and X-WC-Webhook-Source.

Common causes

  1. Hashing parsed JSON. Re-serializing the body changes escaping (WooCommerce's PHP JSON escapes slashes as \/) and spacing. Hash the raw bytes.
  2. Hex instead of base64. Unlike Slack or GitHub, WooCommerce base64-encodes the digest. Compare base64 to base64.
  3. Wrong secret. Each webhook has its own secret. Open the webhook in the admin and copy the value in the Secret field; a REST API consumer secret is a different thing.
  4. The creation ping. When you save a new webhook, WooCommerce sends a test POST with a form body like webhook_id=123, not an order or product payload. Answer it with 200 instead of failing hard, or the webhook may not activate cleanly.
  5. Webhook got disabled. After repeated failed deliveries (5 by default, via the woocommerce_max_webhook_delivery_failures filter) WooCommerce sets the webhook status to Disabled. Fix the endpoint, then set it back to Active. Check WooCommerce → Status → Logs for the delivery log.
  6. Deliveries are queued. Webhooks are sent through Action Scheduler. If WP-Cron doesn't run (low-traffic site, DISABLE_WP_CRON), deliveries arrive late or not at all. Check Tools → Scheduled Actions for pending woocommerce_deliver_webhook_async actions.

Node (Express)

const crypto = require('crypto');
app.post('/webhooks/woocommerce', express.raw({ type: '*/*' }), (req, res) => {
  const sig = req.get('X-WC-Webhook-Signature');
  if (!sig) return res.sendStatus(200);                           // creation ping (webhook_id=...) - nothing to do
  const expected = crypto.createHmac('sha256', process.env.WC_WEBHOOK_SECRET).update(req.body).digest('base64');
  const a = Buffer.from(expected), b = Buffer.from(sig);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
  const topic = req.get('X-WC-Webhook-Topic');                    // e.g. order.created
  const data = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200);
});

Python (Flask)

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

@app.post('/webhooks/woocommerce')
def woo():
    sig = request.headers.get('X-WC-Webhook-Signature')
    if not sig: return '', 200                                   # creation ping
    raw = request.get_data()                                     # raw bytes
    expected = base64.b64encode(hmac.new(os.environ['WC_WEBHOOK_SECRET'].encode(), raw, hashlib.sha256).digest()).decode()
    if not hmac.compare_digest(expected, sig): abort(401)
    data = request.get_json()
    return '', 200

In FastAPI use raw = await request.body(). Only skip verification for a request with no signature header if you also ignore its contents; never process an order from an unsigned request.

The guide is Stripe-only. The kit is a self-hosted Cloudflare Worker relay that adds retries, replay and a dead-letter list in front of your endpoint. Its built-in schemes cover Stripe, Lemon Squeezy, Shopify, Paddle Billing, Square, Twilio (status callbacks) and GitHub. WooCommerce isn't one of them, so you would add a small verifier like the one above.

Fix Your Stripe Webhooks guide ($9) · Self-Hosted Webhook Relay Kit ($19)

Free download: Webhook debugging cheat sheet.

Fighting webhooks on more than one platform? The Fix Your Stripe Webhooks guide ($9) covers raw-body and signature problems in depth, and the Self-Hosted Webhook Relay Kit ($19) adds retries, replay and a dead-letter list in front of any endpoint. More fixes: all webhook error pages.