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 SELECT followed by INSERT) for concurrency protection.