All articles
E-commerce order and product events leaving a WordPress store and crossing a signed tunnel to a localhost webhook handler.
WooCommerceWordPresse-commerce webhookslocalhost

Test WooCommerce Webhooks on Localhost

To test WooCommerce webhooks on localhost, expose your local handler with an HTTPS tunnel, create a webhook under WooCommerce → Settings → Advanced → Webhooks, and verify X-WC-Webhook-Signature as a Base64 HMAC-SHA256 digest of the raw body. Trigger an order or product change in a safe test store, inspect the delivery, and iterate without deploying the receiver.

What WooCommerce sends and when

WooCommerce can notify a delivery URL when orders, products, coupons, or customers are created, updated, or deleted. Extensions can add topics, and developers can define custom topics. Each configured webhook has a name, status, topic, delivery URL, secret, and API version. The official WooCommerce webhook documentation describes creation, topics, delivery logs, and failure behavior.

A webhook is attached to a topic, not to every store mutation automatically. Choose the narrowest topic your integration needs. An order-created consumer should not also process every product update. This reduces personal data exposure, traffic, and accidental side effects during local testing.

Create a raw-body Express endpoint

WooCommerce's signature is calculated over the body it sends. Preserve those bytes until verification is complete. The signature header contains the Base64-encoded binary HMAC-SHA256 digest, not a hexadecimal string.

import express from 'express';
import crypto from 'node:crypto';

const app = express();

function validWooSignature(rawBody, supplied, secret) {
  if (!supplied || !secret) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(rawBody)
    .digest('base64');
  const a = Buffer.from(supplied);
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

app.post(
  '/webhooks/woocommerce',
  express.raw({ type: 'application/json', limit: '2mb' }),
  async (req, res) => {
    const supplied = req.get('x-wc-webhook-signature');
    if (!validWooSignature(req.body, supplied, process.env.WC_WEBHOOK_SECRET)) {
      return res.sendStatus(401);
    }

    const payload = JSON.parse(req.body.toString('utf8'));
    await webhookInbox.insertOnce({
      deliveryId: req.get('x-wc-webhook-delivery-id'),
      topic: req.get('x-wc-webhook-topic'),
      payload,
    });
    return res.sendStatus(202);
  },
);

app.use(express.json());
app.listen(3000);

The route-specific raw parser must run before a global JSON parser. If middleware parses the body first, re-stringifying the object may change whitespace or escaping and invalidate the digest. This is the same raw-body rule covered in the webhook signature guide, but WooCommerce specifically uses Base64 output.

Start the HTTPS tunnel

  1. Start your receiver and confirm it listens on http://localhost:3000.
  2. Run npx portpreview 3000 in a second terminal.
  3. Copy the public HTTPS URL and append /webhooks/woocommerce.
  4. Keep the process running while WordPress sends its initial ping and topic deliveries.

The WordPress host—not the browser where you opened wp-admin—must be able to reach the public URL. A tunnel bridges that public request to your private development process. It also provides trusted TLS, so you do not need to expose a router port or install your own public certificate.

Configure the webhook in WooCommerce

  1. Open WooCommerce → Settings → Advanced → Webhooks.
  2. Select Add webhook and give it a recognizable local-development name.
  3. Choose Active status and a specific topic, such as Order created.
  4. Paste the full tunnel delivery URL.
  5. Generate a long random secret and place the identical value in WC_WEBHOOK_SECRET.
  6. Save the webhook, then trigger the topic in a test store.

When an active webhook is saved for the first time, WooCommerce sends a ping to the delivery URL. The ping confirms connectivity but is not a substitute for a real order payload. Make your endpoint tolerate the initial request and then create or update test data to exercise the selected topic.

export WC_WEBHOOK_SECRET="$(openssl rand -base64 48)"

If you paste a Base64 secret into an environment file, quote it so punctuation is preserved. The secret is the HMAC key; WooCommerce REST API consumer keys and WordPress passwords are unrelated credentials.

Use headers to route and trace deliveries

WooCommerce includes useful metadata headers. Depending on version and environment, these include the topic, resource, event, source, webhook ID, and delivery ID. Treat names case-insensitively as HTTP requires. Use the topic for dispatch and the delivery ID for traceability, but always authenticate the body first.

const handlers = {
  'order.created': handleOrderCreated,
  'order.updated': handleOrderUpdated,
  'product.updated': handleProductUpdated,
};

const handler = handlers[topic];
if (handler) await handler(payload);
else await recordUnsupportedTopic(topic);

Do not infer the topic only from the JSON shape. An order created and order updated payload can look similar, while the correct downstream action differs. Conversely, reject a header/topic combination your endpoint was never configured to accept.

Process order payloads defensively

Use immutable identifiers

Correlate records by store identity and WooCommerce object ID, not order number formatting, customer email, or display names. Two stores can both have order ID 42, so multi-store integrations need a compound key.

Expect extensions to alter fields

Payment, subscription, tax, checkout, and fulfillment extensions can add metadata and line-item fields. Validate the fields your business logic requires, ignore unknown fields, and save a schema version or minimal redacted fixture for regression tests.

Separate event receipt from fulfillment

A webhook saying an order changed should enter a durable queue or inbox. Inventory synchronization, shipping labels, ERP calls, and customer email should run after acknowledgement. This prevents a slow dependency from making WooCommerce interpret a successful receipt as a failed delivery.

Model updates as state transitions

An order may move through pending, processing, on-hold, completed, cancelled, refunded, or failed states. Updates can occur quickly and delivery order is not a safe substitute for comparing timestamps and current source state. Make repeated transitions harmless.

Idempotency is mandatory for commerce events

A timeout can happen after your receiver commits but before WooCommerce sees the response. Re-delivery then produces the same business action unless the handler is idempotent. Store the delivery ID where present. Also enforce domain-level uniqueness, such as one fulfillment request per store and order transition.

await db.transaction(async (tx) => {
  if (!(await tx.deliveries.claim(deliveryId))) return;
  await tx.orders.applyWooCommerceEvent(storeId, topic, payload);
  await tx.outbox.enqueueRequiredActions(storeId, topic, payload.id);
});

An inbox plus outbox transaction prevents both duplicate handling and lost follow-up work. See webhook retry and idempotency for the full pattern.

Use WooCommerce logs to debug the sender side

WooCommerce records webhook deliveries. Open WooCommerce → Status → Logs and filter for the webhook delivery source described in the official documentation. Compare the delivery URL, request time, response status, and response body with your local tunnel trace. Sender logs answer whether WordPress attempted the request; receiver logs answer what your application did with it.

Do not copy an unredacted order payload into a public issue. It can contain names, billing and shipping addresses, email, phone, product selections, and payment metadata. Reduce the fixture to fields required to reproduce the bug.

Troubleshoot common WooCommerce webhook failures

The webhook becomes disabled

WooCommerce automatically disables a webhook after more than five consecutive delivery failures. Responses outside 2xx, 301, or 302 count as failures according to the official guide. Fix the endpoint, reactivate the webhook, and send a controlled test. Avoid redirects anyway: they complicate signature debugging and can accidentally send signed customer data to an unintended host.

The signature always differs

Hash the exact raw body with the webhook's configured secret, request binary HMAC output, then Base64-encode it. In Node, that is .digest('base64'). Common mistakes are using hex, using a REST API secret, parsing JSON first, or including extra newline bytes.

The initial ping works but order events do not

Confirm the selected topic matches the action you triggered. Creating an order and changing an existing order are different topics. Verify status is Active, check WooCommerce logs, and ensure a plugin or staging cache is not preventing the underlying hook.

Local requests return 404

Check the full path, route method, and tunnel target port. WordPress must POST to /webhooks/woocommerce, not merely the tunnel origin. Framework middleware should not redirect the webhook to a localized or authenticated page.

Deliveries time out

Persist the authenticated event and return 200 or 202 promptly. Move remote API calls and heavy transformations to a worker. Check whether local breakpoints pause the request long enough to be classified as a failure.

Replaying a payload causes a 401

A captured request must retain the exact raw bytes and signature header. Editing JSON invalidates the original signature. For business-logic tests, use a sanitized fixture after the verification boundary; for end-to-end tests, generate a new HMAC with a dedicated test secret. Follow the safe replay workflow.

Security checklist for local store data

  • Test on a staging store with synthetic customers and products whenever possible.
  • Use a unique webhook secret for local development and rotate it after exposure.
  • Verify the signature before parsing, logging, or enqueuing the body.
  • Allowlist expected store source and topic after cryptographic verification.
  • Redact addresses, contact details, order notes, and payment metadata from captures.
  • Never disable TLS verification or expose wp-admin credentials to the receiver.

The local architecture should match production: HTTPS transport, raw-body authentication, durable acceptance, idempotent processing, fast response, and auditable failures. For another commerce provider with a different HMAC header, compare the Shopify local webhook guide.

Frequently asked questions

How do I test WooCommerce webhooks on localhost?
Expose your local POST route with an HTTPS tunnel, enter its public URL in WooCommerce webhook settings, configure the same secret on both sides, and trigger the selected topic.
How do I verify X-WC-Webhook-Signature?
Compute HMAC-SHA256 over the exact raw request body with the configured webhook secret, Base64-encode the binary digest, and compare it timing-safely with the header.
Why did WooCommerce disable my webhook?
WooCommerce disables a webhook after more than five consecutive delivery failures. Fix connection, timeout, or response errors, then reactivate it and test again.
Where can I see failed WooCommerce webhook deliveries?
Open WooCommerce → Status → Logs and filter for webhook delivery logs. Compare the recorded response with your tunnel and local application logs.