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 → API keys).
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) and keeps the ID in memory for the rest of the page load, so every subsequent event carries it and resolves to the contact at ingest, even where the cookie can’t be stored. In a mobile app’s webview it also keeps the ID in local storage, so the next launch starts with it. - 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, and nothing else. Gated by your verified domain: browsers only send the call from pages on your domains. That check isn’t authentication, because a script outside a browser can claim any origin (see Choose a safe external ID). - 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 email finds an existing contact — they never overwrite an existing contact’s properties. Browser-asserted data isn’t trusted to mutate profiles; use /v1/identify for that. Identify never creates a company: your backend writes the account and names it on the contact (see Integrate from your backend).
Log out with reset()
Section titled “Log out with reset()”When the user logs out, clear the identity so the next person’s identified activity on that browser isn’t attributed to them:
window.pathbound = window.pathbound || [];window.pathbound.push(['reset']);reset() clears the external-contact cookie, the ID kept in memory and, in a mobile app’s webview, the ID kept in local storage, and starts a fresh session. Events captured before reset() keep the user they were captured under. It does not reset the device’s visitor ID, and the device stays bound to the user who logged out. So anything the next person does before they log in is still attributed to the previous user. 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": "ada@example.com", "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. A user who isn’t in any connected CRM yet gets a unified profile immediately, instead of staying anonymous until a sync lands. A phone number or a LinkedIn address never finds a contact: two people who share an office line stay two contacts. - 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. An email address another contact already holds is not written (see
skipped_traits). - Binds the visitor under the one-device-one-contact rule.
- Back-fills the visitor’s prior anonymous events onto the contact.
The call is idempotent: sending it again finds the same contact and changes nothing. Identify says who someone is; it does not write their account. To create the person with their company, or to record a plan change, use the contact and company routes keyed on your own ids: see Integrate from your backend.
Response
Section titled “Response”200 OK — including when a new contact was created (created: true); there is no 201.
{ "status": "success", "contact_id": "665f1c2ab3d4e5f6012345ab", "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.
skipped_traitsis present when a trait was not written, by field, with the reason. Today that is"email": "email_belongs_to_contact:<contact_id>": the contact found by yourexternal_idwas given an address that another contact already holds, so the address was left where it is and the other traits were written. Two contacts for one person is something to resolve in your data or in Pathbound; the call does not fail, so a user’s later calls (a plan change) keep working.
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. A traits.company string is a property of the contact, not a link to a company. sb_visitor_ids is ignored: a visitor is tied to the contact only through anonymous_id. |
409 — the contact is already identified under a different ID
Section titled “409 — the contact is already identified under a different ID”If no contact has your external_id and traits.email belongs to a contact that already carries a different external_contact_id, the call returns 409 with that contact’s contact_id 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. No API route changes a contact’s external ID once it is set.
To read the contact back by your own ID, use external_id on GET /v1/contacts.
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, and a submission from an app webview creates or updates the contact without binding the device.
5. If you also sync a CRM
Section titled “5. If you also sync a CRM”A contact synced from your CRM and the same person identified on your website converge on one profile by email: pass email in the traits. Identify then finds the synced contact and gives it your external ID (if it doesn’t have one yet), so later events resolve to it directly.
Matching on an external ID stored in the CRM itself (for HubSpot, the sb_external_contact_id custom property) isn’t available yet: the value is synced but not used to match contacts.
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 stops someone from writing to another user’s ID. It doesn’t stop a claim by email: a browser identify with a made-up ID and someone else’s email links that ID to their contact if the contact has no external ID yet, and the real user’s identify is then refused. To close that, give your contacts their external IDs ahead of time, from your backend with /v1/identify or from a synced database keyed on your user ID. The authenticated /v1/identify path is immune regardless.
Iframes and app webviews: identify on every page load
Section titled “Iframes and app webviews: identify on every page load”Where cookies don’t persist, the identify cookie is lost on the next load: a page embedded in another site’s iframe (Safari blocks third-party cookies), or a mobile app’s webview. In those contexts call identify() on every page load, not only at login.
Within a page load the snippet keeps the ID in memory: events after the call carry it, and so do events captured before it that are still waiting to be sent. The snippet sends a batch two seconds after the last event, after ten events, on every in-app navigation, when the page is hidden or closed, and at once on identify(). Events sent before the call stay anonymous, and in an app webview nothing attributes them later. So call identify() as early as you can in each app start, before the app’s first route change: a redirect from / to /dashboard sends the first page view straight away.
In a mobile app’s webview (capacitor:// pages) the snippet does two more things, because there the ID has no cookie to come back from:
- It keeps the ID in local storage, so the next launch’s events carry it from the first screen, before your app calls
identify(). That ID is the last one identified on the device, which is whyreset()at logout matters there as much as on the web. - When a launch starts with no stored ID, it holds its events until
identify()or five seconds after load, whichever comes first, so that the first screen goes with the ID when your app identifies the user in time. Hiding or closing the app still sends them at once.
Keep calling identify() on every app start all the same: the operating system may clear local storage when the phone runs short of space.
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. Because reset() doesn’t rotate the visitor ID, the device’s anonymous activity keeps landing on its first owner, including what the next person does before logging in. The second user’s identified events still resolve to their own contact through their external ID. Moving a visitor between contacts is a deliberate operation, not an ingestion side effect.
App webviews are the exception. In a mobile app’s webview (for example Capacitor, capacitor:// pages), no cookie survives a reload, and visitor IDs recovered there from the device fingerprint are shared by identical phones. Pathbound never binds an app-webview visitor ID to a contact and never attributes app-webview events through it: they’re attributed only through the external ID they carry.
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 matching by email — will ever replace an existing external_contact_id with a different one. The client paths refuse and log; /v1/identify returns 409.
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. In an app webview there is no visitor link to rely on, so events there are attributed only when they carry the external ID.
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).