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
woocommerce_checkout_order_created it POSTs the deal, post-glue persists _hubspot_deal_id.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.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.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.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.
_hubspot_deal_id
Verify by inspecting a recent test order's post meta — _hubspot_deal_id should hold a HubSpot record ID.
_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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
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.
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:
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:
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.
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+.
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.
_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.
fswa_webhook_payload (pre-dispatch shape) and fswa_glue_post_dispatch (post-dispatch persist) in your own mu-plugin — same hooks under the hood.
/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).
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.