How to process WooCommerce webhooks and validate signature in PHP in WordPress

Contents

Introduction

This tutorial explains, in complete detail, how to process WooCommerce webhooks in WordPress and how to validate the webhook signature in PHP to ensure the request came from your WooCommerce store. It includes secure, production-ready code examples (plugin-style) using the WordPress REST API, explanations of the signature algorithm WooCommerce uses, common pitfalls, testing instructions, logging suggestions, and hardening tips.

Overview — how WooCommerce webhooks work

WooCommerce webhooks send an HTTP POST to a URL you configure in WooCommerce. The body is typically JSON containing the event payload (order.created, order.updated, product.updated, etc.). To ensure authenticity, WooCommerce signs the raw request body with HMAC-SHA256 using the webhook secret and includes that signature in the HTTP header X-WC-Webhook-Signature as a Base64-encoded value.

Validating that signature on receipt is essential: it prevents attackers from forging webhooks and allows your endpoint to reject unauthorized requests.

Prerequisites

  • WordPress installation with WooCommerce installed or access to the WooCommerce store settings to create webhooks.
  • Basic PHP and WordPress plugin or theme knowledge.
  • HTTPS on the endpoint (never run webhook endpoints over plain HTTP in production).

What WooCommerce sends

  • Request method: POST.
  • Content-Type: usually application/json.
  • Payload: JSON string (the raw request body is what must be signed and validated).
  • Header: X-WC-Webhook-Signature: Base64(HMAC-SHA256(raw_body, webhook_secret)).

Signature algorithm — exact steps

  1. Take the raw request body exactly as sent (byte-for-byte), not the JSON-decoded or re-encoded version.
  2. Compute HMAC using SHA256 with your webhook secret as the key. The binary raw output (not a hex string) should be used for Base64 encoding.
  3. Base64-encode the binary HMAC result.
  4. Compare the Base64 string with the header value X-WC-Webhook-Signature using a constant-time comparison (hash_equals) to avoid timing attacks.

Recommended endpoint integration: WordPress REST API route

Use the WordPress REST API to create a clean endpoint that receives webhooks. The REST route handler gives you direct access to the raw request body via request->get_body() and gives you the headers via request->get_header(x-wc-webhook-signature).

Complete minimal plugin example (REST route signature validation)

Drop the following code into a single file plugin (for example woocommerce-webhook-listener.php) and activate it. It registers a REST route at /wp-json/wc-webhook/v1/receive.

lt?php
/
Plugin Name: WooCommerce Webhook Listener (HMAC Validation)
Description: Example listener that validates WooCommerce webhook HMAC-SHA256 signatures.
Version: 1.0
Author: Example
/

if ( ! defined( ABSPATH ) ) {
    exit
}

/
  Change this to store or retrieve the secret securely.
  Options:
   - define(WC_WEBHOOK_SECRET, your-secret) in wp-config.php
   - use get_option() to read from database
   - use environment variable _ENV or getenv()
 /
if ( ! defined( WC_WEBHOOK_SECRET ) ) {
    // Example: define it in wp-config.php or replace this with your secure storage.
    define( WC_WEBHOOK_SECRET, replace_with_your_actual_webhook_secret )
}

/
  Register the REST endpoint.
 /
add_action( rest_api_init, function () {
    register_rest_route( wc-webhook/v1, /receive, array(
        methods  => POST,
        callback => wc_webhook_receive_callback,
        // Well validate signature in the callback you can also use permission_callback if desired.
        permission_callback => __return_true,
    ) )
} )

/
  REST callback: validate signature, decode JSON, process.
 
  @param WP_REST_Request request
  @return WP_REST_ResponseWP_Error
 /
function wc_webhook_receive_callback( request ) {
    // 1) Get raw body
    raw_body = request->get_body() // raw POST body exactly as received

    // 2) Get signature header
    signature_header = request->get_header( x-wc-webhook-signature )
    if ( empty( signature_header ) ) {
        error_log( Webhook: missing signature header )
        return new WP_REST_Response( array( error => Missing signature ), 401 )
    }

    // 3) Retrieve the secret securely
    secret = defined( WC_WEBHOOK_SECRET ) ? WC_WEBHOOK_SECRET : null
    if ( empty( secret ) ) {
        error_log( Webhook: no secret configured )
        return new WP_REST_Response( array( error => Server misconfiguration ), 500 )
    }

    // 4) Compute expected signature
    // Use raw_output = true to get binary result, then base64_encode it.
    hmac_binary   = hash_hmac( sha256, raw_body, secret, true )
    expected_sig   = base64_encode( hmac_binary )

    // 5) Compare using hash_equals to avoid timing attacks
    if ( ! hash_equals( expected_sig, signature_header ) ) {
        error_log( Webhook: invalid signature. Expected:  . expected_sig .  Received:  . signature_header )
        return new WP_REST_Response( array( error => Invalid signature ), 401 )
    }

    // 6) Signature validated — decode JSON and process
    data = json_decode( raw_body, true )
    if ( json_last_error() !== JSON_ERROR_NONE ) {
        error_log( Webhook: invalid JSON payload:  . json_last_error_msg() )
        return new WP_REST_Response( array( error => Invalid JSON ), 400 )
    }

    // Example: inspect topic if present and act accordingly
    // WooCommerce includes a topic property in webhook payloads
    topic = isset( data[topic] ) ? data[topic] : ( isset( data[webhook_topic] ) ? data[webhook_topic] : null )

    // Process the payload - use safe, idempotent actions
    // Example: if order.created -> process order
    if ( topic === order.created  ( isset( data[resource] )  isset( data[resource][type] )  data[resource][type] === order ) ) {
        // Do processing here, e.g. enqueue background job, update DB, notify external system, etc.
        // Keep processing short return quickly and offload long tasks to background via WP Cron or queue.
    }

    // Always respond with 200 OK when successfully accepted and validated
    return new WP_REST_Response( array( success => true ), 200 )
}

Detailed explanation of the code

  • request->get_body() returns the raw request payload. Use this exact string when calculating HMAC.
  • request->get_header(x-wc-webhook-signature) — gets the header sent by WooCommerce.
  • hash_hmac(sha256, raw_body, secret, true) — produce binary HMAC the true flag is crucial so that base64_encode produces the same Base64 string WooCommerce sent.
  • base64_encode() — WooCommerce encodes the binary HMAC into Base64.
  • hash_equals() — constant-time string comparison to mitigate timing attacks.

Alternative: simple standalone PHP endpoint (not REST API)

If you prefer a plain PHP endpoint (for example early in your theme or as an independent file), ensure WordPress is loaded and get the raw body from php://input. This example demonstrates the same validation algorithm but uses native PHP globals.

lt?php
// webhook-endpoint.php - ensure this file is placed in a safe location and that WP is loaded if you rely on WP functions.
// For standalone usage without WP functions, remove WP-specific dependency.

if ( php_sapi_name() === cli ) {
    die(Run from HTTP)
}

// Read raw body
raw_body = file_get_contents(php://input)

// Get headers in a portable manner
headers = function_exists(getallheaders) ? getallheaders() : []
signature_header = 
if ( isset(headers[X-WC-Webhook-Signature]) ) {
    signature_header = headers[X-WC-Webhook-Signature]
} elseif ( isset(headers[x-wc-webhook-signature]) ) {
    signature_header = headers[x-wc-webhook-signature]
} elseif ( isset(_SERVER[HTTP_X_WC_WEBHOOK_SIGNATURE]) ) {
    signature_header = _SERVER[HTTP_X_WC_WEBHOOK_SIGNATURE]
}

if ( empty(signature_header) ) {
    http_response_code(401)
    echo json_encode([error => Missing signature])
    exit
}

// Secret: get from secure place
secret = replace_with_your_secret

// Compute HMAC (binary), then base64
hmac_binary = hash_hmac(sha256, raw_body, secret, true)
expected_sig = base64_encode(hmac_binary)

// Constant-time compare
if ( ! hash_equals(expected_sig, signature_header) ) {
    error_log(Webhook invalid signature)
    http_response_code(401)
    echo json_encode([error => Invalid signature])
    exit
}

// Process payload
data = json_decode(raw_body, true)
if ( json_last_error() !== JSON_ERROR_NONE ) {
    http_response_code(400)
    echo json_encode([error => Invalid JSON])
    exit
}

// Successful
http_response_code(200)
echo json_encode([success => true])

Testing webhooks and verification locally

Use curl to simulate the webhook. Compute the signature with openssl on the client side, then include the header. Example using a JSON payload saved in payload.json:

# Compute base64 HMAC using secret mysecret and raw payload file payload.json
cat payload.json  openssl dgst -sha256 -binary -hmac mysecret  openssl base64 > sig.txt
SIG=(cat sig.txt)
curl -X POST https://example.com/wp-json/wc-webhook/v1/receive 
  -H Content-Type: application/json 
  -H X-WC-Webhook-Signature: SIG 
  --data-binary @payload.json

Note: the use of –data-binary ensures curl sends the exact payload bytes without transformations.

Common pitfalls and gotchas

  • Using JSON-decoded then re-encoded body for signature — do not do this. Parsing and re-encoding can change whitespace or key order and produce a different signature.
  • Using hex output from hash_hmac and then base64 — if you call hash_hmac(…, false) you get a hex string base64-encoding that will not match WooCommerce. Use the true flag to get raw binary HMAC, then base64_encode it.
  • Modifying the request body before validation — validate before any filters alter the payload.
  • Not using constant-time comparison — use hash_equals to avoid timing attacks.
  • Insecure storage of secrets — store webhook secret in wp-config.php, environment variable, or in WP options with proper protections. Do not hardcode in publicly readable files.
  • Assuming IP addresses or user agents to validate source — WooCommerce does not publish a fixed IP for webhooks rely on HMAC instead of IP allowlists unless you have a controlled, static outbound IP.
  • Large synchronous processing — return 200 quickly and offload intensive work to background jobs (WP Cron, Action Scheduler, queue workers).

Error handling, logging, and responses

  • Return 200 status only when you have accepted and validated the webhook (and optionally enqueued processing). If validation fails, return 401 or 400 as appropriate.
  • Log invalid signatures, missing headers, or JSON errors to error_log or a dedicated logging mechanism (preferably not in publicly browsable files).
  • Respond quickly if WooCommerce does not receive a 200 it will retry delivery (be mindful of idempotency).

Security best practices and hardening

  • Always use HTTPS.
  • Store the webhook secret out of the webroot or in environment variables or wp-config.php constants.
  • Rotate webhook secrets periodically. When rotating, create a new webhook in WooCommerce or update existing with new secret. If rotating, consider supporting two secrets for a window (check both old and new secret during rotation).
  • Use short processing flows in the webhook handler and offload heavy tasks to background workers or queues.
  • Use strict content-type checks (e.g., require application/json for payloads you accept).
  • Limit accepted HTTP methods to POST.
  • Rate-limit based on IP or other heuristics if you observe abuse (but ensure legitimate retries are still allowed).

Idempotency and retries

WooCommerce will retry webhooks on non-200 responses. Your handler must be idempotent or detect duplicate deliveries. Common methods:

  • Record processed webhook IDs (if present in payload) or resource IDs with timestamps.
  • Use a database table or transient to mark work as accepted to prevent duplicate processing.
  • Design processing operations to be idempotent (e.g., update status rather than append duplicate records).

Examples of extended processing

Once validated, typical actions include:

  • Enqueue a background job (Action Scheduler, WP Cron, or a queue worker) to process order data.
  • Send notifications to other services (via authenticated POST requests, using their own secrets).
  • Update an external CRM or ERP, ensuring retry and idempotency strategies are used.

Troubleshooting checklist

  1. Confirm the webhook secret in WooCommerce matches the secret your endpoint uses.
  2. Ensure you compute the HMAC over the raw request body exactly as received.
  3. Make sure to use binary output from hash_hmac (fourth parameter true), then base64_encode.
  4. Confirm the header name is exactly X-WC-Webhook-Signature (case-insensitive in practice, but retrieving through frameworks may vary).
  5. Check server logs for errors and mismatched expected vs received signatures.
  6. Use the curl example shown earlier to reproduce and debug locally.

Useful reference links

Summary checklist before going to production

  • Endpoint is HTTPS and reachable from WooCommerce.
  • Webhook secret is configured and kept secret.
  • Signature verification implemented exactly as described (raw body, HMAC-SHA256, binary, base64 encode, hash_equals).
  • Processing is idempotent and heavy work is queued for background processing.
  • Proper logging and monitoring are in place for invalid signatures and failures.
  • Responses use appropriate HTTP codes (200 on success, 4xx on client/auth errors, 5xx on server errors).

Appendix — Common code snippets

Compute signature locally (PHP)

// Given raw_body and secret
hmac_binary = hash_hmac(sha256, raw_body, secret, true)
base64_sig  = base64_encode(hmac_binary)

Example curl to send webhook (see earlier but repeated)

cat payload.json  openssl dgst -sha256 -binary -hmac mysecret  openssl base64 -A > sig.txt
SIG=(cat sig.txt)
curl -X POST https://example.com/wp-json/wc-webhook/v1/receive 
  -H Content-Type: application/json 
  -H X-WC-Webhook-Signature: SIG 
  --data-binary @payload.json

Final note

Following the guidance in this article ensures that your WordPress application will only accept legitimate WooCommerce webhook requests by properly validating the HMAC-SHA256 signature that WooCommerce attaches. Implement the validation early in your request handling, return quick responses, and move heavy processing to background jobs. Log mismatches and secure your secret storage to keep your system safe.



Acepto donaciones de BAT's mediante el navegador Brave 🙂



Leave a Reply

Your email address will not be published. Required fields are marked *