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. Every hop is its own log row.
TL;DR — The dual-path chain
- One trigger fans out a 4-link chain.
woocommerce_checkout_order_createdfires Webhook 31 (sync). On 2xx, the chain dispatches three targets in parallel: line items batch (Part 3), existing-contact PUT, and new-contact upsert. - Existing customer (contact_id in user_meta): Webhook 54 fires a single
PUT /crm/objects/2026-03/contacts/{cid}/associations/default/deals/{did}— one HubSpot call. - New customer (no contact_id): Webhook 55 POSTs to
/contacts/batch/upsert, then chains forward to Webhook 54 to PUT the association. Two calls. - Idempotent end-to-end. Each path stamps
_hubspot_contact_deal_associatedon the order only after the actual PUT lands.
All Four Earlier Parts Working in Isolation
- Part 2 shipping
_hubspot_product_idon every productVerified by inspecting a recently published product.
- Part 3 shipping
_hubspot_line_item_idson every orderConfirms the post-glue loop is firing and resolving product IDs.
- Part 4 (optional) — Sync registered WP users to HubSpot contacts on
user_registerPre-populates
user_meta._hubspot_contact_idso order-time flows hit the cheaper existing-contact PUT path. Part 5 self-heals for new emails in-chain — Part 4 is purely an optimization. - HubSpot scopes: deals, contacts, products, line_items, plus
crm.objects.contacts.read+crm.associations.readThe associations writes don't need a separate scope — covered by object-write scopes — but reads (verification step) need the read scope.
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 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.
Why Webhook 54 appears twice: it's the same webhook (same PUT method, same URL template), but the target of two different chain links. Each chain link has its own pre-glue snippet — one reads contact_id from user_meta, the other from the upstream upsert response. One webhook config, two pre-glue snippets.
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 /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 /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) PUT /contacts/{new_cid}/associations/default/deals/{did} → 200 # 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 (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 chain link — keeping it observable with its own log row, retry, and replay.
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. The new design moves that work into the chain via Webhook 55, eliminating a race condition. 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}'
Webhook 54 — HS - Associate Existing Contact (PUT)
Webhook 54 uses HubSpot's default-association PUT endpoint. It takes the contact ID and deal ID directly in the URL and an empty body — one call, one association, idempotent on replay. One webhook config serves both the existing-customer path (chain link 12) and the new-customer path (chain link 14) via different pre-glue snippets.
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",...}
/crm/objects/2026-03/{from}/{id}/associations/default/{to}/{id} path is the simpler call. No request body, no associationTypeId bookkeeping, idempotent on replay. Use the v4 batch only when you need labeled or custom association types.2.2 — Pre-dispatch Code Glue: look up contact_id from user_meta
This snippet runs on chain link 12. It resolves _hubspot_contact_id from user_meta with order_meta fallback. If the contact isn't known, it returns an empty field — the condition 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, ];
2.3 — Post-dispatch Code Glue: stamp the replay marker
snippet code — post-dispatch glue
// Stamps the idempotency marker only on 2xx. Reused for chain link 14. 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 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",...}
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" }'
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 contacts/batch/upsert. Then chain link 14 fires Webhook 54 again — same webhook config, 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
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", ], ]], ];
associations arrays. Only batch/create supports inline assoc — batch/upsert drops the field with no warning and returns 200. This is why we need the follow-up PUT via chain link 14.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. 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 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"] ?? ""); $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->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",...}
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)
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). $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, ];
End-to-End Test — Both Paths
4.1 — New customer path
Place a test order with a fresh billing email. Filter Webhook Actions Logs by chain HS - Order Line Items — you should see 5 rows: Webhook 31 (201) → Webhook 53 line items (201) → Webhook 54 link 12 (skipped — condition not met) → Webhook 55 upsert (200) → Webhook 54 link 14 (200).
4.2 — Existing customer path
Place a second order using the same email. You should see 4 rows: Webhook 31 (201) → Webhook 53 (201) → Webhook 54 link 12 (200) → Webhook 55 (skipped — condition not met). Open the deal in HubSpot — contact attached, line items listed, the full graph complete via either path.
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, but upsert drops the field with no warning. Verify by querying GET /crm/v4/objects/deals/{id}/associations/contacts after the upsert.
Post-glue must NOT stamp the assoc marker on Webhook 55. If you stamp _hubspot_contact_deal_associated after the upsert, the follow-up Webhook 54 (chain link 14) skips — contact created, no association.
URL template variables come from the transformed payload. {{ _hubspot_contact_id }} is resolved against whatever pre-glue returns — not the chain payload, not order meta directly. Pre-glue is the single source of truth.
HubSpot rate limits. Worst case (new customer, 50-item cart) = 4 HubSpot calls total: deal create, line items batch, contact upsert, contact↔deal PUT. Well within the default 110 req/10s burst on standard HubSpot plans.
Common questions always ask.
Don't see yours? Open an issue on GitHub or check the full reference in the API docs.