Author picture Gareth Wilson

How to Prevent Duplicate WooCommerce Webhooks

Published


If you've integrated WooCommerce with external systems using webhooks, you've probably encountered a frustrating problem: the same webhook firing multiple times for a single event. Whether it's duplicate order notifications, repeated inventory updates, or charges processed twice, duplicate webhooks can cause real damage to your business operations. This article explains why WooCommerce sends duplicate webhooks, how to implement idempotency in your webhook handlers, and how Hookdeck can help filter duplicates before they reach your application.

Why WooCommerce sends duplicate webhooks

WooCommerce webhook duplication isn't a single bug with a single cause. Multiple factors contribute to receiving the same event more than once.

Multiple internal actions trigger the same webhook. When you configure a webhook for "Order Updated," WooCommerce fires it for every action that modifies the order. A single checkout can trigger multiple updates: the order is created with a pending status, payment is processed, inventory is adjusted, and the status changes to processing or completed. Each modification fires another webhook, even though from your perspective it's all one transaction.

Order creation and update events overlap. WooCommerce always creates orders with a "pending" status first, then updates them when payment completes. If you're listening for "Order Created," you'll also see "Order Updated" fire moments later. Some integrations inadvertently process both, duplicating their downstream actions.

Plugins and custom code trigger additional saves. Third-party plugins that interact with orders often call $order->save() multiple times during processing. Each save can trigger webhooks configured for update events. Auto-complete plugins, fulfilment integrations, and payment gateways are common culprits.

WooCommerce's verification ping. When you create or update a webhook, WooCommerce sends a test request to verify the endpoint is reachable. This verification ping can look like a duplicate if your handler doesn't distinguish it from real events.

Network retries and timeouts. If your endpoint takes too long to respond or returns an ambiguous error, WooCommerce may retry the delivery. The original request might have succeeded on your end, but WooCommerce didn't receive confirmation, so it sends the webhook again.

Payment gateway webhooks compound the problem. If you're also receiving webhooks from Stripe, PayPal, or other payment providers, their events can overlap with WooCommerce's. A completed payment triggers webhooks from both the gateway and WooCommerce, and if your handler processes both, you've doubled your work.

The result is that a single customer placing a single order can generate up to ten webhook deliveries to your endpoint, depending on your configuration and installed plugins.

Understanding WooCommerce webhook headers

Before implementing deduplication, you need to understand what WooCommerce sends with each webhook. Every delivery includes several custom headers:

  • X-WC-Webhook-ID — The WordPress post ID of the webhook configuration
  • X-WC-Webhook-Topic — The event type, such as order.created or order.updated
  • X-WC-Webhook-Resource — The resource type, such as order or product
  • X-WC-Webhook-Event — The specific event, such as created or updated
  • X-WC-Webhook-Signature — HMAC-SHA256 signature for verification
  • X-WC-Delivery-ID — A unique identifier for this specific delivery attempt

The X-WC-Delivery-ID is particularly important. WooCommerce generates a new delivery ID for each attempt (using a hash of the webhook ID and current timestamp), meaning retries of the same event will have different delivery IDs. This header helps you identify retries but doesn't help you identify duplicate events triggered by multiple internal actions.

The payload itself contains the resource data, including the order ID, which you can use as a more stable identifier for deduplication.

Implementing idempotency in your webhook handler

Idempotency means that processing the same webhook multiple times produces the same result as processing it once. Your webhook handlers must be idempotent because duplicate deliveries are inevitable, not just from WooCommerce but from any webhook provider.

Strategy 1: Database unique constraints

The simplest approach uses your database to reject duplicates. Create a table to track processed webhooks and use a unique constraint on the identifier.

CREATE TABLE processed_webhooks (
    id INT AUTO_INCREMENT PRIMARY KEY,
    order_id BIGINT NOT NULL,
    webhook_topic VARCHAR(50) NOT NULL,
    processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY unique_order_topic (order_id, webhook_topic)
);

In your webhook handler:

function handle_woocommerce_webhook($payload, $headers) {
    global $wpdb;

    $order_id = $payload['id'];
    $topic = $headers['X-WC-Webhook-Topic'];

    // Attempt to insert - will fail if duplicate
    $result = $wpdb->insert(
        'processed_webhooks',
        [
            'order_id' => $order_id,
            'webhook_topic' => $topic
        ]
    );

    if ($result === false) {
        // Duplicate detected - return success without processing
        return wp_send_json(['status' => 'duplicate'], 200);
    }

    // Process the webhook
    process_order($order_id);

    return wp_send_json(['status' => 'processed'], 200);
}

This approach is reliable because the database handles concurrency. If two identical webhooks arrive simultaneously, only one insert will succeed.

Strategy 2: Webhook history with TTL

For higher-volume scenarios, maintain a lightweight cache of recent webhook identifiers:

function handle_webhook_with_cache($payload, $headers) {
    $cache_key = 'webhook_' . $payload['id'] . '_' . $headers['X-WC-Webhook-Topic'];

    // Check if we've seen this webhook recently
    if (get_transient($cache_key)) {
        return wp_send_json(['status' => 'duplicate'], 200);
    }

    // Mark as seen with 1-hour expiry
    set_transient($cache_key, true, HOUR_IN_SECONDS);

    // Process the webhook
    process_order($payload['id']);

    return wp_send_json(['status' => 'processed'], 200);
}

WordPress transients work for moderate traffic. For high-volume stores, consider Redis or Memcached for faster lookups.

Strategy 3: Status-based processing

Instead of tracking webhooks, track the business state and only act when appropriate:

function handle_order_webhook($payload) {
    $order_id = $payload['id'];
    $order = wc_get_order($order_id);

    // Check if we've already processed this order
    $already_synced = get_post_meta($order_id, '_synced_to_erp', true);

    if ($already_synced) {
        return wp_send_json(['status' => 'already_processed'], 200);
    }

    // Only process orders in specific statuses
    $processable_statuses = ['processing', 'completed'];
    if (!in_array($order->get_status(), $processable_statuses)) {
        return wp_send_json(['status' => 'skipped'], 200);
    }

    // Sync to ERP
    sync_order_to_erp($order);

    // Mark as synced
    update_post_meta($order_id, '_synced_to_erp', current_time('mysql'));

    return wp_send_json(['status' => 'processed'], 200);
}

This approach is semantic: you're not tracking webhooks, you're tracking whether the business action has been completed.

Strategy 4: Upsert operations

Design your downstream operations to be naturally idempotent using upserts:

function sync_order_to_external_system($order_data) {
    // Instead of INSERT, use INSERT ... ON DUPLICATE KEY UPDATE
    // or your ORM's updateOrCreate equivalent

    ExternalOrder::updateOrCreate(
        ['woocommerce_order_id' => $order_data['id']], // Match criteria
        [
            'customer_email' => $order_data['billing']['email'],
            'total' => $order_data['total'],
            'status' => $order_data['status'],
            'updated_at' => now()
        ]
    );
}

If the order already exists, it's updated. If not, it's created. Either way, the end state is correct regardless of how many times the webhook fires.

Queue-first architecture

For production systems, never process webhooks synchronously. Accept them immediately, queue them for background processing, and apply deduplication in the queue consumer.

This architecture ensures you always respond to WooCommerce quickly (avoiding timeout-related retries) while giving you control over how duplicates are handled in the background.

Using Hookdeck for deduplication

While implementing idempotency in your application is essential, Hookdeck can filter duplicates before they reach your endpoint, reducing load and simplifying your code.

How Hookdeck deduplication works

When you route WooCommerce webhooks through Hookdeck, you can configure deduplication rules that identify and discard duplicate events. Hookdeck computes a hash based on your chosen strategy and checks whether the same hash has been seen within a configurable time window (from 1 second to 1 hour).

Hookdeck offers two deduplication strategies:

Exact deduplication uses the entire payload as the key. Events must be byte-for-byte identical to be considered duplicates. This catches retry storms where the same payload is sent multiple times but won't catch semantically identical events with different timestamps.

Field-based deduplication lets you specify which fields define uniqueness. You can include specific fields (like order ID and status) or exclude volatile fields (like timestamps and delivery IDs). This is more flexible and catches duplicates that exact matching would miss.

Configuring Hookdeck for WooCommerce

For WooCommerce order webhooks, a practical configuration might deduplicate based on:

  • The order ID from the payload (id field)
  • The webhook topic from headers (X-WC-Webhook-Topic)
  • The order status (status field)

This configuration treats multiple "order.updated" webhooks for the same order with the same status as duplicates, while still allowing through legitimate status changes.

{
  "type": "deduplication",
  "config": {
    "strategy": "fields",
    "fields": {
      "include": ["body.id", "body.status", "headers.X-WC-Webhook-Topic"]
    },
    "window": 3600
  }
}

With a one-hour window, Hookdeck will discard any webhook that matches a previously seen combination of order ID, status, and topic within that hour.

You'll still need application-level idempotency

Deduplication is a best-effort feature, not a guarantee. It reduces duplicate traffic but doesn't eliminate the need for idempotent handlers. Reasons include:

  • The deduplication window has limits (maximum one hour)
  • Hookdeck's at-least-once delivery guarantee means retries can still occur
  • Events that arrive outside the window will be delivered
  • If Hookdeck's deduplication service experiences issues, duplicates may pass through

Think of Hookdeck deduplication as a filter that catches the majority of duplicates, reducing load on your application. Your application still needs to handle any duplicates that get through.

Additional Hookdeck benefits

Beyond deduplication, routing WooCommerce webhooks through Hookdeck provides:

Automatic retries that prevent WooCommerce from disabling your webhooks after failures. WooCommerce always sees a successful delivery to Hookdeck; if your endpoint is down, Hookdeck retries automatically.

Request logging that shows you exactly which webhooks arrived, which were deduplicated, and which failed. This visibility makes debugging duplicate issues much easier.

Transformation rules that let you normalize payloads before delivery. You can strip volatile fields, add computed values, or restructure the data to simplify your handler.

Rate limiting that protects your endpoint from webhook floods during high-traffic periods like flash sales.

Conclusion

WooCommerce webhook duplication is a feature of the system, not a bug. Multiple internal events, plugin interactions, and network retries all contribute to receiving the same logical event multiple times. Rather than fighting this behavior, embrace it by building idempotent webhook handlers.

Use database constraints or status tracking to ensure your business logic only executes once. Consider a queue-first architecture that decouples receiving webhooks from processing them. And for additional protection, route your webhooks through Hookdeck to filter obvious duplicates before they consume your application's resources.

The goal isn't to receive each webhook exactly once; that's impossible to guarantee. The goal is to produce correct results regardless of how many times you receive each webhook.


Author picture

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.