WooCommerce Webhooks: How to Create, Verify and Debug Them
Webhooks let WooCommerce push changes to your application the moment they happen, instead of your app polling the REST API. This guide covers creating webhooks, verifying their signatures, and debugging deliveries that fail or get disabled.
Table of Contents
- Creating a webhook
- Topics
- What a delivery looks like
- Verifying the signature
- Managing webhooks via the REST API
- Debugging and auto-disable
Creating a Webhook
- Go to WooCommerce → Settings → Advanced → Webhooks and click Add webhook.
- Set a Name, Status (Active, Paused or Disabled) and Topic.
- Enter the Delivery URL – an HTTPS endpoint in your app.
- Set a Secret; it is used to sign every payload. Store the same value in your app.
- Leave API version on the current WP REST API integration version (v3) so payloads match
wc/v3responses.
When you save an active webhook, WooCommerce sends a "ping" POST to the delivery URL with a form-encoded body webhook_id=<id>. Your endpoint should answer it with HTTP 200, otherwise WooCommerce reports an error.
Topics
| Resource | Topics |
|---|---|
| Orders | order.created, order.updated, order.deleted, order.restored |
| Products | product.created, product.updated, product.deleted, product.restored |
| Customers | customer.created, customer.updated, customer.deleted |
| Coupons | coupon.created, coupon.updated, coupon.deleted, coupon.restored |
| Custom | action.<hook_name> – fires on any WordPress action, e.g. action.woocommerce_order_status_completed |
order.updated fires on many internal saves, so expect several deliveries for one checkout. Make your handler idempotent (for example by storing the last processed date_modified per order).
What a Delivery Looks Like
Each delivery is a POST with a JSON body equal to the resource's REST API representation, and these headers:
X-WC-Webhook-Source– the store URLX-WC-Webhook-Topic– e.g.order.updatedX-WC-Webhook-ResourceandX-WC-Webhook-Event– e.g.order/updatedX-WC-Webhook-Signature– base64-encoded HMAC-SHA256 of the raw body, keyed with the secretX-WC-Webhook-IDandX-WC-Webhook-Delivery-ID
The User-Agent is WooCommerce/<version> Hookshot (WordPress/<version>). Deliveries are queued through Action Scheduler by default, so they arrive shortly after the event rather than inside the customer's request.
Verifying the Signature
Always compute the HMAC over the raw request body – re-serialising parsed JSON changes the bytes and breaks the comparison.
Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
app.post('/hooks/woocommerce', express.raw({ type: '*/*' }), (req, res) => {
const raw = req.body; // Buffer
// The ping sent when the webhook is saved is form-encoded and unsigned
if (raw.toString('utf8').startsWith('webhook_id=')) return res.sendStatus(200);
const received = Buffer.from(req.get('X-WC-Webhook-Signature') || '');
const expected = Buffer.from(
crypto.createHmac('sha256', process.env.WC_WEBHOOK_SECRET).update(raw).digest('base64')
);
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.sendStatus(401);
}
const payload = JSON.parse(raw.toString('utf8'));
const topic = req.get('X-WC-Webhook-Topic');
// enqueue work here; respond fast
console.log(topic, payload.id);
res.sendStatus(200);
});
app.listen(3000);
PHP
<?php
$raw = file_get_contents( 'php://input' );
$received = $_SERVER['HTTP_X_WC_WEBHOOK_SIGNATURE'] ?? '';
$expected = base64_encode( hash_hmac( 'sha256', $raw, getenv( 'WC_WEBHOOK_SECRET' ), true ) );
if ( str_starts_with( $raw, 'webhook_id=' ) ) { // ping on save
http_response_code( 200 );
exit;
}
if ( ! hash_equals( $expected, $received ) ) {
http_response_code( 401 );
exit;
}
$payload = json_decode( $raw, true );
// queue the work, then:
http_response_code( 200 );
Managing Webhooks via the REST API
Integrations often register their own webhooks on install. This needs a key with Write (or Read/Write) permission:
curl -X POST https://example.com/wp-json/wc/v3/webhooks -u ck_xxx:cs_xxx \
-H "Content-Type: application/json" \
-d '{
"name": "Order updated -> ERP",
"topic": "order.updated",
"delivery_url": "https://erp.example.com/hooks/woocommerce",
"secret": "a-long-random-string",
"status": "active"
}'
A read-only key gets "The API key provided does not have write permissions." List existing webhooks with GET /wc/v3/webhooks and avoid creating duplicates on every deploy.
Debugging and Auto-Disable
- Logs: WooCommerce → Status → Logs, source
webhooks-delivery, shows each request and response (enable logging if it is off). - Scheduled actions: Tools → Scheduled Actions, search for
woocommerce_deliver_webhook_async. If WP-Cron does not run (low-traffic or cron disabled without a real cron job), deliveries pile up as pending. - Auto-disable: WooCommerce counts consecutive failed deliveries (any non-2xx response or timeout). When the count exceeds 5 (filter
woocommerce_max_webhook_delivery_failures), the webhook's status is set to Disabled. Fix the endpoint, then set it back to Active. - Timeouts: respond within a few seconds and do heavy work asynchronously.
- Firewalls: make sure your WAF does not block the Hookshot user agent or the store's IP.
See also: REST API authentication and wc/v3 endpoints reference.