WhatsApp Usernames Will Break Your Phone-Keyed Contacts: Migrating to Business-Scoped User IDs
If your WhatsApp integration stores contacts by phone number, 2026 is the year that stops being safe.
WhatsApp is rolling out usernames, and Meta now identifies every user with a business-scoped user ID (BSUID). For someone with a username, a webhook may arrive with no phone number at all.
Here's what changed, the quiet failure it causes in a phone-keyed pipeline, and the identity model and TypeScript we'd use to migrate. The snippets are small, self-contained versions of the pattern, and each was run against the cases shown.
What changed
From Meta's business-scoped user IDs page:
- A BSUID "is a unique user identifier that can be used to message a WhatsApp user when you don't know their phone number". It looks like
US.13491208655302741918: a two-letter country code, a dot, then the ID. Use it whole. - It's scoped to your business portfolio. The same person has a different BSUID with a different business, and it only works with phone numbers in your portfolio.
- It changes when the user changes their phone number.
- The timeline: BSUIDs began appearing in webhooks in April 2026. Businesses could reserve usernames from June 29, 2026. Sending to a BSUID has been supported since July 2026. Usernames reach WhatsApp users gradually through 2026.
- It isn't optional. Meta's page says supporting BSUIDs "is required for all partners", and extends that to businesses integrated directly.
When the phone number disappears
For a user with a username, Meta includes their phone number in webhooks only if, for that business phone number, one of these is true:
- you messaged or called their phone number in the last 30 days;
- they messaged or called you from it in the last 30 days;
- they're in your contact book (a Meta-hosted list built from your interactions).
Otherwise wa_id and from are simply left out. An inbound message can look like this:
{
"contacts": [{ "profile": { "name": "Sam", "username": "sam.builds" }, "user_id": "US.13491208655302741918" }],
"messages": [{ "from_user_id": "US.13491208655302741918", "id": "wamid.HBg...", "type": "text", "text": { "body": "Is the blue one in stock?" } }]
}
Status webhooks change the same way: recipient_user_id sits next to recipient_id, and the phone-based field can be missing.
The failure is silent
Trace what your code does with that message. A typical phone-keyed pipeline does something like this:
const phone = splitPhone(`+${message.from}`); // "+undefined"
const contact = await Contact.findOrCreate({ where: { contact_no: phone.number } });
"+undefined" doesn't parse. If your helper falls back to the digits it can find, that's an empty string. findOrCreate finds or creates a contact whose number is "". Nothing throws.
The next username-only customer matches the same empty number, and so does the one after that. Every customer without a visible phone number ends up in one shared contact and one conversation, and a reply to it goes to an invalid recipient. No error, no log line, and it only starts once usernames reach your customers.
Search your webhook handler for every read of from, wa_id and recipient_id, and check what happens when each is undefined.
Give contacts identities, not a phone number
The fix is to stop treating the phone number as the contact. A contact is your own row; phone numbers and BSUIDs are identifiers that point to it:
CREATE TABLE contact_identities (
business_account_id VARCHAR(36) NOT NULL,
contact_id VARCHAR(36) NOT NULL,
kind ENUM('phone', 'bsuid') NOT NULL,
value VARCHAR(160) NOT NULL,
PRIMARY KEY (business_account_id, kind, value)
);
Scope it by business account, because a BSUID only means something within one portfolio. Then resolve inbound senders by whatever Meta sent, and remember every identifier you see:
// identity.ts
// A contact is our own row. Phone numbers and BSUIDs are identifiers that point to it.
export type IdentityKind = "phone" | "bsuid";
export type Contact = { id: string; name?: string; username?: string };
export interface IdentityStore {
findContact(kind: IdentityKind, value: string): Contact | undefined;
link(contactId: string, kind: IdentityKind, value: string): void;
unlink(kind: IdentityKind, value: string): void;
createContact(fields: Omit<Contact, "id">): Contact;
}
type InboundContact = { wa_id?: string; user_id?: string; profile?: { name?: string; username?: string } };
// Match on whatever Meta sent, then remember every identifier we saw.
export const resolveInbound = (store: IdentityStore, sender: InboundContact): Contact => {
const phone = sender.wa_id;
const bsuid = sender.user_id;
if (!phone && !bsuid) throw new Error("Inbound message with no sender identifier");
const contact =
(bsuid && store.findContact("bsuid", bsuid)) ||
(phone && store.findContact("phone", phone)) ||
store.createContact({ name: sender.profile?.name, username: sender.profile?.username });
if (bsuid && !store.findContact("bsuid", bsuid)) store.link(contact.id, "bsuid", bsuid);
if (phone && !store.findContact("phone", phone)) store.link(contact.id, "phone", phone);
if (sender.profile?.username) contact.username = sender.profile.username;
return contact;
};
What this gets you:
- Existing contacts migrate themselves. A customer you've only known by phone sends a message that carries both
wa_idanduser_id; the BSUID is attached to their existing contact. No backfill job needed for active customers. - Username-only customers get their own contact, keyed by BSUID, instead of sharing a ghost.
- A phone number that arrives later joins the same contact, rather than creating a duplicate.
- A message with no identifier at all is an error you'll see, not a silent merge.
- The username is display data. Store it for the agent's benefit; never key anything on it.
When the number (and the BSUID) changes
A user who changes their phone number gets a new BSUID. Meta tells you with a system message on the messages webhook, carrying both the new and the previous ID:
{ "type": "system", "system": { "type": "user_changed_number", "wa_id": "<NEW_PHONE>", "user_id": "<NEW_BSUID>", "previous_user_id": "<OLD_BSUID>" } }
There's also user_changed_user_id for a BSUID change without a phone number. Handle both, or the customer's next message creates a stranger:
// system-events.ts
import type { IdentityStore } from "./identity.ts";
type SystemMessage = { type: "user_changed_number" | "user_changed_user_id"; wa_id?: string; user_id: string; previous_user_id: string };
// A new number means a new BSUID. Move both identifiers to the same contact
// instead of letting the next message create a stranger.
export const applySystemEvent = (store: IdentityStore, event: SystemMessage) => {
const contact = store.findContact("bsuid", event.previous_user_id);
if (!contact) return;
store.unlink("bsuid", event.previous_user_id);
store.link(contact.id, "bsuid", event.user_id);
if (event.type === "user_changed_number" && event.wa_id) {
if (!store.findContact("phone", event.wa_id)) store.link(contact.id, "phone", event.wa_id);
}
};
Decide separately what to do with the old phone number. Numbers get recycled; keeping it linked forever means a future owner of that number lands in this customer's history.
Sending: to or recipient
Send requests now take either to (a phone number) or recipient (a BSUID). If you send both, to wins. Meta's Marketing Messages API docs recommend sending to phone numbers where you can, "primarily so you can continue to receive phone numbers in webhooks", and the same logic applies to ordinary sends.
And some messages can't go to a BSUID: one-tap, zero-tap and copy-code authentication templates need a phone number (error 131062 otherwise).
// send-target.ts
export type Identifiers = { phone?: string; bsuid?: string };
export type MessageKind = "text" | "template" | "auth_otp_template";
export type SendTarget = { to: string } | { recipient: string };
// Prefer the phone number: sending to phones keeps phone numbers coming back
// in webhooks. One-tap, zero-tap and copy-code OTPs need one.
export const sendTarget = (ids: Identifiers, kind: MessageKind): SendTarget => {
if (ids.phone) return { to: `+${ids.phone}` };
if (kind === "auth_otp_template") throw new Error("This authentication template needs a phone number");
if (ids.bsuid) return { recipient: ids.bsuid };
throw new Error("Contact has no identifier we can send to");
};
Match delivery statuses by message ID (wamid), not by recipient. It's the one field that's always there.
Asking for the phone number
Sometimes you genuinely need the number: for delivery, for a CRM, for an OTP. Meta added a REQUEST_CONTACT_INFO button for utility and marketing templates and interactive messages. If the customer taps it, their number is shared in the chat, and your webhook receives a contacts message with "origin": "contact_request" and the phone number. Feed it through resolveInbound and it joins the existing contact.
Everything else that's keyed on a phone number
The contacts table is the obvious one. Audit these too:
- Opt-outs. An opt-out stored against a phone number won't match a username-only customer. Store it on the contact.
- Block lists. Meta's Block Users API accepts a BSUID (
user_id) as well as a phone number. - Campaign audiences and imports. Imports still bring phone numbers; that's fine, they become phone identities. De-duplicate on identities, not a phone column.
- CRM and other integrations that match customers by phone.
- Analytics that count "unique customers" by phone number.
- Calling, where call events carry the same BSUID fields.
Checklist
- Find every read of
from,wa_idandrecipient_id, and makeundefinedan error you can see. - Move identity to a separate table: phone and BSUID, scoped by business account.
- Resolve inbound senders by BSUID first, then phone, and link whatever is new.
- Handle
user_changed_numberanduser_changed_user_id. - Send with
towhen you have a phone number,recipientwhen you only have a BSUID; keep OTP templates on phone numbers. - Match statuses by message ID.
- Re-key opt-outs, blocks, de-duplication and integrations on the contact, not the phone number.
- Test with Meta's sample messages webhook from your app dashboard before your customers do it for you.
Usernames are good for users: people can talk to a business without handing over their number. Code that assumed a phone number would always be there has to catch up.
I work on WAPGROWAI, a WhatsApp Business platform built on the Cloud API. Earlier in this series: phone numbers, webhooks, WhatsApp Flows and build vs buy.
Comments
No comments yet. Start the discussion.