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
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. 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. Webhook Actions (free) v1.12.0+

    Search for FlowSystems in plugin install.

  3. HubSpot Private App with crm.objects.contacts.write

    Add to your existing app from Parts 1-3 or create a new one.

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

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.

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

Stop losing webhooks.
Start logging them.

$ wp plugin install wp-webhooks --activate