A store owner saves a product in WooCommerce. Three seconds later, a customer on the other side of the world finds that product by typing "something warm for winter" into a chat widget, adds it to their cart, and checks out. The store owner sees the purchase attributed to the chat session on their dashboard.
That's a lot of systems talking to each other. Here's what happens at each step.
Step 1: Product Save Triggers a Webhook
The WooCommerce plugin hooks into woocommerce_update_product. When the store owner clicks "Publish" or "Update," the hook fires and queues a webhook event in memory.
The event isn't sent immediately. It goes into an in-memory queue that flushes on WordPress's shutdown action, after the HTTP response is already sent to the browser. The admin page loads at normal speed. The webhook happens a fraction of a second later.
// Simplified from the actual plugin
add_action('woocommerce_update_product', [$this, 'on_product_update']);
add_action('shutdown', [$this, 'flush_webhook_queue']);
The queue deduplicates. If a product triggers multiple hooks in one request (saving a variable product fires hooks for the parent and each variation), each product ID is queued once. The batch goes out as a single HMAC-signed POST to the webhook endpoint.
The payload is a consolidated JSON structure — one event per product with all translations nested inside:
{
"events": [{
"type": "product.updated",
"data": {
"identification_number": "product-42",
"sku": "product-42",
"channels": ["default"],
"names": {"default": {"en": "Merino Wool Hiking Jacket"}},
"descriptions": {"default": {"en": "Warm, breathable, water-resistant..."}},
"links": {"default": {"en": "https://store.com/merino-wool-hiking-jacket"}},
"categories": {"default": {"en": ["Outerwear > Jackets"]}},
"brands": {"default": "TrailPeak"},
"prices": {"default": [
{"currency": "USD", "current_price": 149.99, "regular_price": 189.99}
]},
"availability_statuses": {"default": "available"},
"stock_quantities": {"default": 25},
"attributes": {"default": {"en": {"Material": "Merino Wool", "Weight": "380g"}}},
"images": {"default": ["https://store.com/jacket-front.jpg"]},
"is_parent": false,
"parent_sku": null,
"variation_attributes": {}
}
}]
}
For multilingual stores (Polylang or WPML), the plugin sends all translations in a single event with nested language keys. A store with 3 languages and 1,000 products generates 1,000 events during a full sync, not 3,000.
Step 2: Webhook Endpoint Validates and Queues
The webhook hits the receiving endpoint. Five checks happen before anything is processed:
- Rate limit (120 requests/60s per store, sliding window in Redis)
- Store lookup
- Subscription check
- HMAC-SHA256 signature verification
- Pydantic schema validation on every event
If all pass, the events go to a Celery task. The endpoint returns 202 and the store doesn't wait.
Step 3: Embedding (or Skipping It)
The Celery worker picks up the events. For each product, it computes a SHA-256 hash of the content that affects search quality (name, SKU, category, brand, description, attributes). If the hash matches what's already stored, and only the price or stock changed, the existing embedding is reused. No inference call needed.
If the content actually changed, the product goes to the inference service for a new embedding. The model (intfloat/multilingual-e5-large, 1024 dimensions) encodes the product into a vector that captures its meaning across 100+ languages.
For a typical product update where only the price changed, the embedding step is skipped entirely. During a full resync of 10,000 products where maybe 50 descriptions changed, this saves thousands of inference calls.
The product and its embedding get upserted to Qdrant, which stores both the vector and a BM25 index of the text fields.
Step 4: A Customer Types "Something Warm for Winter"
A customer on the store opens the chat widget and types a query. The message goes to a LangGraph-based pipeline.
First, a classifier looks at the message and decides which agents need to handle it. "Something warm for winter" goes to the product expert. "Something warm for winter, and what's your return policy?" goes to both the product expert and the customer support agent, running in parallel.
The product expert runs a hybrid search:
BM25 searches the text fields for exact token matches. Good for brand names, SKUs, specific attributes.
Vector search finds products that are semantically close to the query. "Something warm for winter" lands near jackets, sweaters, thermal gear, even though none of those product titles contain the words "warm" or "winter."
Both result sets get normalized to the same score range and merged. A cross-encoder reranker (ms-marco-MiniLM-L-6-v2) then compares each candidate directly against the query and re-sorts. The Merino Wool Hiking Jacket from step 1 scores high on both the vector similarity (warm + winter + outdoor) and the reranker.
The agent formats a response with product recommendations, prices, and comparison notes. If multiple agents ran in parallel, an LLM merges their responses into one coherent reply.
Step 5: Customer Adds to Cart
The customer sees the product card in the chat and clicks "Add to Cart." The widget calls window.EmporiqaCartHandler({ action: 'add', items: [...] }) on the store's frontend. This is a JavaScript function registered by the WooCommerce plugin that talks to WordPress AJAX endpoints:
emporiqa_add_to_cart → WC()->cart->add_to_cart()
emporiqa_get_cart → current cart state
emporiqa_update_cart → change quantity
emporiqa_remove_from_cart → remove item
emporiqa_clear_cart → empty cart
The plugin resolves Emporiqa's identification_number format (product-42, variation-123) back to WooCommerce integer IDs. For variable products, it resolves the correct variation based on attributes. After every operation, the WooCommerce mini-cart in the page header refreshes automatically.
The cart operations go through WooCommerce's own service layer. Store-specific rules (promotions, minimum quantities, inventory limits) still apply.
Step 6: Checkout and Conversion Attribution
The customer proceeds to checkout. The plugin hooks into woocommerce_checkout_order_processed (and the block checkout equivalent, woocommerce_store_api_checkout_order_processed) and reads the emporiqa_sid cookie (the chat session ID, set when the widget opened). It saves this as order meta.
When the order status changes to processing, completed, or on-hold (or woocommerce_payment_complete fires), the plugin sends an order.completed webhook with the session ID, line items, and totals. On the receiving end, this purchase gets attributed to the chat session that recommended the product.
One problem I had to solve: external payment gateways (PayPal, Stripe) redirect server-to-server. The browser cookie is gone by the time the order status changes. The fix was capturing the session ID during checkout (when the browser is still present) and storing it as order meta. The payment completion hook then reads it from the database, not the cookie.
// During checkout (browser present)
add_action('woocommerce_checkout_order_processed', function($order_id) {
if (isset($_COOKIE['emporiqa_sid'])) {
$order = wc_get_order($order_id);
$order->update_meta_data('_emporiqa_session_id', sanitize_text_field($_COOKIE['emporiqa_sid']));
$order->save();
}
});
// On order status change or payment complete (server-to-server, no cookie)
// Hooks: woocommerce_order_status_processing, _completed, _on-hold, woocommerce_payment_complete
add_action('woocommerce_order_status_processing', function($order_id) {
$order = wc_get_order($order_id);
$session_id = $order->get_meta('_emporiqa_session_id');
// Send order.completed webhook with session_id for attribution
});
A _emporiqa_order_tracked meta flag prevents duplicate sends. Payment gateways sometimes fire status hooks multiple times.
Step 7: The Dashboard
The store owner opens the dashboard and sees:
- Chat sessions → cart adds → checkouts → purchases, with conversion rates at each step
- Revenue attributed to chat conversations
- Which products get recommended most, which get added to cart
- Widget engagement (loads, opens, unique visitors)
This closes the loop. Product save → webhook → embedding → search → agent response → cart → purchase → attribution → dashboard.
What's Extensible
The WooCommerce plugin exposes WordPress filters for customization:
-
emporiqa_should_sync_product/emporiqa_should_sync_pageto skip specific items -
emporiqa_product_data/emporiqa_variation_datato modify payloads before sending -
emporiqa_order_tracking_datato add shipment tracking numbers to order lookups -
emporiqa_widget_enabledto disable the widget on specific pages
Standard WordPress patterns, no forking needed.
What I'd Do Differently
The conversion attribution relies on a cookie. If the customer clears cookies between chatting and checking out, the link breaks. There's no server-side fallback that connects the customer's identity across sessions. A logged-in user ID match would fix this, but it adds complexity for guest checkouts.
I wrote about the hybrid search pipeline, the sync architecture, and the parallel agent system separately. This post is about how they connect. The WooCommerce plugin is live on wordpress.org. I covered the full WooCommerce integration on the Emporiqa blog, and the setup docs walk through the configuration step by step. You can test the pipeline end-to-end with a free sandbox at Emporiqa.
Top comments (0)