Originally published at hamzaahmadaslam.com.
How this article was made: drafted with AI assistance from a researched brief, then checked against primary documentation before publishing.
WooCommerce duplicate webhooks should be harmless to a CRM sync. Verify the signature against the raw body, claim a stable operation key in durable storage before writing to the CRM, then reconcile any request whose final outcome is uncertain.
Stop WooCommerce duplicate webhooks at the receiver
A valid webhook HMAC lets the receiver verify that the body was signed with the shared secret. That still does not make a repeated business effect safe. WooCommerce webhook idempotency means the receiver can accept repeated work and still leave the destination in the intended state once.
Start with the sender contract. The WooCommerce webhook documentation says the webhook secret is used to create a hash sent in the request headers. It also records delivery responses in WooCommerce logs for troubleshooting.
For a WooCommerce CRM sync, keep four responsibilities in the receiver:
- verify the signed body before parsing or storing it;
- turn the event into a stable operation key;
- claim that key atomically and record its state;
- reconcile any stale, ambiguous, or out-of-order state against WooCommerce and the CRM.
A repeated delivery, manual replay, or second event that represents the same work then becomes a lookup instead of another create call.
Verify the raw request before you claim work
WooCommerce documents X-WC-Webhook-Signature as a base64-encoded HMAC-SHA256 hash of the payload, and lists X-WC-Webhook-Delivery-ID among its webhook delivery headers. Verify the signature against the exact raw body. Parse JSON only after that check passes.
The first time you save an Active webhook, WooCommerce sends a ping to its delivery URL. Current core sends that ping as an unsigned body in the form webhook_id=<id> and requires exactly HTTP 200 from the destination. The receiver below accepts only that narrow unsigned ping before the HMAC check and performs no database work for it. Other unsigned requests still receive 401.
The local receiver uses __return_true as its route permission callback, so it does not use a WordPress user capability check for this endpoint. The callback handles the activation ping, rejects a bad HMAC before database work, then accepts only the illustrative order.updated topic. WordPress says to register REST routes on rest_api_init; see the register_rest_route() reference.
Put the same illustrative secret in a local webhook and local wp-config.php:
define( 'HAA_WC_WEBHOOK_SECRET', 'illustrative-local-secret-replace-me' );
define( 'HAA_WC_FIXTURE_MODE', true );
Do not enable HAA_WC_FIXTURE_MODE on production. It exists only to create the timeout fixture later in this guide.
Choose a deduplication key that represents the effect
The WooCommerce webhook delivery ID is useful only as a loose log-correlation hint. Current WooCommerce core creates it at send time with wp_hash( $this->get_id() . strtotime( 'now' ) ), which hashes the webhook ID and the current Unix second; see the current WC_Webhook source.
Two deliveries from the same webhook in the same second therefore receive the same input to wp_hash() and can carry the same delivery ID, even when they concern different events. A later delivery of equivalent business work can have another ID. Do not use this header as a deduplication key. The older v2 developer reference still calls it a delivery log ID stored as a comment, while current core says it no longer uses comments for delivery logs.
Use the identity that matches the effect you are protecting:
| Candidate key | Use it for | Reason |
|---|---|---|
Illustrative X-WC-Webhook-Delivery-ID
|
Loose log correlation | It can change between equivalent business operations and can repeat for deliveries from the same webhook sent in the same second. |
Illustrative order ID 91001
|
Entity identity | One order can have many valid updates. |
Illustrative store-a + integration-v1 + body SHA-256
|
Exact repeated projection | The same signed body maps to the same operation key even when the delivery header changes. |
| Illustrative `store-a\ | order:91001\ | crm-create:v1` |
The sample receiver uses a scoped body hash because it projects order state into a test destination. For a one-time side effect, such as creating a CRM object, prefer a stable business key and make that key the CRM's external ID when the CRM supports one.
Persist processing state before the CRM write
A check followed by an insert leaves a race: two requests can both see “missing” and both write. Use a unique database key as the claim. With InnoDB, SELECT ... FOR UPDATE locks selected rows until commit or rollback, which makes it suitable for serializing updates to the same destination entity; the behavior is covered in the MySQL locking-read documentation.
Use explicit states so recovery does not depend on guessing from logs. This transition table is illustrative:
| Current state | Receiver condition | Next state | Destination action |
|---|---|---|---|
| No row | Atomic operation claim wins | processing |
Continue to the destination guard. |
processing |
Another request loses the same operation claim | processing |
Do not write again; return an accepted response. |
processing |
Destination write and local ledger commit together | done |
Keep one projected CRM row. |
processing |
Incoming source time is older than the projected row | superseded |
Keep the newer destination state. |
processing |
Equal source time has a different payload, or the remote outcome is uncertain | needs_reconcile |
Read WooCommerce and the CRM before another write. |
done or superseded
|
The same operation arrives again | unchanged | Return success without another destination write. |
All table names, integration labels, order IDs, delivery labels, timestamps, secrets, and timeout values in the artifact are illustrative. Create these two tables in a local test database. Replace wp_ if your local site uses another prefix. This guide leaves plugin schema migration mechanics to your existing migration path.
CREATE TABLE wp_haa_webhook_ops (
operation_key CHAR(64) NOT NULL,
entity_key VARCHAR(191) NOT NULL,
body_hash CHAR(64) NOT NULL,
status VARCHAR(24) NOT NULL,
claim_token CHAR(32) NOT NULL,
source_modified_gmt DATETIME NULL,
last_delivery_id VARCHAR(191) NULL,
duplicate_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (operation_key),
KEY entity_key (entity_key),
KEY status (status)
) ENGINE=InnoDB;
CREATE TABLE wp_haa_crm_test (
entity_key VARCHAR(191) NOT NULL,
order_id BIGINT UNSIGNED NOT NULL,
order_status VARCHAR(191) NULL,
source_modified_gmt DATETIME NULL,
payload_hash CHAR(64) NULL,
write_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL,
PRIMARY KEY (entity_key)
) ENGINE=InnoDB;
Save the next block as an illustrative local plugin. It accepts WooCommerce's narrow unsigned activation ping, verifies normal deliveries against the raw HMAC, creates one atomic operation claim, serializes writes per order, and refuses to move the test CRM row backward in time.
<?php
/**
* Plugin Name: Illustrative Woo CRM idempotency receiver
*/
add_action( 'rest_api_init', 'haa_register_woo_crm_receiver' );
function haa_register_woo_crm_receiver() {
register_rest_route(
'haa-sync/v1',
'/woo-crm',
array(
'methods' => 'POST',
'callback' => 'haa_receive_woo_crm',
'permission_callback' => '__return_true',
)
);
}
function haa_set_operation_status( $table, $operation_key, $status ) {
global $wpdb;
return $wpdb->query(
$wpdb->prepare(
"UPDATE {$table}
SET status = %s, updated_at = UTC_TIMESTAMP()
WHERE operation_key = %s",
$status,
$operation_key
)
);
}
function haa_fixture_delay_if_requested( WP_REST_Request $request ) {
if ( defined( 'HAA_WC_FIXTURE_MODE' ) && HAA_WC_FIXTURE_MODE && '1' === $request->get_header( 'x-haa-test-delay' ) ) {
sleep( 2 );
}
}
function haa_receive_woo_crm( WP_REST_Request $request ) {
global $wpdb;
if ( ! defined( 'HAA_WC_WEBHOOK_SECRET' ) ) {
return new WP_REST_Response( array( 'error' => 'secret_not_configured' ), 500 );
}
$ops_table = $wpdb->prefix . 'haa_webhook_ops';
$crm_table = $wpdb->prefix . 'haa_crm_test';
$raw_body = $request->get_body();
$provided = $request->get_header( 'x-wc-webhook-signature' );
if ( ! $provided && 1 === preg_match( '/^webhook_id=\d+$/', $raw_body ) ) {
return new WP_REST_Response( array( 'state' => 'ping' ), 200 );
}
$expected = base64_encode(
hash_hmac( 'sha256', $raw_body, HAA_WC_WEBHOOK_SECRET, true )
);
if ( ! $provided || ! hash_equals( $expected, $provided ) ) {
return new WP_REST_Response( array( 'error' => 'bad_signature' ), 401 );
}
if ( 'order.updated' !== $request->get_header( 'x-wc-webhook-topic' ) ) {
return new WP_REST_Response( array( 'error' => 'unexpected_topic' ), 400 );
}
$payload = $request->get_json_params();
if (
! is_array( $payload ) ||
! isset( $payload['id'], $payload['status'], $payload['date_modified_gmt'] )
) {
return new WP_REST_Response( array( 'error' => 'bad_payload' ), 400 );
}
$modified_ts = strtotime( (string) $payload['date_modified_gmt'] . ' UTC' );
if ( false === $modified_ts ) {
return new WP_REST_Response( array( 'error' => 'bad_modified_time' ), 400 );
}
$modified_gmt = gmdate( 'Y-m-d H:i:s', $modified_ts );
$body_hash = hash( 'sha256', $raw_body );
$operation_key = hash( 'sha256', 'illustrative-store-a|woo-crm-v1|' . $body_hash );
$entity_key = 'illustrative-store-a|order:' . (int) $payload['id'];
$delivery_id = (string) $request->get_header( 'x-wc-webhook-delivery-id' );
$claim_token = bin2hex( random_bytes( 16 ) );
if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
return new WP_REST_Response( array( 'error' => 'claim_start_failed' ), 500 );
}
$claim_sql = $wpdb->prepare(
"INSERT INTO {$ops_table}
(operation_key, entity_key, body_hash, status, claim_token,
source_modified_gmt, last_delivery_id, duplicate_count, created_at, updated_at)
VALUES (%s, %s, %s, 'processing', %s, %s, %s, 0, UTC_TIMESTAMP(), UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE
duplicate_count = duplicate_count + 1,
last_delivery_id = %s,
updated_at = UTC_TIMESTAMP()",
$operation_key,
$entity_key,
$body_hash,
$claim_token,
$modified_gmt,
$delivery_id,
$delivery_id
);
if ( false === $wpdb->query( $claim_sql ) ) {
$wpdb->query( 'ROLLBACK' );
return new WP_REST_Response( array( 'error' => 'claim_failed' ), 500 );
}
$operation = $wpdb->get_row(
$wpdb->prepare(
"SELECT claim_token, status
FROM {$ops_table}
WHERE operation_key = %s
FOR UPDATE",
$operation_key
),
ARRAY_A
);
if ( ! $operation ) {
$wpdb->query( 'ROLLBACK' );
return new WP_REST_Response( array( 'error' => 'claim_missing' ), 500 );
}
if ( $operation['claim_token'] !== $claim_token ) {
if ( false === $wpdb->query( 'COMMIT' ) ) {
return new WP_REST_Response( array( 'error' => 'claim_read_commit_failed' ), 500 );
}
$code = (
'processing' === $operation['status'] ||
'needs_reconcile' === $operation['status']
) ? 202 : 200;
return new WP_REST_Response( array( 'state' => $operation['status'] ), $code );
}
if ( false === $wpdb->query( 'COMMIT' ) ) {
return new WP_REST_Response( array( 'error' => 'claim_commit_failed' ), 500 );
}
if ( false === $wpdb->query( 'START TRANSACTION' ) ) {
return new WP_REST_Response( array( 'error' => 'write_start_failed' ), 500 );
}
$seed_sql = $wpdb->prepare(
"INSERT INTO {$crm_table}
(entity_key, order_id, order_status, source_modified_gmt, payload_hash, write_count, updated_at)
VALUES (%s, %d, NULL, NULL, NULL, 0, UTC_TIMESTAMP())
ON DUPLICATE KEY UPDATE entity_key = %s",
$entity_key,
(int) $payload['id'],
$entity_key
);
if ( false === $wpdb->query( $seed_sql ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'destination_seed_failed' ), 500 );
}
$current = $wpdb->get_row(
$wpdb->prepare(
"SELECT source_modified_gmt, payload_hash
FROM {$crm_table}
WHERE entity_key = %s
FOR UPDATE",
$entity_key
),
ARRAY_A
);
if ( ! $current ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'destination_missing' ), 500 );
}
$current_ts = null;
if ( ! empty( $current['source_modified_gmt'] ) ) {
$current_ts = strtotime( $current['source_modified_gmt'] . ' UTC' );
if ( false === $current_ts ) {
if ( false === haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'reconcile_state_failed' ), 500 );
}
if ( false === $wpdb->query( 'COMMIT' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'reconcile_commit_failed' ), 500 );
}
return new WP_REST_Response( array( 'state' => 'needs_reconcile' ), 202 );
}
}
if ( null !== $current_ts && $modified_ts < $current_ts ) {
if ( false === haa_set_operation_status( $ops_table, $operation_key, 'superseded' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'supersede_state_failed' ), 500 );
}
if ( false === $wpdb->query( 'COMMIT' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'supersede_commit_failed' ), 500 );
}
return new WP_REST_Response( array( 'state' => 'superseded' ), 200 );
}
if ( null !== $current_ts && $modified_ts === $current_ts ) {
$next_state = ( $current['payload_hash'] === $body_hash ) ? 'done' : 'needs_reconcile';
if ( false === haa_set_operation_status( $ops_table, $operation_key, $next_state ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'equal_time_state_failed' ), 500 );
}
if ( false === $wpdb->query( 'COMMIT' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'equal_time_commit_failed' ), 500 );
}
return new WP_REST_Response(
array( 'state' => $next_state ),
'done' === $next_state ? 200 : 202
);
}
$update_sql = $wpdb->prepare(
"UPDATE {$crm_table}
SET order_status = %s,
source_modified_gmt = %s,
payload_hash = %s,
write_count = write_count + 1,
updated_at = UTC_TIMESTAMP()
WHERE entity_key = %s",
(string) $payload['status'],
$modified_gmt,
$body_hash,
$entity_key
);
if (
false === $wpdb->query( $update_sql ) ||
false === haa_set_operation_status( $ops_table, $operation_key, 'done' )
) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'destination_write_failed' ), 500 );
}
if ( false === $wpdb->query( 'COMMIT' ) ) {
$wpdb->query( 'ROLLBACK' );
haa_set_operation_status( $ops_table, $operation_key, 'needs_reconcile' );
return new WP_REST_Response( array( 'error' => 'destination_commit_failed' ), 500 );
}
haa_fixture_delay_if_requested( $request );
return new WP_REST_Response( array( 'state' => 'done' ), 200 );
}
The receiver's error responses matter on a live webhook. WooCommerce's documentation treats 2xx, 301, and 302 as successful deliveries, while current core's numeric check accepts response codes from 200 through 302. Timeouts and other failed responses increase the consecutive failure count, and WooCommerce documents automatic disabling after more than five consecutive failures by default. The threshold is adjustable with woocommerce_max_webhook_delivery_failures.
WooCommerce core does not automatically re-send a delivery because it timed out or returned an error. Monitor the webhook under WooCommerce > Settings > Advanced > Webhooks and review the webhooks-delivery log under WooCommerce > Status > Logs. A failing receiver can lose events and then be disabled, so reconciliation must recover failed deliveries instead of depending on redelivery.
This local design has one useful property: the destination row and done state commit in the same database transaction. A production CRM is usually remote, so you cannot assume that atomic boundary. If a remote write times out after the CRM accepted it, record needs_reconcile, look up the CRM object by its stable external order key, and update that object before considering a create.
Run duplicate, timeout and out-of-order fixtures
Everything in the next code block, including the endpoint, store label, order IDs, timestamps, delivery labels, and timeout values, is illustrative. Use a test webhook, a test CRM destination, and synthetic orders only.
Save this as woo-crm-fixture.php in the local WordPress directory:
<?php
$mode = $args[0] ?? '';
$endpoint = 'https://store.example.test/wp-json/haa-sync/v1/woo-crm';
$secret = HAA_WC_WEBHOOK_SECRET;
function haa_send_fixture( $endpoint, $secret, $payload, $delivery_id, $timeout, $delay ) {
$body = wp_json_encode( $payload );
$headers = array(
'Content-Type' => 'application/json',
'X-WC-Webhook-Topic' => 'order.updated',
'X-WC-Webhook-Delivery-ID' => $delivery_id,
'X-WC-Webhook-Signature' => base64_encode(
hash_hmac( 'sha256', $body, $secret, true )
),
);
if ( $delay ) {
$headers['X-HAA-Test-Delay'] = '1';
}
return wp_remote_post(
$endpoint,
array(
'headers' => $headers,
'body' => $body,
'timeout' => $timeout,
)
);
}
function haa_print_fixture_result( $label, $response ) {
if ( is_wp_error( $response ) ) {
echo $label . ": request error\n";
return;
}
echo $label . ': HTTP ' . wp_remote_retrieve_response_code( $response ) . "\n";
}
if ( 'concurrent-a' === $mode || 'concurrent-b' === $mode ) {
$payload = array(
'id' => 91001,
'status' => 'processing',
'date_modified_gmt' => '2026-09-24T12:00:01',
);
$label = 'concurrent-a' === $mode ? 'illustrative-delivery-a' : 'illustrative-delivery-b';
$r = haa_send_fixture( $endpoint, $secret, $payload, $label, 5, false );
haa_print_fixture_result( $mode, $r );
return;
}
if ( 'out-of-order' === $mode ) {
$newer = array(
'id' => 91002,
'status' => 'completed',
'date_modified_gmt' => '2026-09-24T12:05:00',
);
$older = array(
'id' => 91002,
'status' => 'processing',
'date_modified_gmt' => '2026-09-24T12:01:00',
);
$a = haa_send_fixture( $endpoint, $secret, $newer, 'illustrative-delivery-c', 5, false );
$b = haa_send_fixture( $endpoint, $secret, $older, 'illustrative-delivery-d', 5, false );
haa_print_fixture_result( 'newer', $a );
haa_print_fixture_result( 'older', $b );
return;
}
if ( 'timeout' === $mode ) {
$payload = array(
'id' => 91003,
'status' => 'completed',
'date_modified_gmt' => '2026-09-24T12:10:00',
);
$a = haa_send_fixture( $endpoint, $secret, $payload, 'illustrative-delivery-e', 1, true );
$b = haa_send_fixture( $endpoint, $secret, $payload, 'illustrative-delivery-f', 5, false );
haa_print_fixture_result( 'delayed-first-call', $a );
haa_print_fixture_result( 'repeat-call', $b );
return;
}
echo "Use concurrent-a, concurrent-b, out-of-order, or timeout.\n";
Run the concurrent fixture in two shells against a clean local state, starting both commands as close together as practical:
wp eval-file woo-crm-fixture.php concurrent-a
wp eval-file woo-crm-fixture.php concurrent-b
Then run the other illustrative scenarios separately:
wp eval-file woo-crm-fixture.php out-of-order
wp eval-file woo-crm-fixture.php timeout
Inspect the illustrative ledger and test destination after each clean run:
SELECT entity_key, order_status, source_modified_gmt, write_count
FROM wp_haa_crm_test
ORDER BY order_id;
SELECT entity_key, status, duplicate_count
FROM wp_haa_webhook_ops
ORDER BY entity_key, source_modified_gmt;
The timeout fixture asks WordPress's HTTP client to stop waiting after an illustrative one second. The local receiver delays for an illustrative two seconds only after committing the destination write and done state. The repeat uses the same body with a different illustrative delivery ID. The test-only write_count makes a second destination write visible.
This fixture performs the repeat itself. WooCommerce core does not automatically resend the timed-out first request.
Use this test matrix as the acceptance target. These are expected results, not observed results.
| Illustrative scenario | Procedure | Expected operation state | Expected test CRM state |
|---|---|---|---|
Concurrent duplicate, order 91001
|
Send the same signed body from two shells with delivery labels A and B | One operation key; one owner; duplicate count increases | One 91001 row with processing; write_count = 1
|
Out of order, order 91002
|
Send completed at 12:05:00, then processing at 12:01:00
|
Newer operation done; older operation superseded
|
One 91002 row remains completed at 12:05:00; write_count = 1
|
Timeout after commit, order 91003
|
Delay the first response after commit, then send the same body again | Existing operation remains done; duplicate count increases |
One 91003 row remains completed; write_count = 1
|
For two identical requests arriving together, let the database unique key decide who owns the operation. Do not use a read-before-write test as the ownership decision. Repeated inputs can come from another event representing the same business effect, another queued webhook action, a manual replay, or a relay or queue in front of the receiver. Send every such repeat through the same claim path.
Reconcile missed and out-of-order events from source state
Ordering needs a source version. For order payloads, WooCommerce exposes date_modified_gmt as the order's last-modified time in GMT in the current order API documentation. The sample stores that value beside the CRM projection.
Apply three rules:
- If the incoming source time is older than the CRM projection, mark the operation
superseded. - If it is newer, update the existing CRM object by its stable external key.
- If the time ties but the payload differs, mark
needs_reconcilerather than guessing which body wins.
Reconciliation should fetch the current order from WooCommerce, then fetch the CRM object by external order key. Compare the source state with the destination and update one existing object. If you reconcile a range of WooCommerce records rather than one known order, use REST pagination that does not skip records instead of inventing a webhook-specific bulk sync loop.
A stale processing row also belongs in reconciliation. It may mean the owner stopped after the claim. It may also mean a remote write finished but the receiver never recorded the result. Query both systems before another create.
Keep queue repair separate from receiver correctness
If delivery jobs are delayed or backed up on the store, use the WooCommerce Action Scheduler backlog guide for the queue itself. Receiver idempotency still has to hold when delayed work reaches it.
If you are checking WooCommerce webhook retry behavior, distinguish queue execution from HTTP redelivery. Current WC_Webhook::deliver() sends one HTTP request for a delivery, while the failure path records the failure and can disable the webhook; it does not schedule an automatic re-send of that failed request. Use reconciliation to recover an event that received an error or timed out.
Once the local fixture passes, replace the test table with the CRM's upsert or external-ID lookup and keep the operation table as the durable receiver ledger. If that integration needs its own lifecycle, deployment, admin controls, and reconciliation job, treat it as custom WordPress plugin development rather than a theme snippet.
Top comments (0)