---
title: "HubSpot WordPress Integration: Sync Contact on User Register"
description: "Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint and Code Glue. Pre-populates user_meta so the Part 5 order chain hits the cheap PUT path on day one."
url: "https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/"
date: "2026-05-15"
---

[WP Webhooks](/) / [Examples](/examples/)/ [HubSpot × WooCommerce](/examples/hubspot-woocommerce-integration/) / Customer to Contact

/Example · HubSpot × WooCommerce

# HubSpot WordPress Integration: Sync Contact on User Register

Mirror every new WordPress user into HubSpot CRM as a Contact — idempotent by email so the same person never duplicates. Built on the 2026-03 `contacts/batch/upsert` endpoint and stored to `user_meta._hubspot_contact_id` so Part 5's order-time chain hits the cheap PUT path instead of upserting again.

**~10 min read** May 15, 2026 Part **4** of 5

[](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fwpwebhooks.org%2Fexamples%2Fadd-customer-to-hubspot-on-woocommerce-order%2F "Share on LinkedIn")[](https://twitter.com/intent/tweet?url=https%3A%2F%2Fwpwebhooks.org%2Fexamples%2Fadd-customer-to-hubspot-on-woocommerce-order%2F&text=HubSpot%20WordPress%20Integration%3A%20Sync%20Contact%20on%20User%20Register "Share on X")

Filter OK Pro hubspotcontactwoocommerce

TL;DR — What you'll build

-   **Webhook 56 — Sync Contact on User Register:** fires on the WP `user_register` action, POSTs to `https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert`
-   **Pre-glue:** reads `$payload["args"][0]` (user\_id), resolves email + name via `get_userdata()`. Skips re-fire if `user_meta._hubspot_contact_id` is already set.
-   **Post-glue:** reads `results[0].id`, writes as `_hubspot_contact_id` to `user_meta`
-   **No duplicates, no race condition.** The upsert is idempotent by email; the pre-glue guard skips when user\_meta is already populated.

/ Prerequisites

## What You Need **Before Starting**

⚡

This part stands alone — it works without any of the other parts in the series. It's also fully _optional_: Part 5 handles new-customer contact creation in-chain at order time. This part exists to pre-populate `user_meta._hubspot_contact_id` so the first order from a registered user is one HubSpot call instead of two.

1.  1
    
    **WordPress (WooCommerce optional)**
    
    The `user_register` action fires on any WP user creation — the WC customer account flow, your registration form, manual `/wp-admin/user-new.php`, programmatic `wp_insert_user` calls, and so on.
    
2.  2
    
    **Webhook Actions (free) v1.12.0+**
    
    Search for **FlowSystems** in plugin install.
    
3.  3
    
    **HubSpot Private App with `crm.objects.contacts.write`**
    
    Add to your existing app from Parts 1-3 or create a new one.
    
4.  4
    
    **Webhook Actions Pro Pro v1.1.0+ — for pre-glue idempotency + post-glue ID capture**
    
    Required for this article's snippet approach. Without Pro, implement the same logic in a mu-plugin via `fswa_webhook_payload` + `fswa_glue_post_dispatch`.
    
5.  5
    
    **Shell variables**
    
    set shell variables
    
    ```
    export SITE="https://your-wordpress-site.com"
    export TOKEN="fswa_full_..."
    export HS_PAT="pat-eu1-xxxxxxxx..."
    ```
    

/ Architecture

## Upsert by Email, **Triggered at Registration**

HubSpot's contacts API has two write endpoints. `POST /contacts` always creates a new record — useless if the same email registers twice. `POST /contacts/batch/upsert` uses an _idProperty_ (we'll use `email`) to match existing records: it returns the existing contact\_id if found, creates a new one if not. `results[0].new` tells us which case fired.

Why hook `user_register` and not the WC order trigger? Because Part 5's order chain self-heals for new emails by upserting in-chain. An order-time contact webhook would duplicate work and race with the deal-create chain. The cleaner split: register-time syncs the contact identity once, order-time relies on the chain to fill in the gap if it doesn't find one.

flow diagram

```
# USER REGISTERS — WC account, WP admin, registration form, or programmatic
wp_insert_user / wp-admin / WC account / signup form
  └── fires user_register action with ($user_id, $userdata)
        └── pre-glue: get_userdata() + get_user_meta() → email, firstname, lastname
              └── idempotency: if user_meta._hubspot_contact_id already set → return {"inputs":[]} no-op
              └── Webhook 56 POST /crm/objects/2026-03/contacts/batch/upsert
                    └── HubSpot responds 200 + {"results": [{"id": "781200846026", "new": true, ...}]}
                          └── post-glue: update_user_meta($user_id, _hubspot_contact_id, "781200846026")

# Later, when this user places their first WC order:
#   → Part 5's chain dispatcher reads user_meta._hubspot_contact_id
#   → Existing-contact PUT path fires (one HubSpot call)
#   → Without this part, the chain would still work — but via Webhook 55 upsert + Webhook 54 PUT (two calls)
```

**HubSpot does not expose a single-record contacts upsert.** The endpoint `POST /crm/objects/2026-03/contacts/upsert?idProperty=email` returns **405 Method Not Allowed** (verified live). The canonical path is `/contacts/batch/upsert` — the body must wrap a single contact in a one-element `inputs[]` array. Partial upserts are also not supported when `idProperty` is `email`: every send is a full overwrite of the properties you include.

FIG 01 — User register → contact upsert

/ Step 1

## Create the **"Sync Contact on Register"** Webhook

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')
```

step 1.1 — create the webhook

```
curl -sk -X POST "$SITE/wp-json/fswa/v1/webhooks" \
  -H "X-FSWA-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "HS - Sync Contact on User Register",
    "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": ["user_register"]
  }'
```

response — webhook ID 56

```
{
  "id": "56",
  "name": "HS - Sync Contact on User Register",
  "endpoint_url": "https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert",
  "http_method": "POST"
}
```

Why `user_register` and not order triggers

`user_register` fires whenever WordPress creates a new user, regardless of source: WC's "Create account" checkbox at checkout, your registration form plugin, manual admin user creation, programmatic `wp_insert_user()` calls. One hook covers them all. **Guest checkouts don't fire `user_register`** — that's intentional. Part 5's chain handles guest orders in-chain via Webhook 55.

### 1.2 — Condition: skip if email is missing

Some programmatic user creation paths can land without a usable email. The pre-glue's idempotency guard handles re-fires; this condition handles the "no email at all" edge case.

add condition — skip if email is empty

```
curl -sk -X PUT "$SITE/wp-json/fswa/v1/schemas/webhook/56/trigger/user_register" \
  -H "X-FSWA-Token: $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "conditions": {
      "enabled": true,
      "type": "and",
      "rules": [{"field": "inputs.0.properties.email", "operator": "is_not_empty"}]
    },
    "conditions_evaluate_on": "transformed"
  }'
```

**Important — `conditions_evaluate_on` is a TOP-LEVEL schema field**, not nested inside `conditions`. Putting `"evaluate_on": "transformed"` inside the conditions object causes Webhook Actions to silently drop it and evaluate against the original payload — the post-glue field `inputs.0.properties.email` wouldn't exist there and the condition would never match.

/ Step 2 Filter OK fswa\_webhook\_payloadfswa\_glue\_post\_dispatch Pro

## Read User Data, **Capture the Contact ID**

### 2.1 — Pre-dispatch: read user data, build batch/upsert input

snippet code — PHP

```
// Pre-dispatch glue for webhook 56 — fires on user_register.
// $payload["args"][0] = user_id. Skip if _hubspot_contact_id already set (idempotency).

$userId = (int) ($payload["args"][0] ?? 0);
if (!$userId) return ["inputs" => []];

$existing = (string) get_user_meta($userId, "_hubspot_contact_id", true);
if ($existing) return ["inputs" => []];

$user = get_userdata($userId);
if (!$user) return ["inputs" => []];

$email = strtolower(trim((string) $user->user_email));
if (!$email) return ["inputs" => []];

$first = trim((string) get_user_meta($userId, "first_name", true));
$last  = trim((string) get_user_meta($userId, "last_name", true));
// Fall back to splitting display_name if first/last aren't set yet (common at signup).
if (!$first && $user->display_name) {
    $parts = explode(" ", trim((string) $user->display_name), 2);
    $first = $parts[0] ?? "";
    $last  = $parts[1] ?? $last;
}

return [
    "_wp_user_id" => (string) $userId,
    "inputs" => [[
        "idProperty" => "email",
        "id"         => $email,
        "properties" => [
            "email"          => $email,
            "firstname"      => $first,
            "lastname"       => $last,
            "lifecyclestage" => "lead",
        ],
    ]],
];
```

**Why lifecyclestage = "lead" instead of "customer"?** They've registered but haven't bought anything yet — "lead" is the canonical pipeline stage. Part 5's order-time chain bumps the contact to "customer" via the upsert on the first order. **Why normalise email to lowercase?** HubSpot's email-based dedupe is case-insensitive but stores the case you send. Lowercasing keeps the value tidy and prevents case-mismatched user-meta lookups later.

### 2.2 — Post-dispatch: store the returned contact ID on user\_meta

snippet code — PHP

```
// Post-dispatch glue — read results[0] from the batch response, write to user_meta.

if ($responseCode < 200 || $responseCode >= 300) return;
$data      = json_decode($responseBody, true);
$result    = $data["results"][0] ?? [];
$contactId = (string) ($result["id"] ?? "");
$userId    = (int) ($payload["_wp_user_id"] ?? 0);
if ($contactId && $userId) {
    update_user_meta($userId, "_hubspot_contact_id", $contactId);
    update_user_meta($userId, "_hubspot_contact_was_new", !empty($result["new"]) ? "1" : "0");
}
```

**No order\_meta write here.** `user_register` has no order context — there's no order to attach meta to. Part 5's chain reads from `user_meta` first (preferred) with `order_meta` fallback, so writing `user_meta` alone is enough.

/ Step 3

## End-to-End Test — **New, Existing, Re-Fire**

**Test 1 — new user registers.** Use any path that creates a WP user with a fresh email: WP admin "Add New User", a registration form plugin, or directly via WP-CLI: `wp user create test1234 test1234@example.com --first_name=Test --last_name=User --role=customer`. Webhook Actions log shows webhook 56 returning `200` with `"results":[{...}]`. HubSpot contact record is created; WP `user_meta` has `_hubspot_contact_id` populated.

**Test 2 — existing HubSpot email.** Register a WP user with an email that already exists in HubSpot. Webhook Actions log shows webhook 56 returning `200` with `"results":[{...}]` — HubSpot returned the existing contact\_id. No duplicate.

**Test 3 — re-fire idempotency.** For the same WP user, run `do_action('user_register', $user_id, [])` manually via WP-CLI `wp eval`. Pre-glue detects `user_meta._hubspot_contact_id` is already set, returns `"inputs":[]`, condition fails — webhook is skipped with no HubSpot call. Status in the log: `skipped`.

HubSpot → CRM → Contacts — the new contact record created by Webhook 56 on `user_register`, with `lifecyclestage = Lead` visible in the About panel.

Webhook Actions admin → Logs filtered by webhook 56. First dispatch returns 200 with `results[0].new = true`. The re-fire row below it is _skipped_ — pre-glue idempotency guard short-circuited because `user_meta._hubspot_contact_id` is already set.

/ Common Pitfalls

## Things **That Bite**

**Using `POST /contacts` instead of `/contacts/batch/upsert`.** Plain POST creates duplicates if the email already exists in HubSpot. The single-record `/contacts/upsert?idProperty=email` URL returns **405 Method Not Allowed** — it doesn't exist in the 2026-03 API. Always batch/upsert.

**Forgetting the `"id"` field inside `inputs[]`.** The batch/upsert input requires _both_ `idProperty: "email"` AND `id: "<email value>"` at the input root. Omitting it returns 400.

**Re-firing user\_register breaks the contact.** Some plugins re-fire the action when updating user roles or syncing from external systems — without the pre-glue idempotency guard, every re-fire is a full overwrite of HubSpot properties. Test re-fires explicitly before going live.

**Lifecyclestage overwrite on re-fire.** Setting `lifecyclestage = "lead"` can _downgrade_ a contact already at "customer". The pre-glue idempotency guard avoids the overwrite entirely for previously-synced users.

**Programmatic user creation skips first\_name/last\_name.** `wp_insert_user()` with just `user_email` + `user_login` creates a user with empty name user\_meta. The pre-glue falls back to splitting `display_name` — but if that's also blank, the HubSpot contact lands with empty name fields.

/ HubSpot × WooCommerce Series

[← Previous Part 3 — Order Line Items](/examples/woocommerce-order-line-items-hubspot/) [All 5 parts Series Index](/examples/hubspot-woocommerce-integration/) [Next → Part 5 — Full Customer × Order Sync](/examples/hubspot-wordpress-plugin-full-customer-order-sync/)

/Notes

→ [HubSpot Contacts — create-contact (2026-03)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/create-contact)

→ [HubSpot Contacts — guide (covers batch/upsert)](https://developers.hubspot.com/docs/api-reference/latest/crm/objects/contacts/guide)

→ [WordPress Webhook REST API](/webhook-wordpress-plugin-api/) — full reference for webhooks, schemas, conditions, and logs

→ [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 hook user\_register instead of the WC order action? +

Part 5's order chain already handles contact creation in-chain via Webhook 55 for new emails. An order-time contact webhook duplicates that work, introduces a race condition with the deal-create chain (the contact webhook is async; the chain dispatches synchronously), and bloats the per-order HubSpot call count. Register-time sync runs once per user lifetime, pre-populates user\_meta, and lets Part 5's chain take the one-call PUT path on the first order.

Why use upsert instead of POST for contacts? +

HubSpot's POST /contacts 409s on duplicate email. If the registering user's email already exists in HubSpot (prior marketing form, manual entry, imported list), POST fails. /contacts/batch/upsert returns the existing contact\_id instead — exactly what we want for dedupe.

Why is it the batch/upsert endpoint when we only ever send one contact? +

HubSpot's 2026-03 contacts API does not expose a single-record upsert endpoint — only the batch one. We send a one-element inputs\[\] array per dispatch. The single-record /contacts/upsert URL returns 405 Method Not Allowed.

What about guest checkouts — they don't fire user\_register? +

Correct — guests don't get a WP user. Part 5's order chain handles guest checkout: Webhook 55 detects empty user\_meta.\_hubspot\_contact\_id and runs the upsert in-chain at order time. This Part 4 webhook only fires for registered users; the two halves cover all cases between them.

Does this work without Pro? +

Yes — the upsert flow needs no stateful inject. Pro is only required for the post-dispatch ID capture and the pre-dispatch idempotency guard. Without Pro, implement the same flow against fswa\_webhook\_payload + fswa\_glue\_post\_dispatch filters in a mu-plugin.

Does upsert preserve unmentioned fields, or overwrite the whole contact? +

When idProperty is email, partial upserts are not supported — every send is a full overwrite of the properties you include. Marketing-attribution fields that HubSpot manages internally (hs\_email\_optin, lifecyclestage history, sequence membership) are preserved because we never send them — but never include a property in inputs\[\] that you don't want to authoritatively replace.

/More examples

## Related integrations.

[

Part 1

Create & Update Deals from WC Orders

The deal half of the customer + order pair that Part 5 ties together.

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

Part 5 (next)

Full Customer × Order Association

Associates the contact you just created to the deal from Part 1 — the endgame of the series.

Read →](/examples/hubspot-wordpress-plugin-full-customer-order-sync/)[

Series Hub

HubSpot × WooCommerce — All 5 Parts

The complete series.

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

## Structured data

```json
{"@context":"https://schema.org","@type":"Article","headline":"HubSpot WordPress Integration: Sync Contact on User Register","description":"Mirror every WordPress user registration into HubSpot CRM as a Contact — idempotent by email via the 2026-03 batch/upsert endpoint and Code Glue. Pre-populates user_meta so the Part 5 order chain takes the cheap PUT path on first order.","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/add-customer-to-hubspot-on-woocommerce-order/","image":{"@type":"ImageObject","url":"https://wpwebhooks.org/examples/hubspot-woocommerce-integration/og_image.jpg"},"keywords":["hubspot wordpress integration","hubspot wordpress contact","woocommerce customer to hubspot","hubspot contact woocommerce","hubspot crm wordpress"],"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 hook user_register instead of the WC order action?","acceptedAnswer":{"@type":"Answer","text":"Part 5's order chain already handles contact creation in-chain via Webhook 55 for new emails. An order-time contact webhook duplicates that work, introduces a race condition with the deal-create chain (the contact webhook is async; the chain dispatches synchronously), and bloats the per-order HubSpot call count. Register-time sync runs once per user lifetime, pre-populates user_meta, and lets Part 5's chain take the one-call PUT path on the first order."}},{"@type":"Question","name":"Why use upsert instead of POST for contacts?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's POST /contacts 409s on duplicate email. If the registering user's email already exists in HubSpot (prior marketing form, manual entry, imported list), POST fails. /contacts/batch/upsert returns the existing contact_id instead — exactly what we want for dedupe."}},{"@type":"Question","name":"Why is it the batch/upsert endpoint when we only ever send one contact?","acceptedAnswer":{"@type":"Answer","text":"HubSpot's 2026-03 contacts API does not expose a single-record upsert endpoint — only the batch one. We send a one-element inputs[] array per dispatch. The single-record /contacts/upsert URL returns 405 Method Not Allowed."}},{"@type":"Question","name":"What about guest checkouts — they don't fire user_register?","acceptedAnswer":{"@type":"Answer","text":"Correct — guests don't get a WP user. Part 5's order chain handles guest checkout: Webhook 55 detects empty user_meta._hubspot_contact_id and runs the upsert in-chain at order time. This Part 4 webhook only fires for registered users; the two halves cover all cases between them."}},{"@type":"Question","name":"Does this work without Pro?","acceptedAnswer":{"@type":"Answer","text":"Yes — the upsert flow needs no stateful inject. Pro is only required for the post-dispatch ID capture and the pre-dispatch idempotency guard. Without Pro, implement the same flow against fswa_webhook_payload + fswa_glue_post_dispatch filters in a mu-plugin."}},{"@type":"Question","name":"Does upsert preserve unmentioned fields, or overwrite the whole contact?","acceptedAnswer":{"@type":"Answer","text":"When idProperty is email, partial upserts are not supported — every send is a full overwrite of the properties you include. Marketing-attribution fields that HubSpot manages internally (hs_email_optin, lifecyclestage history, sequence membership) are preserved because we never send them — but never include a property in inputs[] that you don't want to authoritatively replace."}}]}

{"@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":"Customer to Contact","item":"https://wpwebhooks.org/examples/add-customer-to-hubspot-on-woocommerce-order/"}]}
```
