/ Part 2 of 5 — HubSpot × WooCommerce Series Filter OK Pro

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 to https://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
/ Prerequisites

What You Need Before Starting

This 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.

  1. 1
    WordPress + WooCommerce with at least one product to test

    Triggers fire for any product type — simple, variable, virtual, downloadable.

  2. 2
    Webhook Actions (free) v1.12.0+

    Search for FlowSystems under Plugins → Add Plugin.

  3. 3
    Webhook Actions 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.

  4. 4
    HubSpot Private App token with product scopes

    If you followed Part 1, edit your existing app and add e-commerce scope. Copy the new token.

  5. 5
    Webhook Actions API token with full scope

    Create 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..."
/ Architecture

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

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 →

/ Step 1

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

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.

/ Step 2 Pro

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), ], ];

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.

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

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
Webhook Actions admin Code Glue Snippets list with the Product Create pre-dispatch and post-dispatch snippets attached to webhook 32
Webhook Actions admin → Code Glue Snippets list with Product Create pre + post snippets attached to webhook 32.
/ Step 3 Pro

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

// 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

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" }'

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.

/ Step 4

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.

HubSpot CRM Products view with the newly synced WooCommerce widget visible in the products list
HubSpot → CRM → Products with the newly synced WooCommerce widget visible.
Webhook Actions admin Logs page showing webhook 32 returning 201 on first publish, then webhook 32 skipped and webhook 33 returning 200 on the second edit
Webhook Actions admin → Logs showing webhook 32 (201) on first publish, webhook 32 (skipped) + webhook 33 (200) on the second edit.
/ Backfill

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\n"; }'

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.

/ Common Pitfalls

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.

/ FAQ

Common Questions

HubSpot's line_items API requires an 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.
No — one-way push runs on the free plugin with static Mapping. Pro is required if you want post-dispatch persistence of hs_product_id (which Part 3 needs).
Not with the two-webhook condition split. Webhook 32 has _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.
The 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.
WooCommerce variations are separate 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).
/ HubSpot × WooCommerce Series