Skip to content

feat: implement unified_session telemetry in ib stanzas#1057

Closed
RafaelFernandesMotta wants to merge 12 commits into
tulir:mainfrom
RafaelFernandesMotta:sendUnifiedSession
Closed

feat: implement unified_session telemetry in ib stanzas#1057
RafaelFernandesMotta wants to merge 12 commits into
tulir:mainfrom
RafaelFernandesMotta:sendUnifiedSession

Conversation

@RafaelFernandesMotta
Copy link
Copy Markdown

This PR implements the unified_session telemetry node sent via ib stanzas, which was observed in recent WhatsApp Web traffic.

Official clients send this time-based identifier to maintain session continuity and for fingerprinting. It is calculated based on the current timestamp with a specific offset and window: (now_ms + 3 days) % 7 days.

Adding this helps whatsmeow stay under the radar by reducing the differences in the connection handshake and activity heartbeat compared to a real browser.

Changes

  • Added internal sendUnifiedSession method to Client
  • Automatically trigger the telemetry node after successful pairing and during PresenceAvailable updates.

@zhamghaoran
Copy link
Copy Markdown
Contributor

sounds good

Copy link
Copy Markdown
Contributor

@purpshell purpshell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Didn't this request some sort of time skew from the server each time?
Might've gotten stored in localStorage

@RafaelFernandesMotta
Copy link
Copy Markdown
Author

RafaelFernandesMotta commented Jan 12, 2026

You're absolutely right. The JS function t() indeed uses Date.now() + WATimeUtils.getClockSkew().

In the current whatsmeow implementation, I couldn't find a publicly exported ServerTimeOffset. Should I use time.Now().UnixMilli() for now, or would you prefer that I expose the internal clock skew (calculated during handshake) to use it here?

From my tests, the web client seems to store this skew in memory/localStorage to keep the unified_session ID in sync with the server's window

I've analyzed the WATimeUtils module and confirmed that it uses a clock skew variable w (set via setClockSkew). Functions like unixTimeMs() calculate the timestamp as Date.now() - (w * 1000).

Since whatsmeow calculates the server's time during the connection but doesn't seem to export it as a simple field like ServerTimeOffset, I'm currently using time.Now().UnixMilli().

Below is part of the original code.

__d( "WAWebUnifiedSession", [ "$InternalEnum", "WALogger", "WASmaxUnifiedSessionShareRPC", "WATimeUtils", "WAWebABProps", "WAWebUnifiedSessionSocketManager", "asyncToGeneratorRuntime", ], function (a, b, c, d, e, f, g) { var h, i, j, k, l, m, n, o, p, q = 7 * d("WATimeUtils").DAY_MILLISECONDS, r = 3 * d("WATimeUtils").DAY_MILLISECONDS; a = b("$InternalEnum")({ InitialRender: "initial-render", Foreground: "foreground", }); c = (function () { function a() { var a = this; this.$1 = null; this.$2 = 0; this.$3 = !1; this.$4 = new (d( "WAWebUnifiedSessionSocketManager" ).UnifiedSessionSocketManager)(function () { return a.$5(); }); } var c = a.prototype; c.getSessionId = function () { return this.$1; }; c.getSequence = function () { return ++this.$2; }; c.clearSessionId = function () { this.$1 = null; }; c.generateSessionId = function (a) { if (!s()) { d("WALogger").DEV( h || (h = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] generate session id skipped: Unified Session is not enabled", ])) ); return; } var b = (t() + r) % q, c = this.$1; this.$1 = String(b); this.$3 = !1; this.$2 = 0; d("WALogger").DEV( i || (i = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] generated new session id. reason: ", ". new ID ", ". old ID ", "", ])), a, this.$1, c ); void this.$6(); }; c.$6 = (function () { var a = b("asyncToGeneratorRuntime").asyncToGenerator(function* () { if (!s()) { d("WALogger").DEV( j || (j = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] send session id skipped: Unified Session is not enabled", ])) ); return; } if (this.$4.isConnected() === !1) { d("WALogger").DEV( k || (k = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] send session id skipped: offline", ])) ); return; } var a = this.$1; if (a == null) { d("WALogger").DEV( l || (l = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] send session id skipped: session id is empty", ])) ); return; } if (this.$3) { d("WALogger").DEV( m || (m = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] send session id skipped: id is alrady sent", ])) ); return; } try { d("WALogger").DEV( n || (n = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] sending to server. session id: ", "", ])), a ), yield d("WASmaxUnifiedSessionShareRPC").sendShareRPC({ unifiedSessionId: a, }), (this.$3 = !0), d("WALogger").DEV( o || (o = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] send to server successfully. session id: ", "", ])), a ); } catch (a) { d("WALogger") .ERROR( p || (p = babelHelpers.taggedTemplateLiteralLoose([ "[unified-session] failed to send session id: ", "", ])), a ) .devConsole({ err: a }) .sendLogs("send-unified-session-id-failed"); } }); function c() { return a.apply(this, arguments); } return c; })(); c.$5 = function () { this.$4.isConnected() && void this.$6(); }; return a; })(); function s() { return ( d("WAWebABProps").getABPropConfigValue("unified_session_version") === 2 ); } function t() { return Date.now() + d("WATimeUtils").getClockSkew(); } e = new c(); g.UnifiedSessionGenReason = a; g.UnifiedSessionManager = e; }, 98 );

__d( "WATimeUtils", ["Promise", "WAHex"], function (a, b, c, d, e, f, g) { "use strict"; var h, i = 60, j = 5 * i, k = 60 * i, l = 24 * k, m = 7 * l, n = 365 * l, o = 60 * 1e3, p = k * 1e3, q = 24 * p, r = 7 * q, s = 1 << 31, t = ~s, u = s + 1, v = { time: 0, day: -4 }, w = 0; function a() { return w; } function c(a) { w = a; } function e(a) { return Z(a).toUTCString(); } function x(a) { a = a | 0; return Math.max(u, Math.min(a, t)); } function y(a) { return x(a / 1e3); } function f(a) { if (typeof a !== "number") if (d("WAHex").hexLongIsNegative(a)) return u; else return t; else return x(a); } function z(a) { if (typeof a !== "number") if (d("WAHex").hexLongIsNegative(a)) return u; else return t; else return C(a); } function A(a) { return a == null ? a : a > t ? y(a) : x(a); } function B() { return C($() - w * 1e3); } function C(a) { return a; } function D(a) { return a; } function E(a) { return C(a * 1e3); } function F(a, b) { return G(N(), a, b); } function G(a, b, c) { b = Math.max(b + c - a, 0); return Math.min(1e3 * b, ~(1 << 31)); } function H(a, b) { b = b != null ? b : N(); return x(Math.ceil(b + Math.max(a, 0))); } function I(a, b) { b = b != null ? b : N(); return x(Math.ceil(b - Math.max(a, 0))); } var J = Math.pow(2, 48); function K(a) { return a >= J ? B() + 6e4 : a; } function L(a) { a = Math.max(a - N(), 0); return Math.min(1e3 * a, ~(1 << 31)); } function M(a) { var c = L(a); return new (h || (h = b("Promise")))(function (a) { return void setTimeout(a, c); }); } function N() { return x(Date.now() / 1e3 - w); } function O() { return x(Date.now() / 1e3); } function P() { return x(new Date().setHours(0, 0, 0, 0) / 1e3 - w); } function Q() { return Date.now() - w * 1e3; } function R(a) { return Math.max(a - N(), 0); } function S(a) { return R(a) > 0; } function T() { return performance.now(); } function U(a) { return Math.floor(performance.now() - a); } function V(a, b) { return a < b ? a : b; } var W = (function () { function a() { var a = T(); this.$1 = a; this.$2 = a; } var b = a.prototype; b.reset = function () { this.$2 = T(); }; b.elapsed = function () { return U(this.$2); }; b.cumulative = function () { return U(this.$1); }; return a; })(); function X(a, b, c) { return Math.abs(a - b) <= c; } function Y(a, b) { return X(N(), a, b); } function Z(a) { return new Date(a * 1e3); } function aa(a, b) { if (Math.abs(a - b) > l) return !1; a = v.time === a ? v.day : Z(a + w).getDay(); var c = v.time === b ? v.day : Z(b + w).getDay(); v.time = b; v.day = c; return a === c; } function ba(a) { a = Number.parseInt(a, 16); return Number.isFinite(a) ? new Date(x(a) * 1e3) : null; } function $() { return Date.now(); } function ca(a) { a = new Date(a); return isNaN(a) ? null : y(a.getTime()); } function da(a, b) { a = Math.abs(a - b); return Math.ceil(a / q); } function ea(a) { var b = 864e5; a = new Date(a.getTime()); var c = a.getUTCDay() || 7; a.setUTCDate(a.getUTCDate() + 4 - c); c = new Date(Date.UTC(a.getUTCFullYear(), 0, 1)); a = Math.ceil(((a.getTime() - c.getTime()) / b + 1) / 7); return a; } g.MINUTE_SECONDS = i; g.FIVE_MINUTES = j; g.HOUR_SECONDS = k; g.DAY_SECONDS = l; g.WEEK_SECONDS = m; g.YEAR_SECONDS = n; g.MINUTE_MILLISECONDS = o; g.HOUR_MILLISECONDS = p; g.DAY_MILLISECONDS = q; g.WEEK_MILLISECONDS = r; g.DEFAULT_UNIXTIME = s; g.MAX_INT = t; g.getClockSkew = a; g.setClockSkew = c; g.toHttpHeaderDate = e; g.castToUnixTime = x; g.castMilliSecondsToUnixTime = y; g.castLongIntToUnixTime = f; g.castLongIntToMillisTime = z; g.castMillisTimeToUnixTime = A; g.millisTime = B; g.castToMillisTime = C; g.fromMillisTime = D; g.castUnixTimeToMillisTime = E; g.timeoutFor = F; g.timeoutForAt = G; g.futureUnixTime = H; g.pastUnixTime = I; g.miAdjustTimestamp = K; g.cappedMillisecondsUntil = L; g.delayUntil = M; g.unixTime = N; g.unixTimeWithoutClockSkewCorrection = O; g.midnight = P; g.unixTimeMs = Q; g.secondsUntil = R; g.isInFuture = S; g.monotonicTime = T; g.monotonicTimeSince = U; g.oldest = V; g.MonotonicTimer = W; g.happenedWithinAt = X; g.happenedWithin = Y; g.toDate = Z; g.sameDay = aa; g.convertHexToDate = ba; g.performanceAbsoluteNow = $; g.convertISO8601DateFormatToUnixTime = ca; g.daysDiff = da; g.weekOfYear = ea; }, 98 );

@purpshell
Copy link
Copy Markdown
Contributor

Implement the time skew by actually getting the data from the server, make that part of the PR.

…ation

- Added serverTimeOffset to Client struct using atomic.Int64 for thread-safe access.
- Implemented server time skew calculation by extracting the t attribute from the connection success node .
- Integrated sendUnifiedSession into the pairing and connection flow
- Ensured session ID generation matches official WhatsApp Web logic: (sync_time + 3 days) % 7 days
@RafaelFernandesMotta
Copy link
Copy Markdown
Author

Done. I've implemented the server time skew synchronization by extracting the t attribute from the success node and storing it in an atomic.Int64 field. The unified_session ID now uses this offset to match the official client behavior

Copy link
Copy Markdown
Contributor

@purpshell purpshell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should fix random "Syncing device" notifications on the main device as well. Testing needed ofc.

@Guilherme-Calesco
Copy link
Copy Markdown

I deployed this branch to my production environment (I have 2k phones connected) yesterday and didn’t run into any issues. I’m not sure if it’s related, but before that some numbers were getting banned just by connecting to the application. After the deployment, I ran a few tests with completely fresh numbers and didn’t get any bans. Does anyone know if this could be related?

rsalcara pushed a commit to rsalcara/InfiniteAPI that referenced this pull request Jan 24, 2026
Implements WhatsApp's unified_session telemetry feature to reduce
detection of unofficial clients. This is an enterprise-grade implementation
inspired by whatsmeow PR WhiskeySockets#1057 and Baileys PR WhiskeySockets#2294.

Features:
- UnifiedSessionManager class with circuit breaker protection
- Server time synchronization for accurate session IDs
- Rate limiting to prevent spam (1 minute between sends)
- Prometheus metrics integration (unified_session_sent, errors)
- Structured logging for debugging
- Configurable via SocketConfig.enableUnifiedSession
- Environment variable support (BAILEYS_UNIFIED_SESSION_ENABLED)

Trigger points (matching official WhatsApp Web):
- After successful login (CB:success)
- After successful pairing (CB:iq,,pair-success)
- When sending 'available' presence

Implementation details:
- Session ID algorithm: (now + serverOffset + 3days) % 7days
- Time constants exported from Defaults/index.ts
- Full test coverage (31 tests)

References:
- tulir/whatsmeow#1057
- WhiskeySockets#2294
- tulir/whatsmeow#810
@tulir tulir closed this in 8e7b838 Feb 10, 2026
devlikepro pushed a commit to devlikeapro/whatsmeow that referenced this pull request Feb 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants