Skip to content
Pathbound DOCS

Events — Identify logged-in users

The snippet captures anonymous browsing the moment it loads. Identifying a user connects that anonymous history to a known person — so when someone logs in, every page view they made before signing up gets attached to their contact, and everything they do afterward lands on the same unified timeline.

There are three ways to identify:

What it isTrustWhen it resolves
window.pathbound.identify()One JS call at loginSoft (browser-asserted, verified-domain-gated)Immediately (sets the cookie + creates or links the contact)
POST /v1/identifyAuthenticated server call at loginYour API key asserts the identityImmediately (upserts the contact + merges traits + back-fills)
pathbound_external_contact_id cookieA cookie your app sets at loginSoft (client-set)Every event, at ingest

Start with window.pathbound.identify() — it needs no backend and no API key. Add /v1/identify from your backend when you want the authoritative version: it’s authenticated, merges traits into the contact, and is the only path that can update an existing contact’s properties. The raw cookie remains supported for setups that manage it themselves. All three share the same join key, so using more than one is safe and complementary.

  • The events snippet is installed and your domain is verified.
  • You’ve chosen a stable, opaque, unguessable external ID for each user (see below).
  • For /v1/identify only: an API key with the contacts:write scope (Settings → REST API).

1. Identify from the browser (window.pathbound.identify())

Section titled “1. Identify from the browser (window.pathbound.identify())”

Call it when the user logs in (or on any page load where you know who they are — it’s idempotent):

window.pathbound = window.pathbound || [];
window.pathbound.push(['identify', user.pathboundExternalId, { email: user.email }]);

Always use the array-push form: it works before the snippet loads (the queue is replayed on load), after it loads (the snippet keeps a compatible push method), and it is silently inert when the script never runs — a visitor who opted out via the pathbound_dnt cookie, or an ad-blocked script. A direct window.pathbound.identify(id, traits) call also works, but only once the script has actually loaded — for DNT/ad-blocked visitors window.pathbound remains the plain array from the stub line, and a direct method call throws a TypeError in your page.

One call does all of this:

  1. Sets the pathbound_external_contact_id cookie (1 year), so every subsequent event resolves to the contact at ingest.
  2. Records an identify event on the visitor’s timeline, carrying the external ID and traits.
  3. Creates or links the contact via POST /v1/public/identify — resolved by external_id, then email, linkedin_url, phone. Gated by your verified domain, so it only works from origins you control.
  4. Binds the visitor under the one-device-one-contact rule and back-fills prior anonymous events.

Traits (email, firstname, lastname, company, linkedin_url, phone, …) seed the contact’s properties when it’s created and help match existing contacts — they never overwrite an existing contact’s properties. Browser-asserted data isn’t trusted to mutate profiles; use /v1/identify for that.

When the user logs out, clear the identity so the next person on that browser isn’t attributed to them:

window.pathbound = window.pathbound || [];
window.pathbound.push(['reset']);

reset() clears the external-contact cookie and starts a fresh session. It intentionally does not reset the device’s visitor ID — see the shared-device note.

identify() refuses placeholder values that are almost always instrumentation bugs — "undefined", "null", "NaN", "[object Object]", "anonymous", "guest", "true", "false", empty strings, and similar (the check is case-insensitive and strips quotes). Without this guard, one broken identify(user.id) where user.id is undefined would fold every affected session into a single junk contact. Rejected calls log a console.warn and do nothing; the same list is enforced server-side on every ingestion path.

2. Identify from your backend (POST /v1/identify)

Section titled “2. Identify from your backend (POST /v1/identify)”

The authoritative call. Read the visitor’s pathbound_visitor_id cookie (it’s intentionally JS-readable, so your frontend can forward it or your backend can read it from the request) and assert the binding:

Terminal window
curl -X POST https://api.pathbound.ai/v1/identify \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "usr_8f3a2c9e-…",
"anonymous_id": "the-pathbound_visitor_id-cookie-value",
"traits": {
"email": "[email protected]",
"firstname": "Ada",
"lastname": "Lovelace",
"company": "Example Inc"
}
}'
// e.g. inside your login route handler
await fetch('https://api.pathbound.ai/v1/identify', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PATHBOUND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
external_id: user.pathboundExternalId, // stable + opaque
anonymous_id: req.cookies.pathbound_visitor_id, // stitches prior anonymous events
traits: {
email: user.email,
firstname: user.firstName,
lastname: user.lastName,
company: user.company,
},
}),
});

One call does all of this:

  1. Resolves or creates the contact — by external_id, then email, linkedin_url, phone. A user who isn’t in any connected CRM yet gets a unified profile immediately, instead of staying anonymous until a sync lands.
  2. Binds the external ID and marks the contact identified.
  3. Merges the traits most-recent-wins — your values are never silently clobbered by a later integration sync.
  4. Binds the visitor under the one-device-one-contact rule.
  5. Back-fills the visitor’s prior anonymous events onto the contact.

200 OK — including when a new contact was created (created: true); there is no 201.

{
"status": "success",
"contact_id": "…",
"created": true,
"events_linked": 42,
"contact": { "…": "the full contact record (PII-redacted for MCP / AI-agent callers)" },
"timestamp": "2026-06-09T12:00:00.000Z"
}

The contact object is the same shape returned by the contacts API, so you can read back the merged profile without a second call.

FieldTypeRequiredNotes
external_idstring (≤100 chars)yesYour stable, opaque id for the user. Becomes the contact’s external_contact_id — the join key shared with the cookie and your CRM. Placeholder values ("undefined", "null", "anonymous", …) are rejected with a 400.
anonymous_idstringnoThe visitor’s pathbound_visitor_id cookie. Stitches their prior anonymous events onto the contact. Omit it and you still upsert the contact, just without visitor-event back-fill — events already tagged with this external_id are relinked either way.
traitsobjectnoContact properties to set/update (email, firstname, lastname, company, linkedin_url, phone, …). Merged most-recent-wins.

409 — the contact is already identified under a different ID

Section titled “409 — the contact is already identified under a different ID”

If traits match an existing contact (e.g. by email) that already carries a different external_contact_id, the call returns 409 and changes nothing. Silently re-keying an identified contact would hijack its join key — any events still arriving under the old ID would go unresolved, and the CRM link would break. If you’re deliberately migrating your user IDs, update the contact’s external_contact_id explicitly via the contacts API instead.

window.pathbound.identify() sets this cookie for you; set it manually only if you’re not using the client call (e.g. you build the cookie server-side on your own domain). Use the same opaque ID you pass to /v1/identify:

document.cookie =
`pathbound_external_contact_id=${user.pathboundExternalId}` +
`; path=/; max-age=31536000; SameSite=Strict; Secure`;

On logout, clear it (or call window.pathbound.reset()):

document.cookie = 'pathbound_external_contact_id=; path=/; max-age=0';

The cookie alone resolves events to existing contacts at ingest — it never creates a contact. Pair it with /v1/identify (or use the client identify() call, which does both).

The cookie value, the client identify() ID, and the external_id you pass to /v1/identify must be identical — that single opaque ID is what ties all paths (and your CRM) to one contact.

If you use Pathbound form capture, a submission automatically links its visitor_id to a contact by email — no identify call needed for that path. It obeys the same one-device-one-contact rule.

Set the same opaque ID in your CRM’s Pathbound external-ID field (for HubSpot, the sb_external_contact_id custom property). Then a contact synced from your CRM and the same person identified on your website converge on one external_contact_id — one unified profile instead of two.

The external_id is a join key, not a secret — but make it opaque and unguessable (a UUID or random token). Never use a sequential integer or an email address: a logged-in user can edit their own cookie, and a guessable ID would let them attribute their browsing to someone else’s contact. Pathbound never returns contact data to the browser, so a forged ID can’t read another profile — an unguessable ID closes the write-side (timeline-poisoning) gap entirely. The authenticated /v1/identify path is immune regardless.

A pathbound_visitor_id (a browser/device) can belong to at most one contact. On a genuinely shared browser, a second user’s identify still creates or updates their contact, but the visitor binding to the first owner is refused (and logged) rather than forking that device’s history across both profiles. This is also why reset() doesn’t rotate the visitor ID: device fingerprint recovery would restore it on the next page load anyway, and the ownership rule already keeps the device’s anonymous history with its first owner. On shared devices, the second user’s identified events still resolve to their own contact via the cookie. Moving a visitor between contacts is a deliberate operation, not an ingestion side effect.

Identified contacts are never re-keyed implicitly

Section titled “Identified contacts are never re-keyed implicitly”

No ingestion path — client identify(), the cookie, or /v1/identify traits matching — will ever replace an existing external_contact_id with a different one. The client paths refuse and log; /v1/identify returns 409. Re-keying is an explicit contact update.

identify(), /v1/identify and the cookie all attribute in real time. Events captured after login without the cookie set (relying only on the visitor↔contact link from a one-time identify) attach on the periodic enrichment pass rather than instantly — which is the main reason the client call sets the cookie too.

  • Identity & fingerprinting — how visitor IDs are assigned and recovered across cookie loss.
  • Install the snippet — the anonymous tracking layer this builds on.
  • Events API — read the unified timeline (identified_only filters by whether an event is attributed to a contact).