Janarayson

Losing the phone number, part 1: Meta's business-scoped user IDs

Engineering7 min read

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.

Optimus Prime saying well, the rules have just changed

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);
}
Within 30 days of the last message More than 30 days since the last message A WhatsApp message to Mamafrom someone who has adopted a username Meta's Cloud APIbuilds the webhook payload "from": "60123456789", no "from" field at all "from_user_id": "MY.1349120865530274191" Our webhook servicethe sender is the phone if present, otherwise the BSUID The chat service found by phone, and the BSUID is stored alongside no phone, so found by the BSUID stored earlier The user recordphone number | BSUID, at least one of the two the reply "to": phone "recipient": BSUID
A message’s path to Mama and back, before and after the phone number disappears.

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.

A cartoon player pulling the goalposts away as the ball comes in

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.