For most of WhatsApp's life, a person was a phone number. Every message, every group
participant, every read receipt named its sender as <digits>@s.whatsapp.net, and every bot,
CRM sync and support tool ever written against WhatsApp keyed its database on those digits. That
assumption is now wrong, and it has been failing quietly in production systems for the better
part of two years. This post explains the identifier that replaced it — the LID — why
WhatsApp introduced it, exactly where it shows up, what it breaks, and how to change your data
model so it stops breaking.
What a LID is
A LID is a second identifier for a WhatsApp account. Where the classic address is built from the
phone number (15551234567@s.whatsapp.net), the LID is an opaque number WhatsApp's servers
assign to the account, written with its own suffix: 187264518273645@lid. It is stable — the same
account has the same LID tomorrow — but it is not derived from the phone number, cannot be turned
back into one by any arithmetic, and cannot be dialled.
Every account has one, including yours: GET /channel on a Wapito channel returns both phone
and lid for the linked number. The account did not gain a LID when you noticed it; it always
had one. What changed is where WhatsApp uses it.
The name is usually expanded as linked identity — an identity linked to the account rather than to the SIM. Meta has never published a specification for it; what follows is what the open-source protocol implementations (whatsmeow, which Wapito's default engine is built on, and Baileys) have established by observation, and what our own traffic confirms.
Why WhatsApp introduced it
WhatsApp spent its first fifteen years exposing every group member's phone number to every other member. Join a neighbourhood group of three hundred people and three hundred strangers can save, call and message you. That was a privacy problem WhatsApp was under growing pressure to fix, and it was also an obstacle to two features the product wanted: usernames, so that people can be reached without handing out a number at all, and large public surfaces — Channels, community announcement groups — where showing numbers is unthinkable.
Fixing it required an address that is not the number. Once a participant can be represented by a LID, WhatsApp can show you a name and a profile photo in a group without ever transmitting the digits behind them, and can let the two of you message each other through the LID alone. Whether you see the number then becomes a privacy setting — roughly, you see it if the person is already in your contacts, or has chosen to show it — instead of a fact of the protocol.
The rollout has been gradual and uneven, which is part of why it hurt. Newer clients started addressing group traffic by LID first; individual chats followed for accounts that opted into number privacy; the servers began returning LIDs in places that used to return numbers. A library that worked on Monday started receiving unfamiliar senders on Tuesday, with no announcement.
Where it shows up
Concretely, in the traffic a Wapito channel sees today:
- Group messages. The
participantof a message in a group — the person who actually sent it — frequently arrives as…@lid. The group itself is still…@g.us. - Group participant lists and events. Listing participants, and the
groups.participantsevent when someone joins, leaves or is promoted, can name people by LID. - Channel (newsletter) activity. Reactions and comments on Channel posts are LID-addressed by design; there is no number to show.
- Individual chats, sometimes. A person who has switched on number privacy can message you from a LID even one-to-one. You can reply to it — the LID is a perfectly good address — but you may never learn the number.
- Your own contact list. The engine's contact store now carries both identifiers for the contacts it knows, and the mapping between them fills in as traffic flows.
And in the Wapito message object, that becomes three fields instead of one:
{
"id": "false_120363012345678901@g.us_3EB0C767D82B0A1E4F2B",
"chat_id": "120363012345678901@g.us",
"from": "15551234567",
"from_lid": "187264518273645@lid",
"participant": "187264518273645@lid",
"from_me": false,
"type": "text",
"text": { "body": "Is the venue confirmed for Saturday?" }
}
participant is the address the message arrived with. from_lid is that address when it was a
LID. from is the phone number in digits — when Wapito could resolve it, and null when it
could not. That last clause is the whole migration.
What breaks
If your system stores WhatsApp users by phone number, four things go wrong, in roughly this order of how quickly you notice them.
Lookups miss. A groups.participants event says 187264518273645@lid joined your customer
group. Your SELECT … WHERE phone = ? finds nothing, and the welcome flow, the CRM tag or the
seat count never fires. Nothing errors; the row simply is not there.
People appear twice. The same customer messages you one-to-one (you see the number) and posts in your group (you see the LID). Two rows, two histories, two "first seen" dates, and a support agent who cannot see that they are one person.
Replies go to the wrong key. Code that takes a sender, strips the suffix and treats the rest
as a phone number will try to message 187264518273645 — which is not a phone number — or, worse,
will pass it to a number-formatting library that "corrects" it into a real number belonging to
someone else. Wapito refuses that send with 400 invalid_recipient, but a library that does not
validate will happily try.
Reports and dedupe drift. Anything counted per person — unique senders, opt-outs, rate limits you keep on your side — over-counts, and an opt-out recorded against a number does not match a later message from the same person's LID.
The pattern under all four: the phone number was doing two jobs, identity and address, and WhatsApp has split them. The LID is now the durable identity; the phone number is an attribute of it that may or may not be visible to you.
How Wapito maps them
Wapito's job is to hand you a phone number whenever one can honestly be known, and to say so clearly when it cannot. Every inbound message goes through the same resolution chain:
- The engine's own answer. Recent versions of whatsmeow expose a sender-alternate field: the
phone-number address of a LID sender, when the server included it. When it is there,
fromis filled from it and nothing else is needed. - The cached mapping. Every resolved pair is remembered per channel. A sender seen once is
known from then on, and the
contactswebhook event fires the first time a LID is resolved —{ "action": "…", "id": "15551234567@s.whatsapp.net", "phone": "15551234567", "lid": "187264518273645@lid", "name": "Dana" }— so your side can learn the mapping at the same moment we do. - A live lookup. If neither is available, Wapito asks the engine, which asks WhatsApp, and caches the result.
- Honesty. If all three fail — typically because the account hides its number — the event
ships with
from: nullandfrom_lidset. The message is not delayed and nothing is invented.
The same mapping is exposed as endpoints, so you can resolve on demand rather than waiting for traffic. Both are covered on the LID to phone pillar:
# Phone number → LID
curl https://api.wapito.com/v1/contacts/+15551234567/lid \
-H "Authorization: Bearer $WAPITO_TOKEN"
# {"phone": "15551234567", "lid": "187264518273645@lid"}
# LID → contact (with the phone number when it can be known)
curl https://api.wapito.com/v1/contacts/lid/187264518273645@lid \
-H "Authorization: Bearer $WAPITO_TOKEN"
A LID-to-phone lookup that answers 404 not_found is not an error in your code. It is the
protocol telling you that this person has chosen not to be a phone number to you, and the right
response is to carry on with the LID.
Two more places the mapping surfaces: the bulk number check
returns a lid beside each jid, so a contact import can learn both identifiers in one paced
request; and every send endpoint accepts a LID in to, so replying to a LID-only sender is the
same call as replying to anyone else.
Re-keying your data
The fix is a schema change, not a code patch. Treat the WhatsApp account as the entity and both identifiers as nullable, unique attributes of it:
CREATE TABLE whatsapp_contacts (
id BIGSERIAL PRIMARY KEY,
lid TEXT UNIQUE, -- '187264518273645@lid', durable
phone TEXT UNIQUE, -- '15551234567', may be unknown
display_name TEXT,
first_seen TIMESTAMPTZ NOT NULL DEFAULT now()
);
Then, on every inbound event, upsert by whichever identifiers are present and merge when a new event supplies the half you were missing:
// Runs from the webhook worker, after the signature check (see /docs/webhooks/).
async function upsertContact(db, message) {
const lid = message.from_lid ?? null;
const phone = message.from ?? null;
// A message that carries both is the moment two half-rows become one person.
const existing = await db.oneOrNone(
'SELECT * FROM whatsapp_contacts WHERE lid = $1 OR phone = $2 ORDER BY first_seen LIMIT 1',
[lid, phone],
);
if (!existing) {
return db.one(
'INSERT INTO whatsapp_contacts (lid, phone, display_name) VALUES ($1, $2, $3) RETURNING *',
[lid, phone, message.from_name ?? null],
);
}
return db.one(
`UPDATE whatsapp_contacts
SET lid = COALESCE(lid, $2), phone = COALESCE(phone, $3),
display_name = COALESCE($4, display_name)
WHERE id = $1 RETURNING *`,
[existing.id, lid, phone, message.from_name ?? null],
);
}
Three rules make this hold up:
- The LID is the primary identity when you have it. It is the one that will still be there when the number is hidden. Key opt-outs, rate limits and conversation history on the account row, never on the phone column.
- Never derive one identifier from the other. No arithmetic, no "strip the suffix". A LID is a string that happens to contain digits.
- Store the address you will reply to. Keep the exact
participantorchat_idyou received. Sending to it works whether it is a number or a LID; reconstructing it does not.
For the backfill, walk your existing phone numbers through POST /contacts/check in batches of
fifty — it costs one check-quota unit per number, so read GET /channel/limits first and spread
the run over days on Sandbox — and store the lid that comes back. Then subscribe to the
contacts event so the table keeps itself current.
# Backfill LIDs for numbers you already hold, fifty at a time, inside the daily check quota.
import os, time, requests
headers = {"Authorization": f"Bearer {os.environ['WAPITO_TOKEN']}"}
limits = requests.get("https://api.wapito.com/v1/channel/limits", headers=headers, timeout=(5, 30)).json()
budget = (limits["number_checks"]["cap"] or 10**9) - limits["number_checks"]["used"]
for batch in chunks(phones_without_lid, 50):
if budget < len(batch):
break # tomorrow's run picks up here
res = requests.post("https://api.wapito.com/v1/contacts/check", headers=headers,
json={"phones": batch}, timeout=(5, 60))
res.raise_for_status()
budget -= len(batch)
for row in res.json()["results"]:
if row["exists"] and row["lid"]:
save_lid(row["phone"], row["lid"])
time.sleep(2)
Group automations in particular
Groups are where LIDs arrived first and bite hardest, because group code is all about who: who joined, who should be removed, who asked to join. Three specific adjustments:
- When you add or remove participants, pass the identifier you have. Both forms are accepted, but a removal by phone number of a member the group only knows by LID names an address that is not in the group, and it is the participant list — not your CRM — that decides which form is in there. Read the list first and remove what it returns.
- When you send to a group and mention someone, mentions
are still expressed as phone digits in the
mentionsarray and as@digitsin the body. A member you know only by LID cannot yet be mentioned that way; write their name instead. - When you moderate — a welcome message keyed on the joiner, a rule that removes a member after a third strike — key the state on the LID. A member who leaves and rejoins keeps their LID; their number may be visible to you one time and hidden the next.
What to expect next
The direction is clear even if the schedule is not: WhatsApp will keep moving toward the LID as the address you see, and the phone number as something a person chooses to share. Every identifier you cannot resolve today is a preview of the default. Build for it now — LID as the key, phone as an attribute, the exact address stored for replies — and the next protocol change is a column that fills in more often, not an outage.
The glossary entry for LID and its neighbour on JIDs are the short versions of this post; the LID to phone pillar has every endpoint, in twelve languages.