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

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_created it 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 own associations array pointing at the deal — one POST to /crm/objects/2026-03/line_items/batch/create creates 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 associations on the batch create endpoint removes the need for a separate /v4/associations/.../batch/create hop. Every hop is its own Webhook Actions log row — replay individually, filter by chain.
  • Resolve hs_product_id from WC product meta written by Part 2 inside the batch pre-glue — fall back to free-text line items for products not yet synced.
/ Prerequisites

What You Need Before Starting

Part 3 is the first example in the series that requires the previous two. The post-glue assumes (a) a HubSpot deal ID has been written by Part 1's create flow and (b) WC products carry _hubspot_product_id from Part 2. Run them in order — or use the Backfill section at the bottom to retrofit Part 3 onto existing orders.

  1. 1
    Part 1 complete: deal-create webhook + post-glue persisting _hubspot_deal_id

    Verify by inspecting a recent test order's post meta — _hubspot_deal_id should hold a HubSpot record ID.

  2. 2
    Part 2 complete: product sync + post-glue persisting _hubspot_product_id

    Verify by inspecting a recent product's post meta. If you skipped Part 2, the line items will still post but without hs_product_id association.

  3. 3
    HubSpot scopes added: crm.objects.line_items.write + crm.objects.line_items.read

    Edit the same Private App from Parts 1+2. After saving, copy the new token if HubSpot regenerated it.

  4. 4
    Shell env from previous parts
    re-export if needed
    export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..."
/ Architecture

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.

Why this beats the old loop: filter Logs by HS - Order Line Items chain and you see all three rows for a single order, in order, with their own retry/replay buttons. If the association batch fails because HubSpot rate-limited mid-burst, you replay one row — not the entire deal-create flow with its double-line-item risk.

/ Step 1 Filter OK fswa_webhook_payloadfswa_glue_post_dispatch Pro

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

HubSpot only reads 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();

Per-order-item meta vs order meta. Storing the HubSpot id on each WC order item (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.

/ Step 2

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.

HubSpot Deal detail page with the Line items card open showing three line items from a WooCommerce order, each with name, quantity, and amount
HubSpot → Deal detail → Line items card showing all three products from the WooCommerce order with quantities and amounts, each associated to the deal inline via the batch/create endpoint.

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
Webhook Actions admin Logs view filtered by chain HS - Order Line Items showing two rows: deal-create 201 and line-items batch chain-link 201 with the humanized chain pill
Webhook Actions admin → Logs filtered by chain HS - Order Line Items. Deal-create row (201) and the line-items batch chain-link row (201) for the same order, with the humanized Chain HS - Order Line Items ← HS - Create Deal pill on the chain-link row.
/ Backfill

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

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

/ Common Pitfalls

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: build N input arrays of ≤100 items each and call array_chunk — but then the batch endpoint only takes one chunk per dispatch, so for now 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" (Webhook Actions's per-trigger conditions ride along on the synthetic fswa_chain_link:N trigger), 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.

WC may pass the order as scalar OR object. woocommerce_checkout_order_created historically passes $order (object) on classic checkout and the order id (scalar) on Block checkout's Store API path. The pre-glue handles both by checking $chain["payload"]["args"][0] — if it's a scalar it's the id; if it's an array, reach into ["id"]. The provided snippet uses the scalar path which is what Webhook Actions's normalizeArgs ultimately serializes to in 1.13.0+.

/ FAQ

Common Questions

Before Webhook Actions 1.13.0 this article used a post-glue loop calling wp_remote_post for each line item. That worked but every sub-call was invisible to the Webhook Actions log, with no per-item retry or replay. Chains make each downstream call a real webhook: its own log row, its own retry policy, its own replay button. Filter logs by chain HS - Order Line Items to see all hops for one order side by side.
The pre-glue checks for _hubspot_product_id; if missing, it falls back to a free-text line item (name + price + quantity, no hs_product_id). You lose SKU-level reporting but the deal still has line items attached. Run the Part 2 backfill to retrofit.
Chains themselves are free in v1.13.0+. The Pro plugin is what makes the pre-glue and post-glue payload shaping easy: snippets live in the admin, get attached per webhook+trigger, and are editable without touching theme code. The free-plugin alternative is to hook fswa_webhook_payload (pre-dispatch shape) and fswa_glue_post_dispatch (post-dispatch persist) in your own mu-plugin — same hooks under the hood.
HubSpot's /crm/objects/2026-03/line_items/batch/create accepts an associations array per input that wires the new line_item to existing records at creation time. That collapses what used to be two HubSpot round-trips (create line item, then PUT association) into one. For line_item↔deal we use HUBSPOT_DEFINED type id 20 (line_item_to_deal direction). Part 5 still uses the standalone /v4/associations/.../batch/create endpoint because there we associate already-existing objects (a separately-created contact to a deal).
HubSpot's batch endpoint caps at 100 inputs per request. Carts over 100 items need chunking. Simplest option: extend the pre-glue to slice the inputs, but a single chain webhook can only dispatch once per trigger. For real-world B2B order forms with 100+ lines, either (a) raise a separate Webhook Actions trigger per chunk, or (b) accept that line items beyond 100 are skipped and add a Pitfalls warning. B2C carts almost never hit this.
Yes by default. Two guards: (a) attach a per-trigger condition to fswa_chain_link:N in the Webhook Actions admin that says "skip if order has _hubspot_line_item_ids" — conditions ride along on the synthetic chain trigger, (b) add an idempotency check at the top of the pre-glue that returns {"inputs": []} when the order is already synced. An empty-inputs POST is a no-op HubSpot accepts gracefully.
/ HubSpot × WooCommerce Series