WooCommerce HubSpot Integration: Sync Order Line Items to Deals
Builds on Parts 1 + 2. When the deal-create webhook returns 201, Webhook Actions's Webhook Chains feature fires one more webhook that POSTs every line item to HubSpot's /line_items/batch/create endpoint — with the deal↔line_item associations attached inline on each input, so the whole graph lands in a single HubSpot call. Each hop is a real webhook with its own log, retry, conditions, and replay — no wp_remote_post hidden inside post-glue.
TL;DR — What you'll build
- Webhook 1 — HS - Create Deal (from Part 1): stays unchanged. On
woocommerce_checkout_order_createdit POSTs the deal, post-glue persists_hubspot_deal_id. - Webhook 2 — HS - Batch Line Items (NEW, chain target): fires on the deal-create's 2xx response. Pre-glue reshapes
args[0](deal id + WC line_items + product meta) into HubSpot's batch body where each input carries its ownassociationsarray pointing at the deal — one POST to/crm/objects/2026-03/line_items/batch/createcreates the line items AND wires them to the deal. Post-glue stores the returned line_item IDs in WC order meta. - One chain link, full graph. A 20-item cart is one outbound batch instead of 40 sub-calls. Inline
associationson the batch create endpoint removes the need for a separate/v4/associations/.../batch/createhop. - Resolve
hs_product_idfrom WC product meta written by Part 2 inside the batch pre-glue — fall back to free-text line items for products not yet synced.
What You Need Before Starting
_hubspot_product_id from Part 2. Run them in order — or use the Backfill section below to retrofit Part 3 onto existing orders.- Part 1 complete: deal-create webhook + post-glue persisting
_hubspot_deal_idVerify by inspecting a recent test order's post meta —
_hubspot_deal_idshould hold a HubSpot record ID. - Part 2 complete: product sync + post-glue persisting
_hubspot_product_idVerify by inspecting a recent product's post meta. If you skipped Part 2, line items will still post but without
hs_product_idassociation. - HubSpot scopes added:
crm.objects.line_items.write+crm.objects.line_items.readEdit the same Private App from Parts 1+2. After saving, copy the new token if HubSpot regenerated it.
- Shell env from previous parts
Re-export if needed.
re-export if needed
export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..."
One WC Event, One Chain Link
Before Webhook Actions 1.13.0 this article shipped with a post-glue loop that called wp_remote_post N times per order — functional, but every sub-call was invisible to the Webhook Actions log, with no retries, no conditions, no replay button per item. Webhook Chains replace that pattern: a webhook completing successfully (2xx) becomes the trigger for the next webhook in the chain, which receives the upstream response, the payload sent, and the pre-mapping original payload as its starting args[0].
For line items we collapse the loop into a single batch call. HubSpot's /crm/objects/2026-03/line_items/batch/create takes up to 100 inputs per request, and each input can carry an associations array that wires the new line_item to existing records (the deal we just created) at creation time. Two chained webhooks, two log rows, full graph in HubSpot — even a 20-item cart is one outbound deal POST + one outbound line-items batch.
flow diagram
# PART 1 (unchanged): create the deal WooCommerce order placed └── fires woocommerce_checkout_order_created └── Webhook #31 POST /crm/objects/2026-03/deals → 201 deal_id=502475571425 └── post-glue (Part 1): persist _hubspot_deal_id to WC order meta └── 2xx → fswa_glue_post_dispatch → ChainDispatcher fires next link # PART 3 (NEW): one batch webhook, chain-triggered, with inline associations └── Webhook #53 HS - Batch Line Items (chain target, trigger fswa_chain_link:N) └── pre-glue: build {inputs:[{properties, associations:[deal]}, ...]} └── POST /crm/objects/2026-03/line_items/batch/create → 201 results[3 ids, all associated to the deal] └── post-glue: persist _hubspot_line_item_ids to WC order meta └── done. Full graph in HubSpot.
Webhook 2 — HS - Batch Line Items
Create the first chain target: a webhook with no WP-hook triggers, configured for HubSpot's line_items batch endpoint. It will be wired as the chain target of Webhook 31 (HS - Create Deal) from Part 1, so it fires automatically on every deal-create success.
1.1 — Create the webhook via REST
create webhook 53 — no WP triggers (chain-only)
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Batch Line Items", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/line_items/batch/create", "http_method": "POST", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }'
response — 200 OK
{ "id": "53", "webhook_uuid": "b41fc8...", "name": "HS - Batch Line Items", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/line_items/batch/create", "triggers": [] }
Empty triggers: [] is allowed in v1.13.0+ — the webhook will be triggered exclusively by the chain link we add in step 1.4. Without a chain link this webhook is dormant.
1.2 — Pre-dispatch Code Glue: build the batch inputs
The chain target receives the upstream deal-create response and the original WC payload as args[0]. The pre-glue's job is to reshape that into HubSpot's batch body: { inputs: [{properties: {...}}, ...] }. Payload shaping only — no outbound HTTP, no DB writes here.
snippet code — pre-dispatch glue
// Read chain args. $payload is the chain payload built by ChainDispatcher. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); // Recover the WC order id. Resolution order: // 1) _wc_order_id if Part 1's pre-glue stashed it in the sent payload // 2) payload.args[0] — woocommerce_checkout_order_created passes the order_id scalar // 3) original_payload.args[0] — same data, set when mapping is applied $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); if (!$dealId || !$wcOrderId) return ["inputs" => []]; $order = wc_get_order($wcOrderId); if (!$order) return ["inputs" => []]; $inputs = []; $wcItemIds = []; // parallel array so post-glue can map result[i] back to a WC order item id foreach ($order->get_items() as $itemId => $item) { $wcProductId = $item->get_variation_id() ?: $item->get_product_id(); $hsProductId = (string) get_post_meta($wcProductId, "_hubspot_product_id", true); $props = [ "name" => $item->get_name(), "quantity" => (string) $item->get_quantity(), "price" => (string) ($item->get_total() / max(1, $item->get_quantity())), "amount" => (string) $item->get_total(), ]; if ($hsProductId) { $props["hs_product_id"] = $hsProductId; } // Inline association: line_item → deal (HUBSPOT_DEFINED type id 20). // HubSpot creates the line item AND wires it to the deal in this same batch call. $inputs[] = [ "properties" => $props, "associations" => [[ "to" => ["id" => $dealId], "types" => [[ "associationCategory" => "HUBSPOT_DEFINED", "associationTypeId" => 20, // line_item_to_deal ]], ]], ]; $wcItemIds[] = (int) $itemId; } // Side-channel: HubSpot ignores unknown top-level fields on this endpoint. // Post-glue reads these to map result[i].id back to the corresponding WC order item. return [ "inputs" => $inputs, "_wc_order_id" => $wcOrderId, "_wc_item_ids" => $wcItemIds, ];
inputs from the body and ignores extra top-level fields like _wc_order_id / _wc_item_ids. We use those fields as a side-channel for the post-glue — they show up in the Webhook Actions log's sent-payload column but never reach HubSpot's parser. If your downstream API is strict, use Webhook Actions's excluded paths field-mapping option to drop them before send.1.3 — Post-dispatch Code Glue: persist the line_item ids
snippet code — post-dispatch glue
$body = json_decode($responseBody, true); $results = $body["results"] ?? []; // Side-channel data stashed by pre-glue. $orderId = (int) ($payload["_wc_order_id"] ?? 0); $wcItemIds = (array) ($payload["_wc_item_ids"] ?? []); if ($responseCode < 200 || $responseCode >= 300 || !$orderId) return; $order = wc_get_order($orderId); if (!$order) return; $liIds = []; // HubSpot returns results in the same order we sent inputs — we can zip by index. foreach ($results as $idx => $r) { $liId = (string) ($r["id"] ?? ""); if (!$liId) continue; $liIds[] = $liId; // Per-WC-order-item meta — survives refunds + per-line edits + downstream sync. $wcItemId = (int) ($wcItemIds[$idx] ?? 0); if ($wcItemId) { wc_update_order_item_meta($wcItemId, "_hubspot_line_item_id", $liId); } } // Order-level rollup for quick lookups + replay guards. $order->update_meta_data("_hubspot_line_item_ids", $liIds); $order->save();
wc_update_order_item_meta, written to wp_woocommerce_order_itemmeta) makes per-line operations cheap later: refund a single item and sync just that refund to HubSpot; edit a line quantity and PATCH only the matching hs_line_item; report mismatches per row. The order-level _hubspot_line_item_ids array is the fast rollup for "does this order already have line items synced?" replay guards.1.4 — Attach both snippets, then wire as chain target
Save the two snippets in the Webhook Actions snippet library, attach them to webhook 53 (no trigger needed — chains use the synthetic fswa_chain_link:N trigger which gets bound when you link the webhooks). Then in the admin: edit webhook 53, toggle "Use other Webhooks as triggers", pick the existing chain (or create one named "HS - Order Line Items") and select Webhook 31 (HS - Create Deal) as the source.
Or wire it via REST:
create the chain (if it doesn't exist)
curl -sk -X POST "$SITE/wp-json/fswa/v1/chains" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "HS - Order Line Items", "description": "Deal create → batch line items → batch associations"}' # → {"id":"10","name":"HS - Order Line Items",...}
link webhook 31 → webhook 53
curl -sk -X POST "$SITE/wp-json/fswa/v1/chains/10/links" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"source_webhook_id": 31, "target_webhook_id": 53}'
Why is_synchronous: false? The deal-create webhook stays synchronous (Part 1 needs the response inline). Chain targets fire on the queue by default — if HubSpot rate-limits the line_items batch or returns a 5xx, retries happen in the background without blocking the WC checkout response. Customer hits the thank-you page in milliseconds; line items land in HubSpot within seconds.
When to flip to sync. Set is_synchronous: true on this chain target when (a) you have a post-checkout thank-you page that reads HubSpot data directly, (b) an internal CRM-driven email / Slack notification fires next and needs the full graph immediately, or (c) the store is internal / B2B where checkout latency doesn't matter. Cost: ~150–400ms HubSpot round-trip added to the checkout response, plus checkout blocks if HubSpot has an outage. For most public storefronts, async is the safer default.
End-to-End Test — Three-Line Order
Place a test order with three different products in the cart. The deal-create webhook (Part 1) fires once and returns the deal ID. On its 2xx response the chain dispatcher fires the line-items batch webhook, which POSTs all three line items + their deal associations in a single call.
Open HubSpot → CRM → Deals → (your test deal). Scroll to the Line items section — you should see all three products listed with their names, quantities, and total amounts. Each hs_product_id link should resolve to the matching HubSpot product from Part 2.
Verify in Webhook Actions. Filter the Logs view by chain HS - Order Line Items: you should see exactly two rows for the test order — one woocommerce_checkout_order_created dispatch (the deal create, status success, 201) and one fswa_chain_link:N dispatch (the line-items batch, status success, 201) sharing the same event UUID.
Verify in WC. Inspect the order's meta — _hubspot_deal_id holds the deal ID, _hubspot_line_item_ids holds the array of HubSpot line_item record IDs. Each WC order item also carries an individual _hubspot_line_item_id in wp_woocommerce_order_itemmeta:
wp-cli inspection
wp wc shell <<'PHP' $order = wc_get_order($ORDER_ID); echo "deal_id: " . $order->get_meta("_hubspot_deal_id") . PHP_EOL; echo "line_item_ids: " . json_encode($order->get_meta("_hubspot_line_item_ids")) . PHP_EOL; foreach ($order->get_items() as $itemId => $item) { echo $itemId . " → " . wc_get_order_item_meta($itemId, "_hubspot_line_item_id") . PHP_EOL; } PHP # deal_id: 502475571425 # line_item_ids: ["18923771401","18923771402","18923771403"] # 9201 → 18923771401 # 9202 → 18923771402 # 9203 → 18923771403
Retrofit Existing Orders
If you already have orders with HubSpot deals but no line items, run a one-off backfill that calls the same post-glue logic for each existing order:
wp-cli backfill
wp eval ' $orders = wc_get_orders(["limit" => -1, "status" => ["processing","completed"], "return" => "ids"]); foreach ($orders as $oid) { $dealId = get_post_meta($oid, "_hubspot_deal_id", true); $existingLi = get_post_meta($oid, "_hubspot_line_item_ids", true); if (!$dealId || $existingLi) continue; // skip orders without deal or already done // Simulate the post-glue context: payload, responseCode, responseBody do_action("fswa_glue_backfill_line_items", $oid, $dealId); echo "backfilled order $oid (deal $dealId) "; }'
fswa_glue_backfill_line_items in your mu-plugin to the same code as the Part 3 block above (extracted to a function). This keeps the line-item loop testable in isolation and reusable from both the live post-glue and the WP-CLI backfill.Things That Bite
100 inputs per batch limit. HubSpot's /line_items/batch/create accepts up to 100 inputs per request. A single cart over 100 items would need chunking — rare for B2C but possible for B2B order forms. The pre-glue can chunk using array_chunk — but then the batch endpoint only takes one chunk per dispatch, so keep the cart-size assumption documented and add a fallback warning if count($inputs) > 100.
Missing hs_product_id. If a product was created in WC before Part 2 ran, no _hubspot_product_id meta exists. The pre-glue falls back to creating a free-text line item — visible in HubSpot but with no SKU link. Run the Part 2 backfill first to fix it across the catalogue.
Association type id 20 (line_item_to_deal) vs 19 (deal_to_line_item). The direction matters in v4 associations — we create the line_item, so the edge is FROM line_item TO deal, which is HUBSPOT_DEFINED type 20. Reading associations on the deal returns type 19 (the inverse). Mixing them gets you a 400 from HubSpot. Look up the canonical id at /crm/v4/associations/line_items/deals/labels.
Currency precision. Some HubSpot line_item reports round to two decimals while WC stores totals at higher precision. The pre-glue casts to string before sending; HubSpot accepts string numerics for currency properties.
Replay doubles line items. The chain target webhook is not idempotent by default. Replaying the deal-create from the Webhook Actions log re-fires the chain and creates a second set of line items. Two guard strategies: (a) add a condition on the chain link "skip if order already has _hubspot_line_item_ids", or (b) add a check at the top of the pre-glue: if ($order->get_meta("_hubspot_line_item_ids")) return ["inputs" => []]; — an empty inputs array is a no-op POST that HubSpot accepts and the chain still completes cleanly.
Common questions always ask.
Don't see yours? Open an issue on GitHub or check the full reference in the API docs.