/ Part 4 of 5 — HubSpot × WooCommerce Series Filter OK Pro

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 across signups. 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.

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() and get_user_meta(), wraps into inputs[] with idProperty: "email". Skips re-fire if user_meta._hubspot_contact_id is already set.
  • Post-glue: reads results[0].id and results[0].new, writes them as _hubspot_contact_id + _hubspot_contact_was_new to user_meta. That's it — user_register has no order context, so we don't touch order meta here.
  • Why register-time instead of order-time: Part 5's order chain self-heals for new emails by upserting in-chain (Webhook 55) — so an order-time contact webhook is redundant for the create case. Register-time sync pre-populates user_meta, so the very first order for a registered user hits the existing-contact PUT path (one HubSpot call) instead of the upsert-then-PUT path (two calls). It's an optimization, not a requirement.
  • No duplicates, no race condition. The upsert is idempotent by email; the pre-glue idempotency guard skips when user_meta is already populated. Re-running do_action('user_register', $user_id) in tests is a no-op.
/ 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. No WC required for this part itself; useful for the full Part 5 chain.

  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 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
    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 (or matches an existing CRM record from a prior touchpoint). 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. That's the right shape for "one HubSpot contact per real-world person".

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 be doing the same work twice for first orders and would 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, so omit fields you don't want to authoritatively replace.

/ Step 1

Create the "Sync Contact on Register" Webhook

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_header": "Bearer '"$HS_PAT"'", "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. Order-triggered contact webhooks duplicate work (Part 5's chain already handles new contacts in-chain) and introduce a race condition with the deal-create chain.

Action signature: do_action('user_register', $user_id, $userdata). args[0] is the new user_id (int); args[1] is the userdata array (sometimes empty depending on creation path). The pre-glue uses get_userdata($userId) + get_user_meta() to read fields reliably regardless of what made it into args[1].

Guest checkouts don't fire user_register. That's intentional — guest orders go directly through Part 5's chain which upserts the contact at order time. No double-write.

1.2 — Condition: skip if email is missing

Some programmatic user creation paths (CLI imports, partial migrations) can land without a usable email. Without that we can't upsert — skip the dispatch cleanly. The pre-glue's idempotency guard handles re-fires (e.g. user-meta already populated), this condition handles the "no email at all" edge case.

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 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, $payload["args"][1] = userdata (sometimes empty). // Skip if user_meta already has _hubspot_contact_id (idempotency on re-fires). $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 (or you can wire Part 1's deal-stage update to mutate lifecyclestage on Closed Won). Either way, the customer-vs-lead distinction stays meaningful in HubSpot reports.

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 (since we'll read user_meta directly, not via HubSpot's matcher).

2.2 — Post-dispatch: store the returned contact ID on user_meta

// 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. Part 5's webhook 55 will write order_meta itself when the first order comes through.

/ 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, the WC account checkbox at checkout, or directly via WP-CLI: wp user create test1234 [email protected] --first_name=Test --last_name=User --role=customer. Webhook Actions log shows webhook 56 returning 200 with "results":[{...,"new":true}]. HubSpot → CRM → Contacts shows the new entry. 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 (e.g. an email from a prior marketing-form submission). Webhook Actions log shows webhook 56 returning 200 with "results":[{...,"new":false}] — HubSpot returned the existing contact_id rather than creating a duplicate. user_meta is populated with that existing contact_id; lifecyclestage is overwritten to "lead" (revisit this if it's a regression for your pipeline — see Pitfalls).

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 inputs.0.properties.email is_not_empty fails — webhook is skipped with no HubSpot call. Status in the log: skipped. No duplicate work.

Confirm the Part 5 hand-off. Now place a WC order using the same user. Filter Logs by chain HS - Order Line Items: you should see Webhook 54 (HS - Associate Existing Contact, chain link 12) fire with status success 200, and Webhook 55 (HS - Upsert Contact) skipped — Condition not met: _hubspot_contact_id is_empty. That's the proof Part 4 paid off — one HubSpot call for the association instead of two.

HubSpot CRM Contact record for a newly registered WordPress user with lifecyclestage set to Lead
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 showing a successful user_register dispatch with results new=true and a follow-up skipped row triggered by the idempotency guard
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 the dispatch 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 you might find in older blog posts 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. HubSpot uses id as the lookup key value and ignores it from properties. 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 (user_meta._hubspot_contact_id already set → return empty inputs), 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" on every upsert can downgrade a contact already at "customer" or "opportunity" stage. HubSpot's stage progression is monotonic by default (lead → customer is allowed, customer → lead is silently dropped), but custom property settings can break that. The pre-glue idempotency guard avoids the overwrite entirely for previously-synced users. For first-time users with a pre-existing HubSpot record at a later stage, you may want to omit the lifecyclestage property from the upsert payload.

Email collision with existing HubSpot records. Upsert by email returns the existing contact_id silently if found — great for dedupe, occasionally surprising if the registering user happens to share an email with an unrelated CRM record (typo, shared inbox, signed up under a coworker's email). user_meta gets the existing contact_id, Part 5 wires the deal to that contact. Usually fine; review your CRM hygiene if cross-contamination is a worry.

Programmatic user creation skips first_name/last_name. wp_insert_user() with just user_email + user_login creates a user with empty first_name/last_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. Either fill the names at registration or accept the empty-name records.

/ FAQ

Common Questions

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.
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.
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?idProperty=email URL returns 405 Method Not Allowed.
Correct — guests don't get a WP user, so user_register doesn't fire. Part 5's order chain handles guest checkout: Webhook 55 detects the empty user_meta._hubspot_contact_id and runs the upsert in-chain at order time. The Part 4 webhook here only fires for registered users. The two halves cover all cases between them.
Yes — the upsert flow needs no stateful inject, so the free plugin with static Mapping suffices. Pro is required only for the post-dispatch ID capture and the pre-dispatch idempotency guard (which Part 5 then uses for the contact-deal association). Without Pro, you can implement the same flow against fswa_webhook_payload + fswa_glue_post_dispatch filters in a mu-plugin.
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. If you need partial-update semantics, use a custom unique property as the idProperty instead of email.
Order-placement contacts are typically created on a legitimate-interest basis (the customer is buying from you). Marketing-list opt-in is a separate property (hs_email_optin) you only set if the checkout has an explicit checkbox the customer ticked. Don't auto-opt-in customers from order data — that's the GDPR/CCPA-actionable mistake.
/ HubSpot × WooCommerce Series