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

HubSpot WordPress Plugin:
Full Customer & Order Sync

The capstone. Tie contact (Part 4) ↔ deal (Part 1) ↔ line items (Part 3) ↔ products (Part 2) into one coherent HubSpot graph on every WooCommerce order — using Webhook Actions Webhook Chains to sequence each step as a real, observable, replayable webhook. Part 1 fires the deal, Part 3's chain creates the line items with inline deal associations, Part 5 adds the contact↔deal edge via a single batch-association call. Every hop is its own log row.

TL;DR — The dual-path chain

  • One trigger fans out a 4-link chain. woocommerce_checkout_order_created fires Webhook 31 (HS - Create Deal, sync). On 2xx, the chain dispatches three targets in parallel: line items batch (Part 3), existing-contact PUT, and new-contact upsert. Conditions on the chain links decide which contact path actually runs.
  • Existing customer (contact_id already in user_meta): Webhook 54 fires a single PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did} — one HubSpot call, association done.
  • New customer (no contact_id): Webhook 55 POSTs to /crm/objects/2026-03/contacts/batch/upsert to create the contact, then chains forward to Webhook 54 which PUTs the contact↔deal association. Two HubSpot calls, atomic in chain order.
  • One webhook, two pre-glue snippets. Webhook 54 is wired as the target of TWO chain links (from Webhook 31 for the existing path, from Webhook 55 for the new path). Each chain link has its own pre-glue snippet on the same webhook — different ways to resolve the same {{ _hubspot_contact_id }} URL template variable.
  • Idempotent end-to-end. Each path stamps _hubspot_contact_deal_associated on the order only after the actual PUT lands. Replaying the deal-create row re-runs the chain; pre-glue guards return empty inputs (line items) or zero-out the URL params (association) so HubSpot sees no-op writes. No duplicates.
/ Prerequisites

All Four Earlier Parts Working in Isolation

Part 5 is the only example in the series that requires all four others. If any of Parts 1-4 isn't returning 2xx in your dev environment, fix that before wiring the graph — debugging four entangled flows at once is misery. Each part is verifiable on its own; verify them one by one.

  1. 1
    Part 1 shipping _hubspot_deal_id on every order

    Verified by inspecting recent test order post meta.

  2. 2
    Part 2 shipping _hubspot_product_id on every product

    Verified by inspecting a recently published product.

  3. 3
    Part 3 shipping _hubspot_line_item_ids on every order

    Confirms the post-glue loop is firing and resolving product IDs.

  4. 4
    Part 4 (optional) — Sync registered WP users to HubSpot contacts on user_register

    This pre-populates user_meta._hubspot_contact_id so order-time flows hit the cheaper existing-contact PUT path. Part 5 self-heals for new emails by upserting the contact in-chain — Part 4 is purely an optimization.

  5. 5
    HubSpot scopes: deals, contacts, products, line_items, plus crm.objects.contacts.read + crm.associations.read

    The associations writes don't need a separate scope — they're covered by the object-write scopes — but reads (used by the verification step) need the read scope.

/ Architecture

One Webhook, Two Conditional Paths

Only Webhook 31 (HS - Create Deal) listens to woocommerce_checkout_order_created directly. On its 2xx, the chain dispatcher fires three child webhooks in parallel — but two of them carry mutually exclusive conditions on the transformed payload, so exactly one fires per order based on whether the customer's HubSpot contact_id is already known.

Existing customer (user_meta has _hubspot_contact_id from a prior order or from Part 4's user_register sync) → Webhook 54 fires directly, PUT default association, done in one call. New customer (no contact_id) → Webhook 55 upserts the contact via 2026-03/contacts/batch/upsert, and on its 2xx the chain dispatches Webhook 54 again (now with the freshly-created contact_id from the upstream response) to PUT the association. Two calls for new customers, one for repeats.

Why Webhook 54 appears twice in the diagram below: it's the same webhook (same name, same PUT method, same URL template), but it's the target of two different chain links. Each chain link has its own pre-glue snippet on Webhook 54 — one reads contact_id from user_meta, the other reads it from the upstream upsert response. The condition _hubspot_contact_id is_not_empty AND _hubspot_deal_id is_not_empty guards both links, so neither fires until those URL template variables are populated.

full chain diagram
# ORDER PLACED — single WC event, single root webhook, chain fans out WC checkout completes └── fires woocommerce_checkout_order_created └── Webhook #31 HS - Create Deal (sync) POST /crm/objects/2026-03/deals → 201 deal_id └── 2xx → ChainDispatcher fires three chain links in parallel: ├── link 11 → Webhook #53 HS - Batch Line Items POST /line_items/batch/create → 201 └── always fires (Part 3) ├── link 12 → Webhook #54 HS - Associate Existing Contact PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did} └── condition: _hubspot_contact_id is_not_empty AND _hubspot_deal_id is_not_empty └── pre-glue reads contact_id from user_meta or order_meta └── link 13 → Webhook #55 HS - Upsert Contact POST /crm/objects/2026-03/contacts/batch/upsert └── condition: _hubspot_contact_id is_empty AND _hubspot_deal_id is_not_empty └── 2xx → post-glue writes contact_id to user_meta + order_meta └── 2xx → ChainDispatcher fires: └── link 14 → Webhook #54 (again, different pre-glue) PUT /crm/objects/2026-03/contacts/{new_cid}/associations/default/deals/{did} → 200 └── pre-glue reads contact_id from $chain["response"]["body"]["results"][0]["id"] (upstream upsert) # HubSpot graph after one order — same end state both paths: # # contact ─── deal ───┬─── line_item ─── product # ├─── line_item ─── product # └─── line_item ─── product

Why upsert + chained PUT instead of a single atomic call? HubSpot's 2026-03/contacts/batch/upsert endpoint silently ignores inline associations arrays in the inputs (the docs don't surface this — we learned it the hard way). Only batch/create supports inline assoc, but create 409s on duplicate email. Upsert is the only safe write, so the contact↔deal edge has to be a follow-up call. Modeling it as a chain link keeps that follow-up observable (own log row, own retry, own replay) instead of a hidden wp_remote_post in post-glue.

Replay safety. Each path stamps _hubspot_contact_deal_associated on the order only after the actual PUT lands (not after the upsert). Replaying the deal-create row re-fires the chain; pre-glue on Webhook 54 zeros out _hubspot_deal_id when the marker is present, the condition fails, the call is skipped. HubSpot sees one new deal (Part 1 isn't idempotent without modification) but no duplicate line items and no duplicate association.

/ Step 1

Disable Part 4's Order-Time Contact Webhook (if you have it)

Older versions of Part 4 wired an async contact-upsert webhook onto woocommerce_checkout_order_created to populate _hubspot_contact_id before deal-create. The new design moves that work into the chain via Webhook 55, which eliminates a race condition (the async webhook hadn't finished by the time the deal-create chain dispatched). If you have an old Webhook 34 named HS - Upsert Contact, disable it.

disable order-time contact webhook (if it exists)
curl -sk -X PUT "$SITE/wp-json/fswa/v1/webhooks/34" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"is_enabled": false}'

Don't delete it — disabling preserves the snippet code and the schema row in case you want to compare. After end-to-end testing the new design, you can delete it cleanly. The new Part 4 moves this same work to user_register — a separate optional sync for registered users that pre-populates user_meta._hubspot_contact_id so the order-time chain hits the cheaper PUT path on the first order, not the second.

/ Step 2 Filter OK fswa_webhook_payloadfswa_webhook_url Pro

Webhook 54 — HS - Associate Existing Contact (PUT)

Webhook 54 is the workhorse of the association flow. It uses HubSpot's v4 default-association PUT endpoint, which takes the contact ID and deal ID directly in the URL and an empty body. One call, one association, idempotent on replay.

We use a URL template (Pro fswa_webhook_url filter) so the same webhook config serves both the existing-customer path (chain link 12 from Webhook 31, contact_id from user_meta) and the new-customer path (chain link 14 from Webhook 55, contact_id from the upstream upsert response). One webhook config, two pre-glue snippets — that's the trick.

2.1 — Create the webhook with URL template

create webhook 54 (PUT default association with URL template)
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Associate Existing Contact (PUT)", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/{{ _hubspot_contact_id }}/associations/default/deals/{{ _hubspot_deal_id }}", "http_method": "PUT", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }' # → {"id":"54",...}

URL template substitution. The {{ _hubspot_contact_id }} and {{ _hubspot_deal_id }} placeholders are resolved from the transformed payload (whatever pre-glue returns). They're side-channel fields — HubSpot ignores them in the request body. Pro's fswa_webhook_url filter does the substitution; the free-plugin equivalent is implementing the same filter in a mu-plugin.

2.2 — Pre-dispatch Code Glue: look up contact_id from user_meta

This snippet runs on chain link 12 (Webhook 31 → 54). It resolves _hubspot_contact_id from user_meta (preferred — survives across orders) with order_meta fallback for guest checkout. If the contact isn't known, it returns an empty _hubspot_contact_id field — the chain link condition (set in step 2.4) then fails and Webhook 54 doesn't fire. Webhook 55 picks up the new-customer path instead.

snippet code — pre-dispatch glue (existing-customer path)
// Pre-dispatch glue for webhook 54 (chain link 12, from Webhook 31). // Payload shaping only. Returns _hubspot_contact_id + _hubspot_deal_id as // side-channel fields that the URL template and condition read. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); $contactId = ""; $alreadyAssociated = false; if ($dealId && $wcOrderId) { $order = wc_get_order($wcOrderId); if ($order) { $alreadyAssociated = (bool) $order->get_meta("_hubspot_contact_deal_associated"); $userId = (int) $order->get_user_id(); if ($userId) { $contactId = (string) get_user_meta($userId, "_hubspot_contact_id", true); } if (!$contactId) { $contactId = (string) $order->get_meta("_hubspot_contact_id"); } } } // Idempotency: if already associated, zero out deal_id so the condition fails. if ($alreadyAssociated) { return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => "", "_wc_order_id" => (string) $wcOrderId, ]; } return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, ];

Use the 2026-03 default-association PUT, not the v4 batch endpoint. Earlier drafts of this article POSTed to /crm/v4/associations/contacts/deals/batch/create with a batch of inputs — functional, but the 2026-03 default-association PUT (/crm/objects/2026-03/{from}/{id}/associations/default/{to}/{id}) is the simpler call. No request body, no associationTypeId bookkeeping (default picks the canonical type), and the URL itself encodes the relationship direction. One PUT, one association, idempotent on replay. Use the v4 batch only when you need labeled/custom association types.

2.3 — Post-dispatch Code Glue: stamp the replay marker

snippet code — post-dispatch glue
// Stamps the idempotency marker only on 2xx. Same snippet is reused for // chain link 14 (the new-customer path) — both paths land here on success. if ($responseCode < 200 || $responseCode >= 300) return; $orderId = (int) ($payload["_wc_order_id"] ?? 0); if (!$orderId) return; $order = wc_get_order($orderId); if ($order) { $order->update_meta_data("_hubspot_contact_deal_associated", gmdate("c")); $order->save(); }

2.4 — Wire as chain target + add the condition

Add Webhook 54 as a second chain target of Webhook 31 (Part 3 already added the line-items batch as the first). Then set a per-trigger condition so the link only fires when both URL template variables are populated.

add chain link 31 → 54 (assumes chain 10 from Part 3)
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": 54}' # → {"id":"12",...} the link id becomes part of the synthetic trigger name
attach pre + post snippets to the new chain trigger
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/trigger-snippets/54/trigger/fswa_chain_link%3A12" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "pre_snippet_id": 19, "pre_enabled": true, "post_snippet_id": 20, "post_enabled": true }'
set condition on the chain trigger (evaluate against transformed payload)
curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/54/trigger/fswa_chain_link%3A12" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [ {"field": "_hubspot_deal_id", "operator": "is_not_empty", "value": "", "cast": "string"}, {"field": "_hubspot_contact_id", "operator": "is_not_empty", "value": "", "cast": "string"} ] }, "conditions_evaluate_on": "transformed" }'

Why the condition matters. Without it, Webhook 54 would fire on every order — for new customers it would PUT to /contacts//associations/default/deals/... with an empty contact_id and HubSpot would 404. The condition + the side-channel _hubspot_contact_id field act as a routing fork: existing customer (field populated) → fires; new customer (field empty) → skipped, Webhook 55 picks it up.

/ Step 3 Filter OK fswa_webhook_payloadfswa_glue_post_dispatch Pro

Webhook 55 — HS - Upsert Contact (new-customer path)

For orders where the customer has no _hubspot_contact_id in user_meta yet, Webhook 55 creates the contact via 2026-03/contacts/batch/upsert. Then chain link 14 fires Webhook 54 again — same webhook config as Step 2, but with a different pre-glue snippet that reads the freshly-created contact_id from the upstream upsert response.

3.1 — Create the upsert webhook

create webhook 55
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "name": "HS - Upsert Contact + Associate to Deal (POST)", "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert", "http_method": "POST", "auth_header": "Bearer '"$HS_PAT"'", "is_enabled": true, "is_synchronous": false, "retry_limit": 2, "triggers": [] }' # → {"id":"55",...}

3.2 — Pre-dispatch glue: build the contact upsert body

Same field-resolution logic as Webhook 54's pre-glue, but the output shape is HubSpot's batch/upsert input format. _hubspot_contact_id is set to the user_meta value (empty for new customers) — the chain link condition reads this field to decide whether to fire.

snippet code — pre-dispatch glue
// Pre-dispatch glue for webhook 55. Resolves contact_id (for the condition // check) AND builds the upsert input from WC billing data. $chain = $payload["args"][0] ?? []; $dealId = (string) ($chain["response"]["body"]["id"] ?? ""); $wcOrderId = (int) ( $chain["payload"]["_wc_order_id"] ?? $chain["payload"]["args"][0] ?? $chain["original_payload"]["args"][0] ?? 0 ); if (!$dealId || !$wcOrderId) { return ["_hubspot_contact_id" => "skip", "_hubspot_deal_id" => "", "inputs" => []]; } $order = wc_get_order($wcOrderId); if (!$order) { return ["_hubspot_contact_id" => "skip", "_hubspot_deal_id" => "", "inputs" => []]; } $alreadyAssociated = (bool) $order->get_meta("_hubspot_contact_deal_associated"); $userId = (int) $order->get_user_id(); $contactId = ""; if ($userId) { $contactId = (string) get_user_meta($userId, "_hubspot_contact_id", true); } if (!$contactId) { $contactId = (string) $order->get_meta("_hubspot_contact_id"); } if ($alreadyAssociated) { return [ "_hubspot_contact_id" => $contactId ?: "skip", "_hubspot_deal_id" => "", "inputs" => [], ]; } $email = strtolower(trim($order->get_billing_email())); $phone = preg_replace('/[^\d+]/', '', $order->get_billing_phone()); return [ "_hubspot_contact_id" => $contactId, // empty for new customer → condition is_empty passes "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, "_wc_user_id" => (string) $userId, "inputs" => [[ "idProperty" => "email", "id" => $email, "properties" => [ "email" => $email, "firstname" => trim($order->get_billing_first_name()), "lastname" => trim($order->get_billing_last_name()), "phone" => $phone, "company" => trim($order->get_billing_company()), "city" => trim($order->get_billing_city()), "country" => trim($order->get_billing_country()), "lifecyclestage" => "customer", ], ]], ];

HubSpot's contacts/batch/upsert silently ignores inline associations arrays. Only batch/create supports inline assoc — batch/upsert drops the field with no warning and returns 200 like nothing's wrong. The HubSpot docs don't surface this. This is why we need the follow-up PUT via chain link 14 instead of a single atomic call.

3.3 — Post-dispatch glue: store contact_id (but NOT the assoc marker)

Webhook 55's post-glue writes the new contact_id to user_meta + order_meta so it's available for the next order. It must NOT stamp _hubspot_contact_deal_associated — that marker is reserved for Webhook 54's success. If we stamped it here, the follow-up chain link 14 would see the marker and short-circuit before doing the actual association.

snippet code — post-dispatch glue
if ($responseCode < 200 || $responseCode >= 300) return; $data = json_decode($responseBody, true); $result = $data["results"][0] ?? []; $contactId = (string) ($result["id"] ?? ""); $wasNew = !empty($result["new"]); $orderId = (int) ($payload["_wc_order_id"] ?? 0); $userId = (int) ($payload["_wc_user_id"] ?? 0); if (!$contactId || !$orderId) return; $order = wc_get_order($orderId); if ($order) { $order->update_meta_data("_hubspot_contact_id", $contactId); $order->update_meta_data("_hubspot_contact_was_new", $wasNew ? "1" : "0"); $order->save(); } if ($userId) { update_user_meta($userId, "_hubspot_contact_id", $contactId); }

3.4 — Wire as third chain target of Webhook 31 + opposite condition

add chain link 31 → 55
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": 55}' # → {"id":"13",...}
attach snippets to webhook 55's chain trigger
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/trigger-snippets/55/trigger/fswa_chain_link%3A13" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": 23, "pre_enabled": true, "post_snippet_id": 24, "post_enabled": true}'
condition: fires only when contact_id is EMPTY (new customer)
curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/55/trigger/fswa_chain_link%3A13" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [ {"field": "_hubspot_deal_id", "operator": "is_not_empty", "value": "", "cast": "string"}, {"field": "_hubspot_contact_id", "operator": "is_empty", "value": "", "cast": "string"} ] }, "conditions_evaluate_on": "transformed" }'

3.5 — Chain Webhook 54 from Webhook 55 (the follow-up PUT)

Webhook 54 needs to fire again after Webhook 55's upsert completes, this time with the freshly-created contact_id from the upstream response. We add Webhook 54 as a chain target of Webhook 55 in the same chain, then attach a different pre-glue snippet to this specific chain trigger.

add chain link 55 → 54
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": 55, "target_webhook_id": 54}' # → {"id":"14",...}
snippet code — webhook 54 pre-glue for chain link 14
// Pre-glue for webhook 54 when chained from webhook 55 (post-upsert). // upstream args[0].response.body.results[0].id → newly created contact_id // upstream args[0].payload._hubspot_deal_id → forwarded from webhook 55 $chain = $payload["args"][0] ?? []; $body = $chain["response"]["body"] ?? []; $contactId = (string) ($body["results"][0]["id"] ?? ""); $dealId = (string) ($chain["payload"]["_hubspot_deal_id"] ?? ""); $wcOrderId = (int) ($chain["payload"]["_wc_order_id"] ?? 0); if ($wcOrderId) { $order = wc_get_order($wcOrderId); if ($order && $order->get_meta("_hubspot_contact_deal_associated")) { return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => "", // idempotency: skip "_wc_order_id" => (string) $wcOrderId, ]; } } return [ "_hubspot_contact_id" => $contactId, "_hubspot_deal_id" => $dealId, "_wc_order_id" => (string) $wcOrderId, ];
attach snippets to webhook 54's second chain trigger (reuse post-glue 20)
curl -sk -X POST "$SITE/wp-json/fswa/v1/pro/trigger-snippets/54/trigger/fswa_chain_link%3A14" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{"pre_snippet_id": 25, "pre_enabled": true, "post_snippet_id": 20, "post_enabled": true}'
same condition as link 12 (both contact_id + deal_id non-empty)
curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/54/trigger/fswa_chain_link%3A14" \ -H "X-FSWA-Token: $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "conditions": { "enabled": true, "type": "and", "rules": [ {"field": "_hubspot_deal_id", "operator": "is_not_empty", "value": "", "cast": "string"}, {"field": "_hubspot_contact_id", "operator": "is_not_empty", "value": "", "cast": "string"} ] }, "conditions_evaluate_on": "transformed" }'
Webhook Actions admin Webhooks list with the HS - Order Line Items chain group expanded, showing webhook 31 with three targets (53, 54, 55) and webhook 55 with one target (54 again via chain link 14)
Webhook Actions admin → Webhooks list with the HS - Order Line Items chain group expanded. Webhook 31 fans out to three targets (53 batch line items, 54 existing-PUT, 55 upsert). Webhook 55 chains forward to webhook 54 a second time via chain link 14 — same webhook, different pre-glue snippet.
/ Step 4

End-to-End Test — Both Paths

4.1 — New customer path

Place a test order with a fresh billing email (no prior HubSpot contact). Filter the Webhook Actions Logs by chain HS - Order Line Items — you should see 5 rows for this order:

  1. Webhook 31 (HS - Create Deal) on woocommerce_checkout_order_createdsuccess 201
  2. Webhook 53 (HS - Batch Line Items) on fswa_chain_link:11success 201
  3. Webhook 54 (HS - Associate Existing Contact) on fswa_chain_link:12skipped — Condition not met: _hubspot_contact_id is_not_empty
  4. Webhook 55 (HS - Upsert Contact) on fswa_chain_link:13success 200 (new contact created)
  5. Webhook 54 (HS - Associate Existing Contact) on fswa_chain_link:14success 200 (chained from 55, association done)

4.2 — Existing customer path

Place a second order, this time using the same email as the first order — or any email whose WP user already has user_meta._hubspot_contact_id populated (Webhook 55's post-glue from the first order writes that). You should see 4 rows:

  1. Webhook 31 → success 201
  2. Webhook 53 (line items) → success 201
  3. Webhook 54 (chain link 12, existing PUT) → success 200 — the assoc landed in one call
  4. Webhook 55 (upsert) → skipped — Condition not met: _hubspot_contact_id is_empty

Open the deal in HubSpot. Right sidebar shows the contact attached. Scroll down: line items section lists all products with quantities. Click any line item — it opens with the linked product card. Click the contact — the deal shows under their Deals tab. The graph is complete via either path.

HubSpot Deal record sidebar showing the associated Contact and three Line Items, each line item linked to its underlying Product record
HubSpot → Deal record. Right sidebar shows the associated Contact (via Part 5's chain) + 3 Line Items + 3 Products, the full graph wired by one WC order placement.
Webhook Actions admin Logs filtered by chain HS - Order Line Items showing five rows for the new-customer path: deal create, batch line items, skipped existing-PUT, upsert, and the chained new-PUT
Webhook Actions admin → Logs filtered by chain HS - Order Line Items for one new-customer order. Five rows: webhook 31 deal create (201) → webhook 53 line items batch (201) → webhook 54 link 12 skipped (condition fail, no contact_id yet) → webhook 55 upsert (200) → webhook 54 link 14 (200) with the Chain ← HS - Upsert Contact pill on the last row.

4.3 — Replay verification

Hit replay on the deal-create log entry. The dispatch re-runs (you'll see a new log row, 201, fresh HubSpot deal — that part isn't idempotent without changing Part 1). But the downstream chain links' pre-glue and conditions absorb the replay:

  • Webhook 53 pre-glue finds _hubspot_line_item_ids already populated → returns {"inputs": []} → HubSpot accepts the empty batch as a 201 no-op → no line item duplication.
  • Webhook 54 + 55 pre-glue detect the _hubspot_contact_deal_associated marker and zero out _hubspot_deal_id → the chain-link condition fails on both → both skip.

To intentionally re-sync after a product update, clear the relevant marker on the order: $order->delete_meta_data("_hubspot_line_item_ids") for line items, $order->delete_meta_data("_hubspot_contact_deal_associated") for the contact link. Then replay — the chain rebuilds that piece of the graph.

/ Common Pitfalls

Things That Bite

HubSpot's contacts/batch/upsert silently ignores inline associations. The 2026-03 object batch APIs accept an associations array per input on create, which would let you atomically create-and-associate. But upsert drops the field with no warning — the response shows 200 and the contact is created, but no association edge appears in HubSpot. That's why this design uses a follow-up PUT via chain link 14 instead of trying to do both in one call. Verify by querying /crm/v4/objects/deals/{id}/associations/contacts after the upsert — the results array is empty.

Post-glue must NOT stamp the assoc marker on Webhook 55. If you copy snippet 20 (Webhook 54 post-glue) into Webhook 55's post-glue, you'll stamp _hubspot_contact_deal_associated after the upsert succeeds — before the actual association call. The follow-up Webhook 54 (chain link 14) then sees the marker on its idempotency check, zeros out _hubspot_deal_id, and skips. End result: contact created, no association. The marker is reserved for Webhook 54's success only.

URL template variables come from the transformed payload. {{ _hubspot_contact_id }} in Webhook 54's URL is resolved against whatever the pre-glue returns — not the chain payload, not order meta, not the upstream response. Pre-glue is the single source of truth for URL substitution. The same applies to the condition rules — both URL templating and condition checks read the transformed payload.

Default association vs typed batch. This article uses the 2026-03 default-association PUT (PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did}). "Default" means HubSpot picks the canonical type id (3 = deal_to_contact, returned in the response) implicitly. If you need a custom or labeled association type, fall back to the v4 typed-batch endpoint POST /crm/v4/associations/contacts/deals/batch/create with explicit associationTypeId. 2026-03 default is simpler and right for 95% of cases.

Chain link condition ordering. The condition has two rules: _hubspot_deal_id is_not_empty AND _hubspot_contact_id is_(not_)empty. We put deal_id first because it doubles as the idempotency switch — when pre-glue detects "already associated", it zeros out deal_id and BOTH chain links 12 and 14 fail the first rule. Without deal_id as the guard, you'd need a separate field just for idempotency.

Order edits after sync. The series stops at first-write. Adding a line item to an existing order, or changing the billing email after sync, doesn't propagate. For mutable orders, listen to woocommerce_update_order + woocommerce_order_status_changed with their own chain that PATCHes/POSTs the delta — outside this series.

HubSpot rate limits. Worst case (new customer, 50-item cart) = 4 HubSpot calls total: deal create, line items batch, contact upsert, contact↔deal PUT. Best case (existing customer, any cart size) = 3 calls. Well within the default 110 req/10s burst on standard HubSpot plans. The pre-chain version of this series did ~100 calls per order and was prone to 429s.

/ FAQ

Common Questions

Each part is independently useful and independently debuggable. Bugs in line items don't take down deal creation; product sync can run on its own schedule. Splitting along clean object boundaries means each piece can be replayed without rerunning the whole pipeline.
It's the same webhook (same name, same PUT method, same URL template) wired as the target of two different chain links. Each link has its own pre-glue snippet attached — one reads _hubspot_contact_id from user_meta (existing-customer path), the other from the upstream upsert response (new-customer path). Different inputs, same output. This is why FSWA's snippet attachment is per-(webhook, trigger) rather than per-webhook — the same webhook serves multiple roles via different trigger contexts.
HubSpot's 2026-03/contacts/batch/upsert silently ignores inline associations arrays — only batch/create supports them, but create 409s on duplicate email. Upsert is the only safe write path for new-or-existing contacts, so the association has to be a follow-up. Modeling that follow-up as a chain link (instead of a hidden wp_remote_post in post-glue) keeps it observable: own log row, own retry, own replay.
Disable the chain link you don't want. The Part 3 line-items chain is one link (31→53); the contact-association flow is three links (31→54, 31→55, 55→54). DELETE any chain link via DELETE /fswa/v1/chains/{cid}/links/{lid} and the target webhook stops firing from that chain. The target webhook itself stays — you can still trigger it manually or from a different chain.
The series targets first-write only. For edits, listen to woocommerce_order_status_changed (already wired in Part 1 for Closed Won) plus woocommerce_update_order, and write a sync-update post-glue that PATCHes the deal and refreshes line items only if the list has changed (compare to _hubspot_line_item_ids).
No. The official integration uses a fixed schema and ships a defined object graph. This setup lets you choose exactly which associations exist, which conditions apply, and gives you a full Webhook Actions delivery log with replay — including the sub-calls for line items and associations.
Yes — all object endpoints (contacts, deals, products, line_items) and v4 associations are available on HubSpot Free. The CRM Suite Starter plans add reporting features but don't change the API. Burst-rate limits are tighter on Free (40 req/10s on some accounts), so high-cart-count orders may need batching.
The 2026-03 default-association PUT (/crm/objects/2026-03/{from}/{id}/associations/default/{to}/{id}) is the lightest call: empty body, no associationTypeId bookkeeping, HubSpot picks the canonical direction implicitly. v4's typed batch (/crm/v4/associations/{a}/{b}/batch/create) is for when you need labeled or custom association types, which the 95% case doesn't. Both endpoints are GA; pick the simpler one unless your CRM model needs custom labels.
/ HubSpot × WooCommerce Series