Losing the phone number, part 1: Meta's business-scoped user IDs
Mama knew every user by their WhatsApp number. Then Meta said the number would stop arriving for anyone who adopts a username. We changed how Mama identifies users, but there's still one thing we can't do.
In February, Meta emailed us about WhatsApp usernames.
WhatsApp users would be able to choose a username instead of sharing their phone number with every business they message. For businesses using the WhatsApp Cloud API, that means the phone number could eventually disappear from incoming messages.
Meta would provide a different identifier instead, called the business-scoped user ID, or BSUID.
That was a problem for MoneyMama. We’d built Mama on WhatsApp, and the phone number was how she knew everyone. It was used to find users, keep conversations together, check registrations and send replies.
We had to change quite a bit before those numbers started disappearing.
When everything depends on a phone number
When I first built Mama on WhatsApp, using the phone number as the user’s identity made sense.
WhatsApp supplied it with every incoming message. It was unique, and people registered with it.
Our table of users had a phone number column that was both unique and required. Conversations were linked to users, while the in-memory chat state and registration whitelist used phone numbers as their keys.
It went further than that. Reminders, daily summaries and broadcasts were queued using phone numbers. The edge functions that sent those messages expected one, and our back office used phone numbers to look up users.
Then in April, while we were working on the BSUID changes, our Stripe integration added another dependency: purchases were recorded against phone numbers too.
None of these decisions had caused a problem before. But removing the assumption that every user had a phone number meant going through nearly every part of the system.
What Meta is changing
WhatsApp usernames are optional. For someone who chooses one, Meta says their phone number remains available to a business for 30 days after the last message or call between them.
After that, the phone number is no longer included in the webhook. The business receives a BSUID instead.
A BSUID looks something like this:
MY.1349120865530274191
It starts with a two-letter country code, followed by a dot and a long number.
The identifier belongs to the relationship between that user and the business. The same person gets a different BSUID when talking to another business, and their BSUID stays the same even if they change their phone number.
The webhook changes are fairly small. Meta’s example, trimmed to the relevant fields, looks like this:
{
"contacts": [
{
"wa_id": "<WHATSAPP_USER_ID>",
"user_id": "<BSUID>"
}
],
"messages": [
{
"from": "<WHATSAPP_USER_PHONE_NUMBER>",
"from_user_id": "<BSUID>"
}
]
}
The new user_id and from_user_id fields carry the BSUID.
The existing wa_id and from fields carry the phone number, but only while Meta is still sharing it. When the number is no longer available, those fields are omitted entirely. They don’t arrive as empty strings.
Sending a message changes too. The API gets a new recipient field for the BSUID, alongside the existing to field for phone numbers.
{
"messaging_product": "whatsapp",
"recipient_type": "individual",
"recipient": "<BSUID>",
"type": "text",
"text": {
"body": "<BODY_TEXT>"
}
}
If a request includes both to and recipient, Meta uses to. To send a message using a BSUID, we have to leave the phone number out.
At least, that’s how the documentation says it will work.
Planning before the rules were final
I started planning the migration as soon as the email arrived in February.
Claude and I put together a fairly long document covering what BSUIDs were, how Mama currently identified users, what would break and how we intended to migrate everything.
The idea was to feed the document back to Claude when we were ready to implement it.
Then Meta published the proper documentation at the end of March, and quite a bit of what we’d planned was wrong.
The BSUID format had changed. We’d assumed missing phone numbers would arrive as empty strings, but they would actually be omitted. Sending to a BSUID would use the new recipient field rather than the existing to field.
There were also things we hadn’t accounted for, including a contact book and a user_id_update webhook for identifier changes.
Meta now had a rollout timeline too: BSUIDs would start appearing in webhooks in April, with the ability to send messages to them following in May.

We had to go back through the plan, comparing the February assumptions against the March documentation and rewriting the parts that no longer applied.
That’s the problem with planning too early. I’d spent time planning around details Meta hadn’t finalised, and then had to keep up with their changes.
Giving users a second identifier
We implemented the changes at the end of April.
The first job was the database. A user could no longer be required to have a phone number, but every user still needed at least one identifier.
We made the phone number nullable, added a BSUID column and a constraint requiring at least one of the two.
The SQL examples here are simplified, with some names changed.
ALTER TABLE accounts
ALTER COLUMN phone_number DROP NOT NULL;
ALTER TABLE accounts
ADD CONSTRAINT one_identifier_required
CHECK (
phone_number IS NOT NULL
OR business_user_id IS NOT NULL
);
CREATE UNIQUE INDEX ON accounts (business_user_id)
WHERE business_user_id IS NOT NULL;
The next change was how we find users.
Previously, the database function only needed a phone number. Now it accepts both identifiers, checking the phone number first and then the BSUID if it doesn’t find a match.
If it finds an existing user, it can also fill in whichever identifier is missing.
IF p_phone IS NOT NULL THEN
SELECT id, phone_number, business_user_id
INTO v_id, v_phone, v_bsuid
FROM accounts
WHERE phone_number = p_phone;
END IF;
IF v_id IS NULL AND p_bsuid IS NOT NULL THEN
SELECT id, phone_number, business_user_id
INTO v_id, v_phone, v_bsuid
FROM accounts
WHERE business_user_id = p_bsuid;
END IF;
-- Store the BSUID against an existing phone-based account.
IF v_id IS NOT NULL
AND v_bsuid IS NULL
AND p_bsuid IS NOT NULL THEN
UPDATE accounts
SET business_user_id = p_bsuid
WHERE id = v_id;
END IF;
While Meta still supplies both identifiers, an incoming message lets us associate the BSUID with the phone number we already know.
Later, when that user’s phone number stops appearing, Mama can find the same account using the BSUID.
That works for users who message us during the period when both identifiers are available.
On the webhook side, we changed the handler to accept either identifier while still preferring the phone number when it’s present.
const bsuid = message.from_user_id || senderContact?.user_id;
const phone = from || senderContact?.wa_id;
const resolvedUserId = phone || bsuid;
if (!resolvedUserId) {
console.error("Webhook message has neither phone nor BSUID, dropping");
return c.body(null, 200);
}
The change didn’t stop at the webhook.
We added a helper to distinguish phone numbers from BSUIDs. The state cache now maps both identifiers to the user’s internal UUID, so switching identifiers doesn’t mean starting a fresh conversation.
The edge functions and message queues were updated to carry both identifiers, and the back office no longer assumes every user has a phone number to display.
We also went back to the Stripe integration. Purchases now have a column for the user’s internal ID, and the view that determines whether someone is a paying customer joins using that ID instead of their phone number.
It was quite a lot of work for something that, from the user’s side, wasn’t supposed to change anything.
Ready to receive, but not to reply
By the end of April, Mama could handle an incoming message with a BSUID and no phone number.
There was still one problem: we couldn’t send a message back.
Meta had announced that sending to BSUIDs using the new recipient field would become available in May. Nothing we’d seen said it was available for our account.
It’s now August.

For now, every outgoing message checks whether its destination is a phone number or a BSUID.
If it’s a BSUID, we log it and skip sending.
That applies to everything: Mama’s replies, reminders, daily summaries, broadcasts and purchase confirmations.
The system can recognise the user, find their account and maintain their conversation. But if Meta has stopped sharing their phone number, we have no working way to reach them.
That’s the part I’m least comfortable with.
Someone could message Mama today and hear nothing back.