Building a WooCommerce ↔ Bitrix24 Sync That Heals Itself
A real plugin, its bugs, and the patterns that fixed them: a queue, backoff with jitter, idempotency and loop guards.
The problem
Most WooCommerce stores that talk to a CRM do it the simple way: when an order is placed, send a request to the CRM and hope for the best.
Then the CRM times out, returns a 502 or hits a rate limit. The request fails, nobody notices, and a customer never gets a call. The store owner finds out weeks later, if at all.
I built a WordPress plugin that syncs WooCommerce with Bitrix24, and its first version had exactly these weaknesses. This post walks through what I changed to make a failed sync fix itself, and what broke along the way.
The design in one paragraph
Each event (new order, status change, Bitrix24 webhook) becomes a row in a small queue table. A cron worker claims rows one at a time, calls the Bitrix24 REST API, and either marks the row done, schedules a retry with exponential backoff, or marks it failed with the error text. Every step is safe to repeat.
Why a custom queue table
WooCommerce ships with Action Scheduler, and it is a good choice for many plugins. I used my own table because I wanted three things in one place: a database-level duplicate guard, an atomic claim, and a visible list of failed jobs with their last error. The cost is that I own the retry logic, which is what the rest of this post is about.
CREATE TABLE wp_wcb24_queue (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
job_type VARCHAR(20) NOT NULL DEFAULT 'order_create',
payload TEXT NULL,
dedupe_key VARCHAR(64) NULL DEFAULT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
attempts SMALLINT UNSIGNED NOT NULL DEFAULT 0,
next_attempt_at DATETIME NOT NULL,
locked_at DATETIME NULL DEFAULT NULL,
last_error TEXT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY dedupe_key (dedupe_key),
KEY status_next (status, next_attempt_at)
);
Queue the order ID, not the data
The first version built the CRM payload at checkout and stored it. That payload could be incomplete (orders created from the admin or by the block checkout), and it never contained later status changes. Now the queue stores only the order ID, and the data is read when the job runs, so it is always fresh.
Several WooCommerce hooks can fire for one order, so the duplicate guard lives in the database, not in a SELECT-then-INSERT that two requests can both pass:
$inserted = $wpdb->query( $wpdb->prepare(
'INSERT IGNORE INTO ' . self::table() . "
(order_id, job_type, dedupe_key, status, attempts, next_attempt_at, created_at, updated_at)
VALUES (%d, 'order_create', %s, 'pending', 0, %s, %s, %s)",
$order_id, 'order:' . $order_id, $now, $now, $now
) );
The UNIQUE key on dedupe_key makes the second insert a silent no-op.
Claim jobs atomically
WP-Cron can start two workers at once. Each takes a job by flipping its status in one UPDATE, and only one of them gets an affected-row count of 1:
$claimed = $wpdb->query( $wpdb->prepare(
"UPDATE {$table}
SET status = 'processing', locked_at = %s, updated_at = %s
WHERE id = %d AND status = 'pending'",
$now, $now, $id
) );
if ( 1 !== (int) $claimed ) {
return null; // someone else got it
}
If PHP dies mid-job, the row would stay in processing forever, so each run first returns rows locked for more than five minutes back to pending. A time budget (40 seconds, 10 jobs per run) keeps a slow API from hitting PHP's execution limit.
Retry only what can succeed
Retrying a request that can never work just fills the queue with noise. The API client turns every response into one of two exceptions, and the worker acts on the type:
if ( 429 === $code || $code >= 500 ) {
throw new WCB24_Temporary_Exception( $summary );
}
if ( '' !== $error_code ) { // Bitrix24 can return an error even with HTTP 200
if ( in_array( $error_code, self::$temporary_codes, true ) ) {
throw new WCB24_Temporary_Exception( $summary );
}
throw new WCB24_Permanent_Exception( $summary );
}
if ( $code >= 400 ) {
throw new WCB24_Permanent_Exception( $summary );
}
A permanent error (bad webhook key, missing permission, validation) goes straight to failed. Anything else is retried. Two details that mattered: a 200 response that is an HTML maintenance page is treated as temporary, and the webhook URL, which contains a secret key, is stripped from every error message before it reaches the log.
Backoff with jitter
public static function backoff_delay( $attempts ) {
$delay = min( 300 * ( 2 ** ( $attempts - 1 ) ), 21600 );
return $delay + random_int( 0, (int) ( $delay * 0.2 ) );
}
Delays grow 5, 10, 20, 40, 80 minutes, capped at six hours, plus up to 20% random jitter. Without jitter, 200 orders queued during an outage would all retry in the same second and cause a second outage.
The timezone bug that shortened every retry
My first version wrote next_attempt_at with PHP's date() (UTC) and compared it with current_time('mysql') (the site's local time). On a site at UTC+6 that made the 5- and 30-minute retries fire almost immediately. The fix is boring and total: store and compare everything in UTC with gmdate().
Make every step repeatable
Retries mean a job can run twice. If Bitrix24 accepted a deal but the response was lost, a naive retry creates a duplicate. The order job is split into steps that can resume from wherever they stopped:
$deal_id = (int) $order->get_meta( self::META_DEAL );
if ( ! $deal_id ) {
$deal_id = self::find_deal( $order->get_id() ); // search by UF_CRM_ORDER_ID
}
if ( ! $deal_id ) {
$contact_id = self::find_or_create_contact( $order );
$deal_id = self::create_deal( $order, $contact_id );
}
$order->update_meta_data( self::META_DEAL, $deal_id );
$order->save_meta_data(); // save immediately: the deal now exists
The deal carries the WooCommerce order ID in a custom field, so a retry finds the deal it created earlier instead of making a new one. Contacts are matched by email, then phone, before one is created. I tested this by sending the same order twice: one deal in Bitrix24.
A smaller choice with the same idea: a status change job reads the order's current status when it runs, not the status at the time it was queued, so quick changes (processing, then completed) send the right final stage.
Two-way sync without loops
The Bitrix24 outbound webhook fires on every deal update in the portal, and it carries almost nothing except the deal ID. So the endpoint only verifies the token, queues the deal ID and answers 200. The worker then does the real work:
- Fetch the deal with
crm.deal.get. - Ignore it unless it has our order ID field and that order points back to this exact deal.
- Map the deal stage to a WooCommerce status through a configurable table.
- Skip if the order already has that status.
- Apply the status with a flag set, so the status-changed hook does not send it back.
Steps 4 and 5 are two separate loop guards. Either would stop an infinite ping-pong; having both means a bug in one does not take down the store.
Things that cost me time
-
A dev tunnel with a password. My local site's public link had HTTP Basic auth in front of it, so Bitrix24 got a 401 before WordPress ever saw the request, and the plugin log stayed empty. The
www-authenticate: Basicheader in thecurloutput gave it away. A tunnel without a password worked. - Silent skips look like failures. When an event is ignored (a deal without an order, a stage without a mapping), the plugin now logs why. An empty log used to mean both "nothing happened" and "nothing was received".
-
WP-Cron only runs on visits. On a quiet test site a job can sit in the queue for minutes. In production, disable it with
DISABLE_WP_CRONand run a real cron every minute.
Testing
The pure logic is covered by 19 PHPUnit tests, with Brain Monkey standing in for the WordPress functions, so they run in seconds without WordPress or a Bitrix24 portal. They cover error classification (including a 200 with an error body and an HTML page), backoff bounds and the cap, UTC timestamps, stage maps and webhook token checks. The end-to-end flow I checked by hand against a test portal: order to deal with products, a duplicate send, status in both directions, and a wrong key followed by a retry.
What is not proven yet
I tested the classic checkout on one test portal. The block checkout hooks, orders with discounts (Bitrix24 may recalculate the deal amount from product rows) and portals with several funnels still need more testing.
Takeaways
- Put the CRM call in a queue; the checkout should never wait for it.
- Queue the order ID and read fresh data when the job runs.
- Let the database enforce uniqueness, and claim jobs with one atomic UPDATE.
- Retry timeouts, 429 and 5xx; fail fast on everything else.
- Add jitter, cap the delay, and keep all timestamps in UTC.
- Make each step safe to repeat by searching for what you already created.
- In two-way sync, guard against loops twice.
- Log why something was skipped, not only what failed.
I am building this in the open. If you work with WooCommerce or CRM integrations, follow along, and if your store loses orders between systems, feel free to message me.
Top comments (0)