Example · HubSpot × WooCommerce

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.

~18 min read May 15, 2026 Part 5 of 5
Filter OK Pro hubspotfull-syncassociations

TL;DR — The dual-path chain

  • One trigger fans out a 4-link chain. woocommerce_checkout_order_created fires 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_associated on the order only after the actual PUT lands.
/ 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.
  1. Part 1 shipping _hubspot_deal_id on every order

    Verified by inspecting recent test order post meta.

  2. Part 2 shipping _hubspot_product_id on every product

    Verified by inspecting a recently published product.

  3. Part 3 shipping _hubspot_line_item_ids on every order

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

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

    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 in-chain — Part 4 is purely an optimization.

  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 — covered by object-write scopes — but reads (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 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.

/ 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. 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}'
/ Step 2 Filter OK fswa_webhook_payloadfswa_webhook_url Pro

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",...}
Use the 2026-03 default-association PUT, not the v4 batch endpoint. The /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"
  }'
/ 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 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",
        ],
    ]],
];
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. 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,
];
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.
/ Step 4

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.

HubSpot → Deal record. Right sidebar shows the associated Contact + 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 for one new-customer order. Five rows: deal create (201) → line items batch (201) → link 12 skipped → upsert (200) → link 14 (200).
/ 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, 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.

/ HubSpot × WooCommerce Series
FAQ

Common questions always ask.

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

Why is the full hubspot wordpress plugin sync split into 5 parts and not one giant snippet? +
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.
Why does Webhook 54 appear twice in the chain? +
It's the same webhook wired as the target of two 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). FSWA's snippet attachment is per-(webhook, trigger), so the same webhook serves multiple roles via different chain-link triggers.
Why upsert + PUT instead of one atomic call? +
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 as a chain link keeps it observable: own log row, own retry, own replay.
What if I want only one HubSpot association to fire (e.g. contact-deal but not line-items)? +
Disable the chain link you don't want via DELETE /fswa/v1/chains/{cid}/links/{lid}. Each chain link is independent. The target webhook itself stays — you can still trigger it manually or from a different chain.
How do I handle order edits after the initial sync? +
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 line-items list has changed (compare to _hubspot_line_item_ids meta).
Is this the same as HubSpot's official WooCommerce integration? +
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.
Ready

Stop losing webhooks.
Start logging them.

$ wp plugin install wp-webhooks --activate