Skip to content
Pathbound DOCS

Events — Identity & fingerprinting

The snippet resolves “is this the same visitor I saw before?” through three layers, each more durable than the last:

  1. The pathbound_visitor_id cookie.
  2. Device fingerprint recovery when the cookie is missing.
  3. External contact ID when your application has authenticated the user.

Plus a session cookie that scopes events to a single browsing session, and a Google Analytics user ID capture that lets you join Pathbound events with GA reports.

CookieLifetimeScopePurpose
pathbound_visitor_id365 dayspath=/; SameSite=Strict; SecureStable per-browser identifier.
pathbound_session_id1 day, slidingpath=/; SameSite=Strict; SecureActive browsing session. Refreshes on activity.
_sellably_visitor_id (mirror)365 dayssame as visitor_idBackward-compat duplicate (legacy SaaS name).

Both cookies are set client-side by the snippet, so both are readable from JS; the visitor cookie in particular is read by SPA code to fire identified events.

CookiePurpose
pathbound_external_contact_idThe logged-in user’s external ID. Set by window.pathbound.identify() (or by your application directly); cleared by window.pathbound.reset(). The snippet forwards this on every event so the backend can resolve it to a Pathbound contact.
_sellably_external_contact_idBackward-compat fallback for the same.
_gaGoogle Analytics client ID — captured into the event as ga_user_id so you can correlate Pathbound events with GA.
pathbound_dntIf =1, the snippet exits at the very top of the script and does nothing.

A session is a single browsing window. The session cookie stores <id>|<last-activity-ms>. On every event:

  • If the cookie is missing, a new session id is minted and a 1-day cookie is set.
  • If the last activity was more than 30 minutes ago, the old session ends and a new one begins.
  • Otherwise the cookie’s last-activity timestamp is bumped, extending the session.

This is the same heuristic GA4 uses: a session is what a human would call a “visit,” not what the cookie engine treats as a single connection.

When a visitor arrives without a pathbound_visitor_id cookie, the snippet attempts to recover their canonical visitor ID from the device fingerprint before minting a new one.

The fingerprint is a SHA-256 hash of:

  • navigator.userAgent, navigator.language, navigator.platform
  • navigator.cookieEnabled, navigator.doNotTrack, navigator.hardwareConcurrency
  • screen.width × height, screen.colorDepth, window.devicePixelRatio
  • Timezone offset (new Date().getTimezoneOffset())
  • A canvas rendering of “Device fingerprint” text in 14 px Arial — the canonical browser-fingerprint canvas trick
  • The WebGL UNMASKED_VENDOR + UNMASKED_RENDERER strings (when WEBGL_debug_renderer_info is exposed)
  • An OfflineAudioContext rendering — sums the absolute amplitudes of the audio output, which depends on the device’s audio stack

If the browser doesn’t support crypto.subtle.digest, the snippet falls back to a 64-bit DJB2-style hash.

  1. The snippet computes the fingerprint hash.
  2. Checks if pathbound_visitor_id is already set — if so, ship a _fingerprint_update event to refresh the server-side mapping and continue.
  3. If no cookie, POST /recover-visitor with { fingerprint }.
  4. If the server returns a known visitorId, set it as the cookie.
  5. Otherwise, mint a fresh UUID and treat this as a new visitor — fire first_visit.

This means a user clearing cookies or switching browsers on the same machine generally lands on the same visitor_id, with all of their prior history intact.

  • It does not identify the user by name, email, or any PII — the fingerprint is one-way.
  • It does not persist data outside the cookie + the server-side fingerprint mapping.
  • It does not ship raw fingerprint signals — only the SHA-256 hash leaves the browser.
  • It does not override the pathbound_dnt opt-out — DNT visitors don’t fingerprint.

When your application authenticates a user, tell the snippet:

// On login (or any page load where you know who the user is):
window.pathbound = window.pathbound || [];
window.pathbound.push(['identify', user.externalId, { email: user.email }]);
// On logout:
window.pathbound.push(['reset']);

identify() sets the pathbound_external_contact_id cookie (and creates or links the contact — see the identify quickstart); reset() clears it and starts a fresh session. If you’d rather manage the cookie yourself, setting it directly still works:

document.cookie =
'pathbound_external_contact_id=' + contact.externalId +
'; path=/; max-age=31536000; SameSite=Strict; Secure';

The snippet reads this cookie on every event and includes it in the payload. The backend resolves it against the contact’s stored external_contact_id (or your custom property mapping) and stitches the event timeline together — even across visitor cookies and devices.

This is the most reliable identification mechanism. Use it whenever you can. Placeholder values ("undefined", "null", "anonymous", and similar) are rejected on every path — client and server — so an instrumentation bug can’t fold unrelated visitors into one junk contact.

The external_contact_id is a soft identity assertion — the cookie path is not authenticated, so treat the value as a join key, not a secret, and make it opaque and unguessable (a UUID or random token). Never use a sequential integer, an email address, or anything an attacker could enumerate: a logged-in user can edit their own cookie, and a guessable ID lets 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 — but an unguessable ID closes the write-side (timeline-poisoning) gap entirely.

For a tamper-proof binding — authenticated, trait-carrying, and able to create a profile on the spot — use the server-side POST /v1/identify call. That’s the recommended path; see the Identify logged-in users quickstart for the full setup (cookie + API, logout handling, and the one-device-one-contact rule).

When an event arrives, the backend tries to identify the visitor in this order:

  1. external_contact_id in the event payload → matches a Pathbound contact directly.
  2. Form submission email → matches a contact by properties.email, creating one if no contact matches.
  3. visitor_id that has a prior identified event → joins to that contact via the events collection.
  4. No identification → event is recorded with internal_contact_id: null. It’s still queryable (set identified_only=false in /v1/events), and will retroactively join to a contact on the next identification.

Anonymous events are not lost; they sit waiting until a future identification event reveals who they belonged to all along.

Two guard rules apply throughout the cascade:

  • One device → one contact. A visitor_id binds to at most one contact; a second user’s bind on a shared browser is refused (and logged), never forked.
  • Identified contacts are never re-keyed implicitly. If an attribute match (e.g. by email) lands on a contact that already carries a different external_contact_id, the mapping is refused and logged (/v1/identify returns 409). Re-keying is an explicit contact update, not an ingestion side effect.