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 is | Trust | When it resolves | |
|---|---|---|---|
window.pathbound.identify() | One JS call at login | Soft (browser-asserted, verified-domain-gated) | Immediately (sets the cookie + creates or links the contact) |
POST /v1/identify | Authenticated server call at login | Your API key asserts the identity | Immediately (upserts the contact + merges traits + back-fills) |
pathbound_external_contact_id cookie | A cookie your app sets at login | Soft (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.
Before you start
Section titled “Before you start”- 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/identifyonly: an API key with thecontacts:writescope (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:
- Sets the
pathbound_external_contact_idcookie (1 year), so every subsequent event resolves to the contact at ingest. - Records an
identifyevent on the visitor’s timeline, carrying the external ID and traits. - Creates or links the contact via
POST /v1/public/identify— resolved byexternal_id, thenemail,linkedin_url,phone. Gated by your verified domain, so it only works from origins you control. - 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.
Log out with reset()
Section titled “Log out with reset()”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.
Invalid IDs are ignored
Section titled “Invalid IDs are ignored”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:
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 handlerawait 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:
- Resolves or creates the contact — by
external_id, thenemail,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. - Binds the external ID and marks the contact identified.
- Merges the traits most-recent-wins — your values are never silently clobbered by a later integration sync.
- Binds the visitor under the one-device-one-contact rule.
- Back-fills the visitor’s prior anonymous events onto the contact.
Response
Section titled “Response”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.
Request fields
Section titled “Request fields”| Field | Type | Required | Notes |
|---|---|---|---|
external_id | string (≤100 chars) | yes | Your 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_id | string | no | The 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. |
traits | object | no | Contact 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.
3. Set the cookie yourself (manual)
Section titled “3. Set the cookie yourself (manual)”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 theexternal_idyou pass to/v1/identifymust be identical — that single opaque ID is what ties all paths (and your CRM) to one contact.
4. Forms (no extra work)
Section titled “4. Forms (no extra work)”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.
5. If you also sync a CRM
Section titled “5. If you also sync a CRM”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.
Rules worth knowing
Section titled “Rules worth knowing”Choose a safe external ID
Section titled “Choose a safe external ID”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.
One device → one contact
Section titled “One device → one contact”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.
Timing
Section titled “Timing”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.
See also
Section titled “See also”- 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_onlyfilters by whether an event is attributed to a contact).