Example · HubSpot × WooCommerce

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.

~14 min read May 15, 2026 Part 3 of 5
Filter OK fswa_webhook_payloadfswa_glue_post_dispatch Pro hubspotwoocommerceline-itemsassociations

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.
  • 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 below to retrofit Part 3 onto existing orders.
  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. 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, line items will still post but without hs_product_id association.

  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. 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..."
/ 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 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 → 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 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)
";
}'
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 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.

FAQ

Common questions always ask.

Don't see yours? Open an issue on GitHub or check the full reference in the API docs.

Why a chain webhook instead of a post-dispatch loop? +
Before Webhook Actions 1.13.0 this article used a post-glue loop calling wp_remote_post for each line item — functional but every sub-call was invisible to the Webhook Actions log, with no per-item retry or replay. Webhook Chains (v1.13.0+) make each downstream call a real webhook: its own log row, its own retry policy, its own replay button. Filter logs by chain to see all hops for one order side by side.
What if a WC product wasn't synced to HubSpot first (Part 2)? +
The pre-glue checks for _hubspot_product_id; if missing, it falls back to creating a free-text line item with just name + price + quantity (no hs_product_id association). You lose SKU-level reporting but the deal still has line items attached. Run the Part 2 backfill to retrofit.
Does this need Pro? +
Chains themselves are free in Webhook Actions v1.13.0+. Pro makes the pre-glue and post-glue payload shaping easy: snippets live in the admin and attach per webhook+trigger. 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.
Why inline associations on batch create instead of a second association webhook? +
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 into one. For line_item to deal we use HUBSPOT_DEFINED type id 20 (line_item_to_deal direction).
Will replaying the chain double the line items? +
Yes by default. Guards: (a) attach a per-trigger condition to the synthetic fswa_chain_link:N trigger saying 'skip if order has _hubspot_line_item_ids', or (b) add a pre-glue idempotency check that returns inputs:[] when the order is already synced. An empty-inputs POST is a no-op HubSpot accepts gracefully.
Ready

Stop losing webhooks.
Start logging them.

$ wp plugin install wp-webhooks --activate