Janarayson

Losing the phone number, part 2: the users who came back

Engineering9 min read

We'd prepared Mama for WhatsApp's new user IDs. Then users started reporting unanswered messages, duplicate accounts and login problems after months away.

Part 1 ended with the problem I was least comfortable with. We’d changed Mama to recognise users by their business-scoped user ID (BSUID), but we couldn’t reply to them until Meta enabled the new recipient field.

A week after I wrote that, the reports started coming in.

People were messaging Mama and getting nothing back.

Dwayne Johnson asking if this is what being ghosted feels like

Their messages were arriving without a phone number in the from field. Only the BSUID remained in from_user_id.

They’d adopted a WhatsApp username, the 30-day period for sharing their number had expired, and our webhook was doing exactly what we’d programmed it to do: log the outgoing message and skip sending it.

The switch was already on

The first thing I checked was whether Meta had enabled BSUID sending.

The public documentation still didn’t mention the recipient field, even though Meta had originally announced support for May. We were now in August.

So we tried sending a message using recipient with a known BSUID, leaving out to completely.

It worked. The message arrived on the user’s WhatsApp.

JonTron throwing his hands up and saying it works

Claude also found a way to test whether Meta recognised the field without involving a real user.

Send a valid message payload with a made-up BSUID, and Meta returns a delivery error. That means it understood the recipient field but couldn’t deliver the message.

Change recipient to a made-up field name, and Meta complains that the required to parameter is missing.

The difference in those responses tells us whether the new field is supported.

We deployed the sending changes that same day, across the webhook service, chat service and edge functions.

Instead of every send hardcoding to, everything now uses one helper:

// Phones use `to`, BSUIDs use `recipient`.
export function sendTarget(
  identifier: string
): { to: string } | { recipient: string } {
  return isBsuid(identifier)
    ? { recipient: identifier }
    : { to: identifier };
}

The guards that previously logged and skipped BSUID messages could finally go.

There was one exception. Meta doesn’t support sending authentication templates, which carry one-time codes, to a BSUID. We kept that check.

Mama could finally reply to people whose phone numbers were hidden.

Registered, but Mama didn’t recognise them

Once the replies were working, another problem appeared.

Some users were getting messages from Mama telling them they weren’t registered, even though they’d already signed up.

Our registration whitelist was still keyed by phone number.

If someone arrived with a BSUID we didn’t recognise, the whitelist couldn’t match it to their existing registration. As far as that check was concerned, they were a new user.

We needed a way to connect the BSUID to the phone number they’d registered with.

So Mama now asks an unrecognised BSUID user for their registered phone number. We accept the common ways Malaysians type their numbers, with or without the leading zero or country code, and check the number against the whitelist.

Once we’ve identified the right registration, we can store the BSUID alongside it. Future messages then resolve using the BSUID.

There was an important security consideration.

We couldn’t let someone claim an existing account simply by typing its phone number into a chat. If that account already had history, we wouldn’t link it automatically.

Instead, Mama tells the person that a human will need to check. The response doesn’t reveal whether the number belongs to a registered user.

We also hit a rather annoying bug in production.

The flow stores a flag in Redis while waiting for someone to provide their number. We’d stored the flag as "1" and checked whether the value returned was the same string.

Locally, it worked.

In production, our Redis client deserialised the value and returned the number 1 instead. The string comparison failed, so Mama kept asking the user for their phone number.

We changed the check to look for the flag’s presence rather than its value.

The shadow accounts

The following day, a couple of users reported something stranger.

Their dashboards were empty. The expenses they’d logged through chat weren’t there, and anything they added through the dashboard didn’t appear in their chat history.

They’d ended up with two accounts.

One was their real account. The other had their BSUID stored in the phone number column.

We traced it to a lookup that still assumed every WhatsApp identifier was a phone number.

It was doing this:

.eq("phone_number", id)

When id was a BSUID, the lookup failed and threw an error.

The error handler then sent the user a support message. But sending that message went through an older, phone-based entry point that also creates a conversation if one doesn’t exist.

It couldn’t find a user with that BSUID as a phone number, so it created one.

That left us with a shadow account. Chat was writing to the real account, while the dashboard was reading the other one.

The fix was to stop assuming that an identifier belonged in the phone number column.

We added a helper that determines which column to search:

export function identifierColumn(
  id: string
): "phone_number" | "business_user_id" {
  return isBsuid(id)
    ? "business_user_id"
    : "phone_number";
}

// At each lookup
.eq(identifierColumn(id), id)

We applied it to every lookup that accepts a raw WhatsApp identifier and fixed the conversation entry point that had been creating the duplicate rows.

Then we merged the shadow accounts back into the real ones.

The order mattered. We had to deploy the fix before repairing the data. Otherwise, the old code could create another shadow account the next time the user messaged Mama.

Then we shut WhatsApp down

At the end of September, we shut down Mama’s WhatsApp service and moved everyone to the mobile app.

That’s a separate story, but it matters here because we didn’t switch off the WhatsApp number completely.

A small function still answers anyone who messages the number, telling them to move to the app.

It also handles /login, which is how existing WhatsApp users can get a PIN to sign in to the app.

And that’s where the last group of affected users appeared.

It’s been 84 years

In the first week of October, someone reported that they couldn’t log in.

They’d sent /login to Mama, but instead of receiving a PIN, they got the usual message telling them to download the app.

An elderly woman saying it's been 84 years

When we investigated, we found that their last conversation with Mama had been in March.

We only started storing BSUIDs in April.

Since their last message, the user had adopted a WhatsApp username. Their phone number was no longer included in the webhook, and the BSUID didn’t match anything in our database.

Mama had no way of knowing they were an existing user.

As far as the system could tell, a stranger had sent /login.

It wasn’t an isolated case either. Every login attempt from an unknown BSUID since the WhatsApp shutdown had failed in the same way.

And we couldn’t simply guess who those users were.

The remaining WhatsApp function doesn’t record message text or profile names. Even if it did, matching a BSUID to a phone-only account based on the timing of a message would be a terrible way to establish someone’s identity.

The frustrating part was that we’d anticipated this exact scenario.

The February migration plan described a user who adopts a username, doesn’t message Mama for more than 30 days, then returns with only a BSUID.

But knowing that it could happen wasn’t the same as having a working solution.

Meta’s contact book was supposed to help with identifier mapping. I believe it’s enabled by default, but it only knew about people who’d interacted with us since the April rollout.

Someone who hadn’t messaged since March was exactly the kind of user it couldn’t help us identify.

Send the PIN to the phone number

We needed a way for a returning user to identify their old account without giving someone else access to it.

The solution was to ask for the registered phone number, but not treat knowing that number as proof of ownership.

Now, when an unknown BSUID sends /login, Mama asks for the phone number they used to register.

For the next 30 minutes, the system treats their following message as the response.

It accepts a number by itself or in a sentence, a shared WhatsApp contact card, and even full-width digits from a Chinese keyboard. It also handles numbers entered with or without the leading zero or Malaysian country code.

Once we have the number, we send the login PIN to that phone number.

Not to the BSUID that asked for it.

The sender receives the same response regardless of whether the number belongs to a registered account. That way, the flow doesn’t tell someone which phone numbers have MoneyMama accounts.

And typing someone else’s number doesn’t give you their PIN. The code goes to that person’s WhatsApp instead.

/loginfrom a username user quiet since before April: BSUID only Mama looks the sender upno account has this BSUID "Reply with the number you used with Mama"the next message within 30 minutes is the answer "my number is 012-345 6789"any format, any language, even full-width digits Mama looks the number upand finds the account it belongs to to the phone numberthe PIN, as a plain messagenever back to the sender to the sender"if that number has an account,your PIN is on its way" Naming someone else's number gets you nothing: the PIN lands in their WhatsApp.
The login flow for a returning user whose phone number is hidden by Meta.

We considered using Meta’s authentication templates, but decided against them.

They cost money per message, Meta doesn’t support sending them to a BSUID, and we already had our own PIN generation process for app sign-in.

The user also has to initiate the conversation with /login, which gives us a window to send an ordinary WhatsApp message.

The documentation didn’t say whether Meta would treat the BSUID and the phone number as the same person for that messaging window.

We could build and test the logic, but we needed someone affected by the change to try it.

The first real user received their PIN within seconds of the fix going live.

They signed in to their existing account. No duplicate, and no need to start again.

Every phone-shaped assumption

A migration like this is an audit of every place that assumes a user has a phone number.

The plan found most of them, and the users found the rest.