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 → 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:

  1. Sets the pathbound_external_contact_id cookie (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.
  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, 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).
  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 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).

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.

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": "ada@example.com",
"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. 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.
  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. An email address another contact already holds is not written (see skipped_traits).
  4. Binds the visitor under the one-device-one-contact rule.
  5. 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.

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_traits is 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 your external_id was 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.
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. 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.

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, and a submission from an app webview creates or updates the contact without binding the device.

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.

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 why reset() 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.

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.

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.

  • 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).