WooCommerce webhooks are how your store communicates with external systems. When a customer completes a payment, WooCommerce sends a webhook to your payment processor's endpoint. When inventory changes, a webhook might notify your fulfillment system. These asynchronous notifications power modern ecommerce integrations.
But here's the critical problem: webhooks are inherently insecure. A webhook arrives at your server claiming to be from WooCommerce or a payment processor, but how do you actually know it came from them and not an attacker? If you blindly process every webhook request you receive, attackers can trigger orders, refunds, or inventory changes by crafting fake webhooks.
This is where WooCommerce webhook security signature validation comes in. In this guide, I'll show you exactly how to implement secure webhook handling that verifies signatures, prevents replay attacks, and validates payloads correctly.
Table of Contents
- Understanding Webhook Security Risks
- HMAC Signature Validation
- Implementing Signature Verification
- Replay Attack Prevention
- Payload Validation and Integrity
- Building Secure Webhook Endpoints
- Testing Webhook Security
- Frequently Asked Questions
Understanding Webhook Security Risks
Webhooks are essentially HTTP POST requests sent from one system to another. Your server receives a request, deserializes the payload, and processes it. The vulnerability is that anyone can make POST requests to your webhook endpoint.
Webhooks are powerful because they enable real-time integrations. Instead of polling external systems to check for updates, webhooks deliver updates immediately. This eliminates delays and enables responsive systems. But this power comes with security responsibility. Unlike regular API calls where the client controls the request, webhooks are triggered by external systems and sent to endpoints you control but didn't initiate. This inversion of control creates unique security challenges.
The fundamental issue is authentication. When you make an API call to a service, you prove your identity using API keys or OAuth tokens. But when a service sends you a webhook, how does your server verify that the webhook actually came from that service and wasn't forged by an attacker? The webhook arrives at your endpoint with no client credentials, no OAuth token, no proof of identity.
Let me illustrate the attack scenario. Suppose you have a webhook endpoint at yourstore.com/webhook/order-confirmed that marks orders as paid. An attacker can simply send a POST request to that endpoint with a forged order confirmation payload, bypassing payment entirely.
POST /webhook/order-confirmed HTTP/1.1
Host: yourstore.com
Content-Type: application/json
{
"order_id": 12345,
"status": "confirmed",
"amount": 0.01
}
Without signature verification, your server processes this as a legitimate payment notification, even though it came from an attacker.
WooCommerce webhook security signature validation prevents this by ensuring that only requests signed with the correct secret key are processed. The payment processor (or WooCommerce itself) signs each webhook with a secret key that only they and you know. Your server verifies this signature before processing the webhook.
The attack surface for webhooks includes:
- Direct attack: Attacker sends fake webhook requests directly
- Man-in-the-middle attack: Attacker intercepts and modifies webhook requests
- Replay attack: Attacker replays an old webhook multiple times
- Event injection: Attacker injects fake events into the webhook stream
WooCommerce webhook security signature validation prevents the first and second attacks. Replay attack prevention prevents the third. Proper validation prevents the fourth.
HMAC Signature Validation
HMAC (Hash-Based Message Authentication Code) is the standard mechanism for signing webhooks. The process is straightforward:
- The sender (payment processor) computes a hash of the webhook payload using a secret key
- The sender includes this hash as a signature in the webhook headers
- Your server receives the webhook and recalculates the hash using the same secret key
- If your calculated hash matches the signature, the webhook is authentic
The security comes from the fact that only you and the payment processor know the secret key. An attacker can't forge a valid signature without knowing this key.
HMAC is elegant because it provides both authentication (proving the message came from the expected sender) and integrity (ensuring the message wasn't modified in transit). If an attacker modifies the webhook payload even slightly—changing an order amount by one cent—the signature will no longer match. This prevents man-in-the-middle attacks where an attacker intercepts webhooks and modifies them.
The choice of hash algorithm matters for security. SHA-256 is modern and strong. MD5 is deprecated and should never be used. SHA-1 is acceptable but aging out. Most payment processors and modern webhook systems use SHA-256, which is what you should implement.
// How HMAC signature validation works
$webhook_secret = 'your_secret_key';
$webhook_payload = json_encode( $webhook_data );
// Sender computes signature
$signature = hash_hmac( 'sha256', $webhook_payload, $webhook_secret );
// Result might be: "5d41402abc4b2a76b9719d911017c592"
// Your server receives the webhook and verifies it
$received_signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'];
$recalculated_signature = hash_hmac( 'sha256', $webhook_payload, $webhook_secret );
if ( $received_signature === $recalculated_signature ) {
// Webhook is authentic - process it
} else {
// Webhook is forged - reject it
http_response_code( 401 );
exit;
}
However, there's a critical security issue with naive string comparison: timing attacks. If you use standard string comparison operators like == or ===, attackers can exploit subtle timing differences to forge signatures.
// Vulnerable: Timing-safe comparison not used
if ( $received === $calculated ) { // WRONG!
process_webhook();
}
// Secure: Timing-safe comparison
if ( hash_equals( $received, $calculated ) ) {
process_webhook();
}
Here's why timing matters: if the comparison exits early when characters don't match, the time taken leaks information about the correct signature. By trying many signatures and measuring response time, an attacker can determine the correct signature character by character.
The hash_equals() function takes the same time to compare regardless of where differences occur, preventing this attack.
Different payment processors use slightly different signature mechanisms. Stripe uses the Stripe-Signature header with a specific format:
Stripe-Signature: t=1614556800,v1=5d41402abc4b2a76b9719d911017c592c
The t is a timestamp and v1 is the signature. You should verify both to prevent downgrade attacks.
// Stripe webhook signature verification
function verify_stripe_signature( $payload, $signature_header, $endpoint_secret ) {
if ( ! $signature_header ) {
return false;
}
// Parse the signature header
$signature_parts = array();
foreach ( explode( ',', $signature_header ) as $part ) {
list( $name, $value ) = explode( '=', $part, 2 );
$signature_parts[ trim( $name ) ] = trim( $value );
}
// Verify timestamp is within acceptable range (prevent replay)
$timestamp = intval( $signature_parts['t'] );
if ( abs( time() - $timestamp ) > 300 ) { // 5 minute window
return false;
}
// Recalculate signature
$signed_content = $timestamp . '.' . $payload;
$expected_signature = hash_hmac( 'sha256', $signed_content, $endpoint_secret );
// Use timing-safe comparison
return hash_equals( $signature_parts['v1'], $expected_signature );
}
Implementing Signature Verification
Let me show you a complete, production-ready implementation of WooCommerce webhook security signature validation.
The implementation process involves several critical decisions: where to register the endpoint, how to extract the raw payload for verification, how to handle signature errors, and how to prevent timing attacks. Each decision impacts security. A common mistake is registering the endpoint in a way that doesn't allow access to the raw payload body—WordPress REST API deserializes the body into JSON by default, and if you verify the signature on the deserialized version, it won't match the original signature because JSON serialization can change formatting (whitespace, key ordering, etc.).
Another critical decision is error handling. When signature verification fails, you should return a 401 Unauthorized response immediately and log the failure. Don't process invalid webhooks even partially. The more information you leak about why a webhook failed, the more attackers can learn about your verification implementation.
First, register a REST API endpoint for your webhooks:
// Register webhook endpoint
add_action( 'rest_api_init', function() {
register_rest_route( 'wc-webhook/v1', '/payment-confirmed', array(
'methods' => 'POST',
'callback' => 'handle_payment_webhook',
'permission_callback' => '__return_true', // We verify via signature
) );
} );
// Handle the webhook
function handle_payment_webhook( $request ) {
// Get raw payload for signature verification
$raw_payload = $request->get_raw_body();
$signature = $request->get_header( 'X-Webhook-Signature' );
$timestamp = $request->get_header( 'X-Webhook-Timestamp' );
// Verify signature
if ( ! verify_webhook_signature( $raw_payload, $signature, $timestamp ) ) {
return new WP_Error(
'invalid_signature',
'Webhook signature verification failed',
array( 'status' => 401 )
);
}
// Verify timestamp (prevent replay)
if ( ! verify_webhook_timestamp( $timestamp ) ) {
return new WP_Error(
'expired_timestamp',
'Webhook timestamp too old',
array( 'status' => 401 )
);
}
// Process the webhook
$data = json_decode( $raw_payload, true );
process_payment_webhook( $data );
return new WP_REST_Response( array( 'status' => 'received' ), 200 );
}
function verify_webhook_signature( $payload, $signature, $timestamp ) {
$webhook_secret = defined( 'WEBHOOK_SECRET' ) ? WEBHOOK_SECRET : '';
// Construct the signed content (same as sender did)
$signed_content = $payload . $timestamp;
// Compute expected signature
$expected_signature = hash_hmac( 'sha256', $signed_content, $webhook_secret );
// Use timing-safe comparison
return hash_equals( $signature, $expected_signature );
}
function verify_webhook_timestamp( $timestamp ) {
// Timestamp should be recent (within 5 minutes)
$current_time = time();
$provided_time = intval( $timestamp );
return abs( $current_time - $provided_time ) < 300;
}
function process_payment_webhook( $data ) {
$order_id = intval( $data['order_id'] );
$order = wc_get_order( $order_id );
if ( ! $order ) {
return false;
}
// Validate order data matches webhook data
if ( $order->get_total() != floatval( $data['amount'] ) ) {
error_log( 'Webhook amount mismatch for order ' . $order_id );
return false;
}
// Process the webhook
if ( $data['status'] === 'confirmed' ) {
$order->payment_complete( $data['transaction_id'] );
}
return true;
}
This implementation has several security layers:
- Signature verification prevents forged webhooks
- Timestamp verification prevents replay attacks
- Amount validation prevents processing incorrect amounts
- Proper error handling doesn't leak information about what failed
The critical points are:
- Use
$request->get_raw_body()not the parsed JSON for signature verification (the signature was calculated on the raw bytes) - Use
hash_equals()for timing-safe comparison - Verify timestamp to prevent old webhooks from being replayed
- Don't process the webhook if any verification fails
Replay Attack Prevention
A replay attack happens when an attacker intercepts a legitimate webhook and sends it again, causing the same transaction to be processed twice. This is especially dangerous with webhooks that create orders or process refunds.
Imagine an attacker recording a webhook that marks a payment as successful and processes a refund. They could replay this webhook multiple times, causing the same refund to be processed five times instead of once. The signature would be valid (it's a legitimate webhook), the timestamp would be valid (using the payment processor's original timestamp), but the result would be catastrophic.
The timestamp verification I showed above provides basic replay protection, but more sophisticated defenses are available. However, timestamp-only protection has limitations. An attacker who intercepts a webhook today can replay it tomorrow, next week, or next month. A 5-minute timestamp window prevents old webhooks from being replayed, but what if the attacker intercepts a webhook that was just sent?
The first defense is idempotency keys. An idempotency key is a unique identifier for the webhook event. If you receive the same webhook twice (with the same idempotency key), you process it only once.
// Idempotent webhook handling
function handle_payment_webhook( $request ) {
$data = json_decode( $request->get_raw_body(), true );
$event_id = $data['event_id']; // Unique event identifier
// Check if we've already processed this event
global $wpdb;
$already_processed = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}webhook_events WHERE event_id = %s",
$event_id
)
);
if ( $already_processed ) {
// Already processed - return success without processing again
return new WP_REST_Response(
array( 'status' => 'already_processed' ),
200
);
}
// Mark this event as processing
$wpdb->insert(
$wpdb->prefix . 'webhook_events',
array(
'event_id' => $event_id,
'processed_at' => current_time( 'mysql' ),
'status' => 'processing',
)
);
// Process the webhook
$result = process_payment_webhook( $data );
// Update status
$wpdb->update(
$wpdb->prefix . 'webhook_events',
array( 'status' => $result ? 'success' : 'failed' ),
array( 'event_id' => $event_id )
);
return new WP_REST_Response( array( 'status' => 'processed' ), 200 );
}
This uses a database table to track processed webhook events. If the same event is received twice, it's recognized as a duplicate and not processed again.
You should also implement exponential backoff and retry logic on your end. If your webhook endpoint returns an error, the payment processor should retry with increasing delays: 10 seconds, 100 seconds, 1000 seconds, etc. This prevents hammering your server with retries while still ensuring delivery.
// Example webhook retry with exponential backoff
class Webhook_Retry_Handler {
public static function schedule_retry( $webhook_id, $attempt = 1 ) {
$delay = 10 * pow( 10, $attempt - 1 ); // 10, 100, 1000 seconds
wp_schedule_single_event(
time() + $delay,
'webhook_retry',
array( $webhook_id, $attempt )
);
}
public static function retry_webhook( $webhook_id, $attempt ) {
$webhook = get_webhook( $webhook_id );
$response = wp_remote_post(
$webhook['endpoint'],
array(
'body' => $webhook['payload'],
'timeout' => 30,
)
);
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
// Failed - schedule another retry
if ( $attempt < 5 ) { // Max 5 attempts
self::schedule_retry( $webhook_id, $attempt + 1 );
}
} else {
// Success - mark as delivered
mark_webhook_delivered( $webhook_id );
}
}
}
add_action( 'webhook_retry', array( 'Webhook_Retry_Handler', 'retry_webhook' ), 10, 2 );
Payload Validation and Integrity
Beyond signature verification, you should also validate the webhook payload structure and content. A valid signature proves the webhook came from the payment processor, but it doesn't prove the payload makes sense.
This is an important distinction. Signature verification answers "Is this webhook from the expected sender?" Payload validation answers "Does this webhook contain sensible data?" Both are necessary. A signature-verified webhook with invalid payload (like an order amount that's negative or zero) should be rejected before processing.
Payload validation also provides defense against bugs and edge cases in the payment processor. If the payment processor sends a malformed webhook (missing fields, unexpected data types, corrupted data), your validation catches it before it crashes your order processing code. This makes your webhook handler more robust.
The validation should be specific to your business logic. Different integrations have different requirements. A payment webhook needs to validate order amounts and order IDs. An inventory webhook needs to validate product IDs and quantities. Think about what could go wrong and validate against it.
// Validate webhook payload
function validate_webhook_payload( $data ) {
$errors = array();
// Check required fields
$required_fields = array( 'event_id', 'order_id', 'status', 'amount' );
foreach ( $required_fields as $field ) {
if ( empty( $data[ $field ] ) ) {
$errors[] = "Missing required field: $field";
}
}
// Validate field types and formats
if ( ! is_numeric( $data['order_id'] ) ) {
$errors[] = 'order_id must be numeric';
}
if ( ! in_array( $data['status'], array( 'pending', 'confirmed', 'failed' ), true ) ) {
$errors[] = 'Invalid status value';
}
if ( ! is_numeric( $data['amount'] ) || $data['amount'] <= 0 ) {
$errors[] = 'amount must be a positive number';
}
// Validate against database records
$order = wc_get_order( intval( $data['order_id'] ) );
if ( ! $order ) {
$errors[] = 'Order not found';
} elseif ( abs( $order->get_total() - floatval( $data['amount'] ) ) > 0.01 ) {
$errors[] = 'Amount mismatch with stored order';
}
return $errors;
}
// Use validation before processing
$payload_errors = validate_webhook_payload( $data );
if ( ! empty( $payload_errors ) ) {
error_log( 'Webhook validation failed: ' . implode( '; ', $payload_errors ) );
return new WP_Error(
'invalid_payload',
'Webhook payload validation failed',
array( 'status' => 400 )
);
}
This validation ensures that:
- All required fields are present
- Field types match expectations
- Field values are within acceptable ranges
- The webhook data is consistent with your stored data
This prevents subtle attacks where valid signatures mask invalid payloads.
Building Secure Webhook Endpoints
Here's a complete, production-ready webhook endpoint implementation:
class Secure_Webhook_Endpoint {
private $webhook_secret;
private $max_webhook_age = 300; // 5 minutes in seconds
public function __construct( $webhook_secret ) {
$this->webhook_secret = $webhook_secret;
}
public function register() {
add_action( 'rest_api_init', array( $this, 'register_route' ) );
}
public function register_route() {
register_rest_route( 'wc-webhook/v1', '/payment-confirmed', array(
'methods' => 'POST',
'callback' => array( $this, 'handle' ),
'permission_callback' => '__return_true',
) );
}
public function handle( $request ) {
$raw_body = $request->get_raw_body();
$signature = $request->get_header( 'X-Webhook-Signature' );
$timestamp = $request->get_header( 'X-Webhook-Timestamp' );
$event_id = $request->get_header( 'X-Event-Id' );
// Step 1: Verify signature
if ( ! $this->verify_signature( $raw_body, $signature, $timestamp ) ) {
return $this->error_response(
'Invalid signature',
401
);
}
// Step 2: Check timestamp
if ( abs( time() - intval( $timestamp ) ) > $this->max_webhook_age ) {
return $this->error_response(
'Webhook expired',
401
);
}
// Step 3: Check for duplicates
if ( $this->event_already_processed( $event_id ) ) {
return $this->success_response( 'Event already processed' );
}
// Step 4: Parse and validate payload
$data = json_decode( $raw_body, true );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return $this->error_response(
'Invalid JSON',
400
);
}
$validation_errors = $this->validate_payload( $data );
if ( ! empty( $validation_errors ) ) {
error_log( 'Webhook validation failed: ' . implode( ', ', $validation_errors ) );
return $this->error_response(
'Payload validation failed',
400
);
}
// Step 5: Mark as processing
$this->mark_event_processing( $event_id );
// Step 6: Process webhook
try {
$result = $this->process_webhook( $data );
$this->mark_event_success( $event_id );
return $this->success_response( 'Webhook processed' );
} catch ( Exception $e ) {
error_log( 'Webhook processing error: ' . $e->getMessage() );
$this->mark_event_failed( $event_id );
return $this->error_response(
'Processing failed',
500
);
}
}
private function verify_signature( $payload, $signature, $timestamp ) {
$signed_content = $payload . $timestamp;
$expected = hash_hmac( 'sha256', $signed_content, $this->webhook_secret );
return hash_equals( $expected, $signature );
}
private function event_already_processed( $event_id ) {
global $wpdb;
return $wpdb->get_var(
$wpdb->prepare(
"SELECT COUNT(*) FROM {$wpdb->prefix}webhook_events WHERE event_id = %s",
$event_id
)
) > 0;
}
private function mark_event_processing( $event_id ) {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'webhook_events',
array(
'event_id' => $event_id,
'processed_at' => current_time( 'mysql' ),
'status' => 'processing',
)
);
}
private function mark_event_success( $event_id ) {
global $wpdb;
$wpdb->update(
$wpdb->prefix . 'webhook_events',
array( 'status' => 'success' ),
array( 'event_id' => $event_id )
);
}
private function mark_event_failed( $event_id ) {
global $wpdb;
$wpdb->update(
$wpdb->prefix . 'webhook_events',
array( 'status' => 'failed' ),
array( 'event_id' => $event_id )
);
}
private function validate_payload( $data ) {
// Implementation from previous section
return array();
}
private function process_webhook( $data ) {
$order_id = intval( $data['order_id'] );
$order = wc_get_order( $order_id );
if ( ! $order ) {
throw new Exception( 'Order not found' );
}
if ( $data['status'] === 'confirmed' ) {
$order->payment_complete( $data['transaction_id'] );
}
return true;
}
private function success_response( $message ) {
return new WP_REST_Response(
array( 'status' => 'success', 'message' => $message ),
200
);
}
private function error_response( $message, $status_code ) {
return new WP_REST_Response(
array( 'status' => 'error', 'message' => $message ),
$status_code
);
}
}
// Initialize
$webhook = new Secure_Webhook_Endpoint( WEBHOOK_SECRET );
$webhook->register();
This implementation follows best practices:
- Signature verification with timing-safe comparison
- Timestamp validation to prevent old webhooks
- Duplicate detection with idempotency keys
- Comprehensive payload validation
- Exception handling and error logging
- Proper HTTP status codes
Testing Webhook Security
Testing WooCommerce webhook security signature validation ensures your implementation actually works.
First, test that invalid signatures are rejected:
// Test invalid signature rejection
function test_invalid_signature_rejection() {
$endpoint_url = 'https://yourstore.com/wp-json/wc-webhook/v1/payment-confirmed';
$payload = json_encode( array(
'event_id' => 'evt_test_123',
'order_id' => 12345,
'status' => 'confirmed',
'amount' => 99.99,
) );
$timestamp = time();
$invalid_signature = 'invalid_signature_string';
$response = wp_remote_post(
$endpoint_url,
array(
'body' => $payload,
'headers' => array(
'X-Webhook-Signature' => $invalid_signature,
'X-Webhook-Timestamp' => $timestamp,
'X-Event-Id' => 'evt_test_123',
),
)
);
$status_code = wp_remote_retrieve_response_code( $response );
if ( $status_code === 401 ) {
echo "PASS: Invalid signature was rejected\n";
} else {
echo "FAIL: Invalid signature was accepted (status: $status_code)\n";
}
}
Next, test that valid signatures are accepted:
// Test valid signature acceptance
function test_valid_signature_acceptance() {
$webhook_secret = 'your_secret_key';
$endpoint_url = 'https://yourstore.com/wp-json/wc-webhook/v1/payment-confirmed';
$payload = json_encode( array(
'event_id' => 'evt_test_456',
'order_id' => 12346,
'status' => 'confirmed',
'amount' => 149.99,
) );
$timestamp = time();
$signed_content = $payload . $timestamp;
$valid_signature = hash_hmac( 'sha256', $signed_content, $webhook_secret );
$response = wp_remote_post(
$endpoint_url,
array(
'body' => $payload,
'headers' => array(
'X-Webhook-Signature' => $valid_signature,
'X-Webhook-Timestamp' => $timestamp,
'X-Event-Id' => 'evt_test_456',
),
)
);
$status_code = wp_remote_retrieve_response_code( $response );
if ( $status_code === 200 ) {
echo "PASS: Valid signature was accepted\n";
} else {
echo "FAIL: Valid signature was rejected (status: $status_code)\n";
}
}
Test replay prevention by sending the same event twice:
// Test replay attack prevention
function test_replay_attack_prevention() {
$webhook_secret = 'your_secret_key';
$endpoint_url = 'https://yourstore.com/wp-json/wc-webhook/v1/payment-confirmed';
$payload = json_encode( array(
'event_id' => 'evt_test_replay',
'order_id' => 12347,
'status' => 'confirmed',
'amount' => 199.99,
) );
$timestamp = time();
$signed_content = $payload . $timestamp;
$valid_signature = hash_hmac( 'sha256', $signed_content, $webhook_secret );
// Send first time
$response1 = wp_remote_post(
$endpoint_url,
array(
'body' => $payload,
'headers' => array(
'X-Webhook-Signature' => $valid_signature,
'X-Webhook-Timestamp' => $timestamp,
'X-Event-Id' => 'evt_test_replay',
),
)
);
// Send identical webhook again
$response2 = wp_remote_post(
$endpoint_url,
array(
'body' => $payload,
'headers' => array(
'X-Webhook-Signature' => $valid_signature,
'X-Webhook-Timestamp' => $timestamp,
'X-Event-Id' => 'evt_test_replay',
),
)
);
// Both should succeed, but the order should only be created once
$order_count = count_orders_by_event_id( 'evt_test_replay' );
if ( $order_count === 1 ) {
echo "PASS: Replay attack prevented - event processed only once\n";
} else {
echo "FAIL: Replay attack not prevented - event processed $order_count times\n";
}
}
Use WP HealthKit to automatically test your webhook security as part of regular security scans.
Additional Resources
For a comprehensive view of how WP HealthKit approaches plugin analysis, explore our 17 verification layers or browse the plugin directory to see real audit scores. Ready to check your own plugin? Run a free audit now.
Frequently Asked Questions
What if I don't have the webhook secret?
Every payment processor or webhook provider should provide you with a secret key. If they don't, don't use them—they're not following security best practices. Look for alternatives that provide signed webhooks.
Can I use HTTP instead of HTTPS for webhooks?
Absolutely not. Even with signature verification, webhooks over HTTP expose the payload to interception. Always use HTTPS. Many processors require HTTPS and will refuse to deliver webhooks to HTTP endpoints.
How long should webhook secrets be?
At least 32 bytes (256 bits) of random data. For HMAC-SHA256, this provides 128 bits of security strength. Longer is fine—64 bytes (512 bits) is even better.
What should I do if someone forges a webhook?
The signature verification will catch it. You'll see a 401 error. Check your logs for patterns of forged webhook attempts. If someone is targeting your endpoint, consider adding rate limiting.
How often should I rotate webhook secrets?
Rotate immediately if you suspect compromise. Otherwise, rotate every 90 days as good security practice. Have a process to generate a new secret, test it, deploy it, then revoke the old one.
Can payment processors see my webhook endpoint response?
Yes, they monitor your endpoint's response to ensure it's healthy. Always respond quickly—return success or failure immediately, don't do heavy processing in the webhook handler. Use queues for long-running tasks.
Conclusion
WooCommerce webhook security signature validation is non-negotiable. Without it, attackers can forge webhooks to create orders, refunds, or inventory changes. With proper signature verification, timestamp validation, and replay attack prevention, you create a secure foundation for webhook-based integrations.
The implementation pattern is consistent across all webhook providers:
- Verify signature using timing-safe comparison
- Check timestamp to prevent replay
- Check for duplicate events
- Validate payload structure and content
- Process only after all checks pass
Implement these patterns in every webhook handler you build. Use environment variables for webhook secrets, never commit them to version control. Rotate secrets regularly and monitor webhook delivery logs for failures.
Use WP HealthKit to continuously scan your webhook implementations for signature verification bypasses, missing validation, and other webhook security issues. Regular automated scanning helps catch vulnerabilities before they can be exploited.
Remember: webhooks are a direct connection between your store and external systems. Secure them with the same rigor you'd apply to your database layer. Your customers' data and transactions depend on it.