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 mobile app’s webview that serves its pages from capacitor:// (Capacitor on iOS) keeps no cookie, not even within a page load. There the snippet keeps two values in the page’s local storage as well, under the cookies’ names and with their lifetimes:

KeyLifetimePurpose
pathbound_visitor_id365 daysThe device’s visitor ID, renewed on every launch.
pathbound_external_contact_id365 daysThe ID passed to identify(), removed by reset().

Each launch reads both before its first event, so the app’s events carry the user from the first screen. A device with nothing stored gets a new visitor ID: in an app webview whose local storage works the snippet takes no device fingerprint and recovers no visitor ID. Where local storage is unavailable, the snippet falls back to fingerprint recovery, as in a browser. Sessions are still kept only in a cookie, so in an app webview each batch of events starts a new session.

On other pages, iframes included, the snippet never reads or writes local storage.

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 (except in an app webview whose local storage works).

The fingerprint is a SHA-256 hash of:

  • navigator.userAgent, navigator.language, navigator.platform
  • navigator.cookieEnabled, navigator.doNotTrack, navigator.hardwareConcurrency, navigator.maxTouchPoints
  • screen.width × height, screen.colorDepth, window.devicePixelRatio
  • Timezone offset (new Date().getTimezoneOffset())
  • A canvas rendering (a gradient, two lines of text and an arc), drawn three times and reduced to the most common value of each pixel, so browsers that add random noise to every canvas read still produce the same hash
  • 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, rounded to three decimals

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 who clears cookies in the same browser generally lands on the same visitor_id, with their prior history intact. A different browser produces a different fingerprint, so it starts a new visitor.

Recovery is deliberately conservative, because handing someone another person’s visitor_id would merge their histories:

  • Per workspace. Fingerprints are stored per workspace, keyed by the verified domain the request comes from. A visitor ID is never recovered on another customer’s site, even for the same device.
  • Only one candidate. A fingerprint recovers a visitor only if exactly one visitor has used it in the last 30 days. Identical devices (the same phone model and browser, for example) produce identical fingerprints; when several visitors share one, recovery is refused and the visitor starts fresh.
  • Never for bots, and never from a host your workspace excluded (see Excluded hostnames).
  • Fingerprint records expire 90 days after a device was last seen.
  • It does not identify the user by name, email, or any PII — the fingerprint is one-way.
  • It does not persist data outside the cookies (and, in an app webview, local storage) + 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.