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
user_register action, POSTs to https://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert.$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.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.do_action('user_register', $user_id) in tests is a no-op.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.
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.
Search for FlowSystems in plugin install.
crm.objects.contacts.write
Add to your existing app from Parts 1-3 or create a new one.
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.
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.
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.
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.
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.
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.
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).
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.
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.
user_register, with lifecyclestage = Lead visible in the About panel.
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.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.
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.
inputs[] array per dispatch. The single-record /contacts/upsert?idProperty=email URL returns 405 Method Not Allowed.
fswa_webhook_payload + fswa_glue_post_dispatch filters in a mu-plugin.
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.
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.