How to Prevent Duplicate WooCommerce Webhook Processing in WordPress
Understanding WooCommerce Webhook Duplication
When building integrations for WooCommerce, webhook retries and duplicate events are common occurrences. Network delays, momentary server hiccups, or WooCommerce timeout thresholds (which default to 5 seconds) often cause WooCommerce to re-send the exact same webhook payload multiple times.
If two identical webhook POST requests arrive at your REST API endpoint at almost the exact same millisecond, simple application checks like if ( $db->has_processed($event_id) ) will fail. This creates a classic Time-of-Check to Time-of-Use (TOCTOU) race condition, where both requests check the database simultaneously, both see that the event hasn't been processed yet, and both proceed to execute actions—causing duplicate orders, double charges, or corrupt data.
The Key to Solved Duplication: Idempotency
To prevent duplicate execution, your webhook endpoint must be idempotent. An idempotent handler ensures that processing the exact same event multiple times yields the same result as processing it once, without duplicating side effects.
Below are the most reliable, WordPress-native approaches to handle concurrent duplicate webhooks safely.
Solution 1: Atomic Locking Using WordPress Options
The simplest way to handle concurrent requests in WordPress without needing custom database tables is to leverage the atomic nature of WordPress's add_option() function. Unlike update_option(), add_option() will fail and return false if the option key already exists in the database. MySQL handles this check atomically at the row-lock level.
add_action('rest_api_init', function () { register_rest_route('my-plugin/v1', '/webhook', [ 'methods' => 'POST', 'callback' => 'handle_webhook', 'permission_callback' => '__return_true', ]); }); function handle_webhook(WP_REST_Request $request) { $payload = $request->get_json_params(); $delivery_id = $request->get_header('x-wc-webhook-delivery-id'); // Fallback to payload ID or resource ID if header is missing $event_id = $delivery_id ?: ($payload['id'] ?? null); if (empty($event_id)) { return new WP_REST_Response(['error' => 'Missing event identifier'], 400); } // Attempt to create an atomic lock $lock_key = 'wc_lock_event_' . sanitize_key($event_id); $lock_acquired = add_option($lock_key, time(), '', 'no'); // autoload = 'no' if (!$lock_acquired) { // Lock exists: request is currently being processed or was already processed return new WP_REST_Response([ 'success' => true, 'message' => 'Duplicate webhook request ignored.' ], 200); } // Process your business logic try { process_order_event($payload); } catch (Exception $e) { // In case of processing error, delete lock so WooCommerce retry can attempt again delete_option($lock_key); return new WP_REST_Response(['error' => $e->getMessage()], 500); } return new WP_REST_Response(['success' => true], 200); }Solution 2: Custom Database Table with UNIQUE Key Constraints
If you prefer storing audit records in a custom database table, you must rely on database-level constraints (such as a UNIQUE index on the event/delivery ID column) rather than checking with SELECT queries before INSERT.
When creating your custom table, define the event ID column as a PRIMARY KEY or UNIQUE INDEX:
CREATE TABLE wp_webhook_events_log ( event_id VARCHAR(191) NOT NULL, processed_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (event_id) ) $charset_collate;In your PHP handler, execute an atomic insert using $wpdb->query() with INSERT IGNORE or handle duplicate key errors:
function handle_webhook_db_lock(WP_REST_Request $request) { global $wpdb; $payload = $request->get_json_params(); $delivery_id = $request->get_header('x-wc-webhook-delivery-id') ?: ($payload['id'] ?? null); if (!$delivery_id) { return new WP_REST_Response(['error' => 'Missing event ID'], 400); } $table_name = $wpdb->prefix . 'webhook_events_log'; // Atomic Insert: returns 1 if row inserted, 0 if row already existed $inserted = $wpdb->query( $wpdb->prepare( "INSERT IGNORE INTO {$table_name} (event_id, processed_at) VALUES (%s, NOW())", $delivery_id ) ); if ($inserted === 0) { // Row already exists - ignore duplicate execution return new WP_REST_Response(['message' => 'Duplicate event ignored'], 200); } // Proceed with processing process_order_event($payload); return new WP_REST_Response(['success' => true], 200); }Solution 3: Asynchronous Queueing with Action Scheduler
For heavy tasks, processing webhooks synchronously inside the REST endpoint can lead to timeouts. The best WooCommerce architectural practice is to accept the webhook immediately, queue it via WooCommerce's built-in Action Scheduler, and respond with a 200 OK or 202 Accepted status code immediately.
Action Scheduler automatically handles duplicate scheduling when configured with unique action parameters:
function handle_webhook_async(WP_REST_Request $request) { $payload = $request->get_json_params(); $delivery_id = $request->get_header('x-wc-webhook-delivery-id') ?: ($payload['id'] ?? null); if (!$delivery_id) { return new WP_REST_Response(['error' => 'Missing ID'], 400); } // Check if an action with these arguments is already pending/enqueued if (!as_has_scheduled_action('my_plugin_process_webhook_event', [$delivery_id, $payload])) { as_enqueue_async_action('my_plugin_process_webhook_event', [$delivery_id, $payload]); } return new WP_REST_Response(['success' => true, 'message' => 'Event queued'], 200); } // Action hook processor add_action('my_plugin_process_webhook_event', 'process_order_event_async', 10, 2); function process_order_event_async($delivery_id, $payload) { // Perform long-running order synchronization work here }Best Practice Checklist
- Always check headers first: WooCommerce provides unique delivery tracking via the HTTP header
X-WC-Webhook-Delivery-ID. - Always return 200 OK for duplicates: Do not return 4xx or 5xx HTTP codes when ignoring duplicates. Returning an error code will cause WooCommerce to flag the delivery as failed and keep retrying.
- Rely on MySQL Atomic Constraints: Never rely on application-level checks (like
SELECTfollowed byINSERT) for concurrency protection.