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.
TL;DR — What you'll build
- Webhook 56 — Sync Contact on User Register: fires on the WP
user_registeraction, POSTs tohttps://api.hubapi.com/crm/objects/2026-03/contacts/batch/upsert - Pre-glue: reads
$payload["args"][0](user_id), resolves email + name viaget_userdata(). Skips re-fire ifuser_meta._hubspot_contact_idis already set. - Post-glue: reads
results[0].id, writes as_hubspot_contact_idtouser_meta - No duplicates, no race condition. The upsert is idempotent by email; the pre-glue guard skips when user_meta is already populated.
What You Need Before Starting
user_meta._hubspot_contact_id so the first order from a registered user is one HubSpot call instead of two.- WordPress (WooCommerce optional)
The
user_registeraction fires on any WP user creation — the WC customer account flow, your registration form, manual/wp-admin/user-new.php, programmaticwp_insert_usercalls, and so on. - Webhook Actions (free) v1.12.0+
Search for FlowSystems in plugin install.
- HubSpot Private App with
crm.objects.contacts.writeAdd to your existing app from Parts 1-3 or create a new one.
- 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. - Shell variables
set shell variables
export SITE="https://your-wordpress-site.com" export TOKEN="fswa_full_..." export HS_PAT="pat-eu1-xxxxxxxx..."
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)
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.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. 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" }'
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.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.
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 [email protected] --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.
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 because user_meta._hubspot_contact_id is already set.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.
Common questions always ask.
Don't see yours? Open an issue on GitHub or check the full reference in the API docs.