HubSpot WooCommerce Product Sync: Push & Update Products from WC
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
- Webhook 1 — Product Create: fires on
woocommerce_new_product+woocommerce_update_product, POSTs WC product fields tohttps://api.hubapi.com/crm/objects/2026-03/products - Post-dispatch Code Glue: stores returned HubSpot product ID in WC product meta as
_hubspot_product_id— Part 3 reads this - Webhook 2 — Product Update: fires on
woocommerce_update_product, PATCHes/crm/objects/2026-03/products/{{ _hs_product_id }}only when an ID already exists - Condition split keeps them orthogonal: create has
_hs_existing_id is_empty; update has_hs_product_id is_not_empty
What You Need Before Starting
- WordPress + WooCommerce with at least one product to test
Triggers fire for any product type — simple, variable, virtual, downloadable.
- Webhook Actions (free) v1.12.0+
Search for FlowSystems under Plugins → Add Plugin.
- Webhook Actions Pro Pro v1.1.0+ — only for the update half
Required for Code Glue and
{{ field.path }}URL templates. Skip if you're doing one-way push only. - HubSpot Private App token with product scopes
If you followed Part 1, edit your existing app and add
e-commercescope. Copy the new token. - Webhook Actions API token with
fullscopeCreate at WP Admin → Webhook Actions → API Tokens.
shell variables
export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..."
Two Webhooks, Same Product Object
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.
flow diagram
# 1. PRODUCT PUBLISHED (first save with status=publish) WooCommerce product saved └── fires woocommerce_new_product | woocommerce_update_product └── Webhook #1 POST /crm/objects/2026-03/products └── HubSpot 201 + {"id": "30258041729"} └── post-glue: update_post_meta($product_id, "_hubspot_product_id", "30258041729") # 2. PRODUCT EDITED (any subsequent change) Product price/name/desc changed └── fires woocommerce_update_product └── pre-glue: read _hubspot_product_id → inject as _hs_product_id └── URL template resolves → /crm/objects/2026-03/products/30258041729 └── Webhook #2 PATCH with updated {"properties": ...} └── HubSpot 200
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 →Create the "Product Create" Webhook
Register a single webhook on two triggers so the first publish and any new-product REST call both fire it.
step 1.1 — create the webhook
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Create Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products", "http_method": "POST", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": true, "retry_limit": 1, "triggers": ["woocommerce_new_product", "woocommerce_update_product"] }'
response — webhook ID 32
{ "id": "32", "name": "HS - Create Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products", "http_method": "POST" }
1.2 — Condition: only fire if the product has no HubSpot ID yet
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.
add condition
for TRIGGER in woocommerce_new_product woocommerce_update_product; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/32/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{"field": "_hs_existing_id", "operator": "is_empty"}] }, "conditions_evaluate_on": "transformed" }' done
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.Map WC Product → HubSpot Product, Capture the ID
2.1 — Pre-dispatch: map WC product to HubSpot properties
snippet code — PHP
// Pre-dispatch glue — payload shaping only. NO side effects. $product = $payload["args"][0] ?? []; $productId = is_object($product) ? $product->get_id() : ((int)($product["id"] ?? 0)); if (!$productId) return $payload; $wc = wc_get_product($productId); if (!$wc) return $payload; $existingId = (string) get_post_meta($productId, "_hubspot_product_id", true); return [ "_wc_product_id" => (string) $productId, "_hs_existing_id" => $existingId, "properties" => [ "name" => $wc->get_name(), "description" => wp_strip_all_tags($wc->get_short_description() ?: $wc->get_description()), "price" => (string) $wc->get_regular_price(), "hs_sku" => $wc->get_sku() ?: "wc-{$productId}", "hs_url" => get_permalink($productId), ], ];
$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.2.2 — Post-dispatch: store the returned HubSpot product ID
snippet code — PHP
// Post-dispatch glue — side effects allowed. $data = json_decode($responseBody, true); $productId = (int) ($payload["_wc_product_id"] ?? 0); if ($responseCode === 201 && !empty($data["id"]) && $productId) { update_post_meta($productId, "_hubspot_product_id", $data["id"]); update_post_meta($productId, "_hubspot_product_synced_at", current_time("mysql")); }
2.3 — Attach both snippets
attach pre + post glue to both triggers
for TRIGGER in woocommerce_new_product woocommerce_update_product; do curl -sk -X POST \ "$SITE/wp-json/fswa/v1/pro/trigger-snippets/32/trigger/$TRIGGER" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d "{"pre_snippet_id": $PRE_ID, "pre_enabled": true, "post_snippet_id": $POST_ID, "post_enabled": true}" done
Create the "Product Update" Webhook
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.
step 3.1 — create the update webhook
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Update Product", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/products/{{ _hs_product_id }}", "http_method": "PATCH", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": ["woocommerce_update_product"] }'
3.2 — Pre-glue: inject ID from product meta
snippet code — PHP
// Pre-dispatch glue — read product meta, shape PATCH body. $product = $payload["args"][0] ?? []; $productId = is_object($product) ? $product->get_id() : ((int)($product["id"] ?? 0)); $hsId = $productId ? (string) get_post_meta($productId, "_hubspot_product_id", true) : ""; if (!$hsId) return $payload; $wc = wc_get_product($productId); if (!$wc) return $payload; return [ "_hs_product_id" => $hsId, "properties" => [ "name" => $wc->get_name(), "description" => wp_strip_all_tags($wc->get_short_description() ?: $wc->get_description()), "price" => (string) $wc->get_regular_price(), "hs_sku" => $wc->get_sku() ?: "wc-{$productId}", ], ];
3.3 — Condition: only fire when there's an ID to PATCH against
add condition
curl -sk -X POST \ "$SITE/wp-json/fswa/v1/schemas/webhook/33/trigger/woocommerce_update_product" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [{"field": "_hs_product_id", "operator": "is_not_empty"}] }, "conditions_evaluate_on": "transformed" }'
_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.End-to-End Test — Publish, Edit, Verify
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.
One-Off Sync for Existing Products
If you already have a catalog, fire the create webhook once per existing product. The simplest path is WP-CLI:
wp-cli backfill loop
wp eval ' $products = wc_get_products(["limit" => -1, "status" => "publish", "return" => "ids"]); foreach ($products as $pid) { if (get_post_meta($pid, "_hubspot_product_id", true)) { continue; } do_action("woocommerce_new_product", $pid); echo "fired for product $pid "; }'
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.Things That Bite
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.
Common questions always ask.
Don't see yours? Open an issue on GitHub or check the full reference in the API docs.