---
title: "HubSpot WordPress Plugin: Full Customer & Order Sync"
description: "The capstone — tie contact, deal, line items, and products into one coherent HubSpot graph on every WooCommerce order, sequenced via Webhook Actions Webhook Chains with batch endpoints. Every hop logged and replayable."
url: "https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/"
date: "2026-05-15"
---

[WP Webhooks](/) / [Examples](/examples/)/ [HubSpot × WooCommerce](/examples/hubspot-woocommerce-integration/) / Full Sync

/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

[](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwpwebhooks.org%2Fexamples%2Fhubspot-wordpress-plugin-full-customer-order-sync%2F "Share on LinkedIn")[](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwpwebhooks.org%2Fexamples%2Fhubspot-wordpress-plugin-full-customer-order-sync%2F&text=HubSpot%20WordPress%20Plugin%3A%20Full%20Customer%20%26%20Order%20Sync "Share on X")

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.  1
    
    **[Part 1](/examples/hubspot-create-deal-woocommerce/) 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](/examples/add-customer-to-hubspot-on-woocommerce-order/) (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.  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.

FIG 01 — Dual-path customer + order sync chain

/ 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

First, store the HubSpot PAT once in the plugin's **Credentials Vault** — webhooks then reference it by id via `auth_credential_id`, which takes precedence over the legacy plaintext `auth_header`. The secret is encrypted at rest and write-only over the API: it never shows up in webhook configs, logs, or agent responses.

one-time — store the PAT in the Credentials Vault

```
CRED_ID=$(curl -sk -X POST "$SITE/wp-json/fswa/v1/credentials" \
  -H "X-FSWA-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name": "HubSpot PAT", "type": "bearer", "secret": "'"$HS_PAT"'"}' | jq -r '.id')
```

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_credential_id": '"$CRED_ID"',
    "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_credential_id": '"$CRED_ID"',
    "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

[← Previous Part 4 — Sync Contact on Register](/examples/add-customer-to-hubspot-on-woocommerce-order/) [All 5 parts Series Index](/examples/hubspot-woocommerce-integration/) Next → (Series end)

/Notes

→ [HubSpot Associations API](https://developers.hubspot.com/docs/api-reference/latest/crm/associations/overview)

→ [HubSpot API usage guidelines](https://developers.hubspot.com/docs/developer-tooling/platform/usage-guidelines)

→ [WordPress Webhook REST API reference](/webhook-wordpress-plugin-api/)

→ [All WordPress webhook automation examples](/examples/)

/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.

/More examples

## Related integrations.

[

Part 1

Create & Update Deals from WC Orders

The deal-create webhook this part extends with the final contact-deal association block.

Read →](/examples/hubspot-create-deal-woocommerce/)[

Bonus

Ask Claude Code to Set Up a WC Webhook

A real Claude Code session — REST API discovery, payload-path correction, live fix.

Read →](/examples/woocommerce-order-webhook-claude-code/)[

Series Hub

HubSpot × WooCommerce — All 5 Parts

Back to the series index.

Read →](/examples/hubspot-woocommerce-integration/)

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"HubSpot WordPress Plugin: Full Customer and Order Sync","description":"Tie HubSpot contact, deal, line items, and products into a single coherent graph for every WooCommerce order — using Webhook Actions Webhook Chains and HubSpot batch endpoints. The hubspot wordpress plugin endgame.","datePublished":"2026-05-15","dateModified":"2026-05-19","author":{"@type":"Person","name":"Mateusz Skorupa","url":"https://wpwebhooks.org/about/"},"publisher":{"@type":"Organization","name":"WP Webhooks","url":"https://wpwebhooks.org","sameAs":["https://flowsystems.pl"]},"url":"https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot wordpress plugin","hubspot wordpress integration","hubspot woocommerce full sync","hubspot crm wordpress","hubspot association woocommerce"],"isPartOf":{"@type":"CreativeWorkSeries","name":"HubSpot × WooCommerce Integration Series","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"}}

{"@context":"https://schema.org","@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Why is the full hubspot wordpress plugin sync split into 5 parts and not one giant snippet?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why does Webhook 54 appear twice in the chain?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"Why upsert + PUT instead of one atomic call?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"What if I want only one HubSpot association to fire (e.g. contact-deal but not line-items)?","acceptedAnswer":{"@type":"Answer","text":"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."}},{"@type":"Question","name":"How do I handle order edits after the initial sync?","acceptedAnswer":{"@type":"Answer","text":"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)."}},{"@type":"Question","name":"Is this the same as HubSpot's official WooCommerce integration?","acceptedAnswer":{"@type":"Answer","text":"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."}}]}

{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://wpwebhooks.org/"},{"@type":"ListItem","position":2,"name":"Examples","item":"https://wpwebhooks.org/examples/"},{"@type":"ListItem","position":3,"name":"HubSpot × WooCommerce","item":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/"},{"@type":"ListItem","position":4,"name":"Full Sync","item":"https://wpwebhooks.org/examples/hubspot-wordpress-plugin-full-customer-order-sync/"}]}
```
