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
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.PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did} — one HubSpot call, association done./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.{{ _hubspot_contact_id }} URL template variable._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.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.
_hubspot_product_id on every productVerified by inspecting a recently published product.
_hubspot_line_item_ids on every orderConfirms the post-glue loop is firing and resolving product IDs.
user_registerThis 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.
crm.objects.contacts.read + crm.associations.readThe 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
woocommerce_checkout_order_created → success 201fswa_chain_link:11 → success 201fswa_chain_link:12 → skipped — Condition not met: _hubspot_contact_id is_not_emptyfswa_chain_link:13 → success 200 (new contact created)fswa_chain_link:14 → success 200 (chained from 55, association done)
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:
success 201success 201success 200 — the assoc landed in one callskipped — Condition not met: _hubspot_contact_id is_emptyOpen 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.
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:
_hubspot_line_item_ids already populated → returns {"inputs": []} → HubSpot accepts the empty batch as a 201 no-op → no line item duplication._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.
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.
_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.
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.
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.
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).
/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.