Mirror your WooCommerce catalog into HubSpot. Publish a WC product → HubSpot product created. Edit it → HubSpot PATCHed. Built on the new 2026-03 products endpoint, with a free one-way path and a Pro stateful path that prepares the ground for line-item associations in Part 3.
TL;DR — What you'll build
woocommerce_new_product + woocommerce_update_product, POSTs WC product fields to https://api.hubapi.com/crm/objects/2026-03/products_hubspot_product_id — Part 3 reads thiswoocommerce_update_product, PATCHes /crm/objects/2026-03/products/{{ _hs_product_id }} only when an ID already exists_hs_existing_id is_empty; update has _hs_product_id is_not_emptyThis hubspot woocommerce product sync uses Webhook Actions by Flow Systems. The create webhook works on the free plugin alone — one-way push, no Code Glue. The update webhook requires Pro (Code Glue + dynamic URL templates) and is the prerequisite for line-item associations in Part 3.
Triggers fire for any product type — simple, variable, virtual, downloadable.
Search for FlowSystems under Plugins → Add Plugin.
Required for Code Glue and {{ field.path }} URL templates. Skip if you're doing one-way push only.
If you followed Part 1, edit your existing app and add e-commerce scope. Copy the new token.
full scope
Create at WP Admin → Webhook Actions → API Tokens.
WooCommerce fires woocommerce_new_product on creation and woocommerce_update_product on every subsequent edit. The publish-from-draft transition fires update, not new — so we listen to both. HubSpot's 2026-03 products endpoint takes the same JSON shape as deals: a properties bag at the body root.
The trick that keeps two webhooks orthogonal on the same trigger: opposite conditions. Webhook 32 fires only when no HubSpot ID is stored yet. Webhook 33 fires only when one is. Each WC update event matches exactly one.
Same hard rule as Part 1. Pre-dispatch glue is payload shaping only — no update_post_meta, no outbound HTTP. Pre-glue runs on every retry and replay, so any side effect there duplicates. Writes go to post-glue. Read why →
Register a single webhook on two triggers so the first publish and any new-product REST call both fire it.
Repeated updates must not create new HubSpot products. The condition checks a pre-glue-injected field (_hs_existing_id) and skips dispatch when it's not empty.
Common bug: evaluate_on placement. Webhook Actions reads conditions_evaluate_on as a top-level field on the schema, not nested inside conditions. Putting "evaluate_on": "transformed" inside the conditions object is silently dropped and the condition then evaluates against the original (pre-glue) payload — meaning fields like _hs_product_id that only exist after pre-glue won't match. Verified live 2026-05-15 on webhook-actions.local.
Variations. For variable products, branch on $wc->is_type("variable") and either iterate $wc->get_children() (recommended for Part 3) or flatten variations into a single HubSpot product with attribute properties.
Same trigger as Step 1, opposite condition. It fires only when the product already has _hubspot_product_id, PATCHes the HubSpot product by ID, and refreshes the property bag.
The two-webhook trick. Webhook 32 has _hs_existing_id is_empty. Webhook 33 has _hs_product_id is_not_empty. The same woocommerce_update_product event flows through both pipelines — the condition system picks exactly one based on whether the meta is already present. No coordination, no race. Note: webhook IDs depend on your install — substitute your own.
WP Admin → Products → Add New. Name, price, SKU (e.g. WIDGET-001). Click Publish. Within a second, the Webhook Actions log shows 201 from webhook 32, and HubSpot's CRM → Products shows the new entry.
Open the same product, change the price, click Update. The Webhook Actions log shows webhook 32 skipped (matched _hs_existing_id is_not_empty) and webhook 33 returning 200 against /crm/objects/2026-03/products/{ID}. Refresh HubSpot — price reflects the new value.
If you already have a catalog, fire the create webhook once per existing product. The simplest path is WP-CLI:
Why do_action instead of curl loops? Firing the WC action runs the full Webhook Actions pipeline (conditions, pre-glue, dispatch, post-glue) so post-glue still persists _hubspot_product_id on every success. Curl-looping the HubSpot API directly skips that and loses the link back to WC meta.
Same trigger, two webhooks, no condition. Forget the _hs_existing_id is_empty / is_not_empty split and every product update creates a duplicate. The condition keeps the two orthogonal.
SKU collisions. HubSpot stores hs_sku as free-text. Empty WC SKUs would all share an identity in HubSpot reporting — the fallback wc-{$productId} prevents that.
Variations vs simple products. Pick one model and document it: per-variation HubSpot products (recommended for Part 3) or flattened parent with attribute properties.
HubSpot scope 403. Response {"status":"error","message":"This app hasn't been granted required scopes"} means the Private App is missing e-commerce. Edit the app, add it, refresh $HS_PAT.
Pre-glue side effects. Re-stating: no update_post_meta in pre-glue. Tag the product as in-flight from a post-glue branch instead.
hs_product_id to associate purchased items with a deal. Without product sync, deals have no SKU-level detail in HubSpot reporting. Product sync gives Part 3's line-item flow something concrete to point at.
hs_product_id (which Part 3 needs).
_hs_existing_id is_empty — only fires on first publish. Webhook 33 has _hs_product_id is_not_empty — only fires on subsequent edits. Exactly one matches per dispatch.
ecommerce-bridge endpoint is being deprecated in favour of the unified CRM objects API. The 2026-03 dated path is the supported way to push products in 2026 and beyond.
WP_Post records. Two clean options: (a) treat each variation as its own HubSpot product — iterate $wc->get_children() in pre-glue (recommended for Part 3); or (b) collapse into a single HubSpot product with attribute properties (simpler reporting, no per-variation line items).