Session Handoff
Definition
This document explains how a web page running Pulse continues the session of another Pulse platform when the user arrives through an OAuth redirect, and how the producing platform builds the value that carries the session.
The session travels in the OAuth state query parameter. When the landing page loads with a valid, fresh handoff, Pulse adopts the producer's session_id instead of keeping or starting a local one, so both platforms report the user's journey as a single session.
The state parameter
The landing URL carries:
?state=<nonce>.<base64url(returnPath)>.<base64url("psid=<lastActivityMs>|<sessionId>")>
- The value has 3 or more dot-separated segments. Pulse reads only the third one; the nonce, the return path and any later segments are left to the OAuth flow.
- The third segment is the base64url encoding of a query string with a
psidkey. Other keys in it are ignored. lastActivityMsis the producer's last activity time, in milliseconds since the epoch.sessionIdis the producer's current Pulsesession_id.
Pulse reads the URL the page first loaded with, so the handoff still works when the application removes state from the address bar before Pulse boots.
When the session is adopted
A handoff is adopted when its lastActivityMs is fresh:
- less than the session timeout (30 minutes) in the past, and
- at most 60 seconds in the future, to tolerate a producer clock that runs ahead of this device.
The rule depends only on the timestamp. There is no apply-once marker, so the same fresh URL is adopted again on a reload, on the Back button (including a page restored from the back-forward cache) and when the URL is pasted into a new tab. Once lastActivityMs is stale, the URL is ignored and the page keeps its normal session.
A handoff is ignored, with no side effects, when:
- it is stale, or more than 60 seconds in the future;
- the
statevalue is missing or malformed in any way (see Validation rules); - the page is a Web To Mobile page with a
session_idinw2m_meta_info— the Web To Mobile session always wins; - the tracker runs in the
anonymousorpage-sessionmode, which keep the session in memory.
What happens on adoption
- The adopted id becomes the session id for every tab of the site, and the last activity is set to the time of adoption. From then on the session expires, renews and syncs across tabs as usual.
session_startfires only when the adopted id differs from the stored session id. It has nosource, like a first session. When the stored id already equals the handoff id — a reload, a Back or a pasted URL of the same handoff — the session resumes silently.- A page with a fresh handoff never rotates the session because of its own referrer, UTM parameters or click id. The OAuth provider is usually an external referrer, which would otherwise start a new session.
- On a page restored from the back-forward cache, the handoff is checked again with the same rules. A
session_startfired there is not preceded by anavigationevent, because Pulse sends nonavigationon a restore.
The adopted session_id embeds the producer's device id (session ids have the form {startMs}_{deviceId}), while device_id stays the local one. Never derive device_id from session_id.
Only SDK bundles that include the handoff read state. A browser still running an older cached bundle ignores it and keeps a local session.
Producer guide
The platform that starts the OAuth flow builds the third segment from its own Pulse state, right before redirecting:
const { session_id } = await pulse.getState();
const psid = `psid=${Date.now()}|${session_id}`;
const toBase64Url = (value) =>
btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
const state = [nonce, toBase64Url(returnPath), toBase64Url(psid)].join(".");
Build the value as late as possible: Date.now() is the activity time the landing page checks for freshness, and the user may take several minutes on the OAuth provider's screens.
Validation rules
The landing page accepts the handoff only when all of these hold. Anything else is ignored silently.
| Part | Rule |
|---|---|
state | At most 2,048 characters, with 3 or more segments separated by . |
| Third segment | 1–512 characters of the base64url alphabet [A-Za-z0-9_-]. Up to two trailing = padding characters are allowed |
| Decoded segment | Printable ASCII only, parsed as a query string with a psid key. The pipe separator may be raw or encoded as %7C |
lastActivityMs | Exactly 13 digits; fresh as described in When the session is adopted |
sessionId | {startMs}_{device part}: startMs is exactly 13 digits and not later than lastActivityMs; the device part is 4–128 characters of [A-Za-z0-9._-] |
A standard Pulse session_id already has the required shape. Make sure the producer's tracker includes session_id in its state (the default in standard mode); otherwise getState() returns no session_id and there is nothing to hand off.