DEV Community

Cover image for WhatsApp's LID migration broke my bot's @mention detection
Rajat Ghate
Rajat Ghate

Posted on

WhatsApp's LID migration broke my bot's @mention detection

I built a WhatsApp group bot that only answers when it is @mentioned. It worked
perfectly in my test group. Then I added it to a second group and it went
completely silent — no error, no warning, no failed assertion. It received every
message and replied to none of them.

The cause is a migration WhatsApp has been rolling out through 2025 and 2026, and
the thing that makes it painful is that the broken version works on your machine.

One person, two WhatsApp IDs: the same user identified by a phone-number JID and by a LID

What changed

Historically every WhatsApp participant was identified by a JID — a Jabber ID —
which embedded their phone number:

919876543210@s.whatsapp.net
Enter fullscreen mode Exit fullscreen mode

WhatsApp is now also issuing a LID (commonly expanded as "Link ID"; you will
also see "linked identity" and "linked device identifier" — the naming is not
consistent even in WhatsApp's own ecosystem). It looks like this:

275551234567890@lid
Enter fullscreen mode Exit fullscreen mode

A LID is assigned per account, not per group, and it is stable: the same person
has the same LID everywhere. It exists so that phone numbers can be hidden —
in Communities, in groups where an admin has enabled Hide phone numbers, and
increasingly by default as WhatsApp moves away from phone numbers as the visible
identifier.

For a bot author, the consequence is small to describe and large to deal with:

The same person, in the same group, can reach your code as either string — and you do not get to choose which one you receive.

Why mention detection breaks silently

Almost every WhatsApp bot tutorial contains some version of this:

const mentions = msg.message?.extendedTextMessage?.contextInfo?.mentionedJid ?? [];
if (mentions.includes(sock.user.id)) {
  await reply(msg);
}
Enter fullscreen mode Exit fullscreen mode

That is a string comparison between two strings that are both correct and are
never equal.

Comparing a LID against a JID with === returns false, even though both belong to the same person

There is no exception to catch. The mention array is populated, your identity is
populated, the comparison returns false, and the bot concludes it was not
addressed. In the logs this is indistinguishable from an ordinary message that
nobody mentioned the bot in.

It also has an unusually cruel failure profile:

  • Your test group probably shows phone numbers, so everything arrives as a JID and the bug never appears.
  • It is intermittent across groups, because the privacy setting is per group.
  • It can change under you, when an admin enables number hiding on a group that worked yesterday.

Two open Baileys issues track the surrounding mess — #1718
on @lid not resolving to a real number, and openclaw #23029
on exactly this mention-detection failure for reply-to-bot.

The fix that looks right and isn't

The instinct is to normalise inward: take the LID, resolve it to a phone number,
compare phone numbers. Several forks and wrapper packages offer a lidToJid() of
some kind.

I would avoid building on that, for two reasons.

**You would be depending on a mapping you don't control. LID exists precisely so that the phone number need not be disclosed, so resolution is something WhatsApp may or may not give you — and it is least likely to in exactly the groups where LIDs show up. Building on it means building on the thing the feature was designed to prevent.

You do not need it. You are not trying to learn who someone is. You are
answering a much smaller question: is this me? And for that, you already hold
both of your own identities.

The fix that works

Baileys exposes both forms of your own identity on sock.user — id and lid.
So rather than converting the incoming value, reduce both sides to a canonical
form and compare against both of your keys.

Four steps: read the message, normalise the ID, compare against both of the bot's identifiers, decide

The normaliser is deliberately boring. Strip the domain, strip the device suffix,
keep the digits:

/**
 *   "919876543210:12@s.whatsapp.net" -> "919876543210"
 *   "275551234567890@lid"            -> "275551234567890"
 *   "+91 98765 43210"                -> "919876543210"
 */
export function normaliseId(raw: string | null | undefined): string | null {
  if (!raw) return null;
  const localPart   = raw.split('@')[0] ?? '';
  const withoutDevice = localPart.split(':')[0] ?? '';
  const digits = withoutDevice.replace(/\D/gu, '');
  return digits.length > 0 ? digits : null;
}
Enter fullscreen mode Exit fullscreen mode

The :12 matters. WhatsApp appends a device suffix on multi-device accounts, so
even two JIDs for the same person are not reliably equal as strings.

Then collect every form of your own identity once:

export function identityKeys(identity: { id: string; lid?: string }): readonly string[] {
  return [normaliseId(identity.id), normaliseId(identity.lid)]
    .filter((key): key is string => key !== null);
}
Enter fullscreen mode Exit fullscreen mode

And the check becomes a set membership test rather than an equality test:

const keys = identityKeys(botIdentity);

for (const mention of message.mentions) {
  const normalised = normaliseId(mention);
  if (normalised !== null && keys.includes(normalised)) {
    return 'explicit_mention';
  }
}
Enter fullscreen mode Exit fullscreen mode

Note what this does not do: it never tries to discover the phone number behind
a LID. It compares two identifiers you were already given.

The second trap: replying to the bot

Swipe-replying to a bot's message is, to a user, obviously addressing it. Many
bots treat it as an implicit mention — and that path breaks the same way, because
the quoted message's sender also arrives as a LID.

The robust answer is to not use an identifier at all. You already know which
messages you sent, because you stored them. Look the quoted message up by id
and check whether it was yours:

Flowchart: is the bot mentioned? if not, is it a reply to the bot? otherwise ignore the message

Two checks, in order of confidence — and the second one is immune to the whole
problem by construction, which is a good argument for keeping it even after the
first is fixed. If you also support a text fallback such as /^@?bot\b/i, put it
last; it is the one most likely to fire by accident.

Record which check fired. When a bot answers something it shouldn't have, "it
matched the text pattern" is a bug report; "it decided to reply" is a mystery.

Testing something you cannot reproduce on demand

You cannot flip WhatsApp's privacy setting from a test. What you can do is stop
treating the payload shape as something only the network produces.

Keep the identifier logic in its own module with no WhatsApp dependency — it takes
strings and returns strings — and give it both shapes directly:

test('matches a mention that arrives as a LID', () => {
  const identity = { id: '919000000001@s.whatsapp.net', lid: '275551234567890@lid' };

  // The same bot, mentioned in each of the two forms.
  for (const mention of ['919000000001@s.whatsapp.net', '275551234567890@lid']) {
    assert.equal(detectMention({ identity, message: messageWith(mention) }), 'explicit_mention');
  }
});

test('ignores a device suffix', () => {
  // Multi-device accounts append :<device>, so even two JIDs need normalising.
  assert.equal(normaliseId('919000000001:12@s.whatsapp.net'), '919000000001');
});
Enter fullscreen mode Exit fullscreen mode

This is the part I would actually argue for. The LID problem is not hard once you
understand it — perhaps thirty lines. It is hard to find, and it is hard to be
confident you have fixed it, because the environment that triggers it is not under
your control. Pulling the decision into a pure function is what converts "I think
this works now" into something you can assert.

What else carries an identifier

Mention arrays are where this is usually noticed first, but the same shape change
reaches anything that names a participant:

  • participant on a group message — who sent it
  • the quoted message's sender in contextInfo
  • group metadata participant lists, including admin checks
  • any allowlist, blocklist or "is this the owner?" comparison you have written

If you have an owner check that compares a configured phone number against an
incoming identifier, it will fail the same way — and failing open or closed
there matters a good deal more than a missed reply.

The takeaway

Treat a WhatsApp identifier the way you would treat a user-supplied email address:
never compare it raw, normalise it at the boundary, and keep the normalisation in
one tested place.

The deeper point is that this was never really a string-comparison bug. It was an
assumption — that a person has one address — that was true for years, quietly
stopped being true, and was never written down anywhere in my code. The fix was
small. Noticing was the expensive part.


References

Top comments (0)