Sheena Nelson has been a Vera Market customer since March. There's one row for her in the contacts table, keyed on her phone number:
| id | phone_number | bsuid | name |
|---|---|---|---|
| 1 | 16505551234 | null | Sheena Nelson |
In April her messages start carrying a BSUID as well. Nothing in the integration reads it, so the row doesn't change. Everything works: she messages, the lookup by phone number finds her, the agent sees four months of history.
In August she adopts a username, and the next message arrives without a phone number. The lookup misses, and the handler does what it was built to do:
| id | phone_number | bsuid | name |
|---|---|---|---|
| 1 | 16505551234 | null | Sheena Nelson |
| 2 | null | US.13491208655302741918 | Sheena Nelson |
Two rows, one person, and nothing in either row connects them. The agent answering her sees an empty conversation. Her order history sits on a record she'll never touch again.
And you can't repair it afterwards: row 2 has no phone number and never will, so there's nothing to match on.
The row you should have had
For four months, every message from Sheena carried both identifiers. Any one of them was enough to write this:
| id | phone_number | bsuid | name |
|---|---|---|---|
| 1 | 16505551234 | US.13491208655302741918 | Sheena Nelson |
With that row in place, the August message finds her by BSUID and the conversation continues. This is the whole migration, and you can only do it while a customer's messages still carry both identifiers.
So record the BSUID on every contact now. Users who haven't adopted usernames still send it on every message, so the mapping costs nothing today. Once the number stops arriving, it can't be built at all.
If your copy points back at Kapso
Store Kapso's contact id as your foreign key, not the phone number.
The same trap applies one level up. If your CRM row says "this is customer +16505551234" and you use that to fetch the contact later, it breaks in August for the same reason row 1 did. The contact id doesn't change when the phone number goes away, and it doesn't change when the BSUID is regenerated either.
Then match on it first
Once contacts carry both, the lookup order decides whether August works:
- Look up by portfolio plus BSUID.
- Fall back to phone number.
- Create only if neither matches.
Phone-first produces the two-row outcome above the moment a phone number stops arriving. BSUID-first survives it, because the BSUID is the identifier that doesn't go away.
When the BSUID itself changes
A BSUID is stable for a user-portfolio pair, with one exception: if the user changes their phone number, Meta regenerates their BSUID. Sheena switches carriers, gets a new number, and her next message carries a BSUID you've never seen.
Left alone, that produces the same two rows as before, for a different reason. Meta announces the change through a dedicated user_id_update webhook field, which gives you both sides of it:
{ "user_id_update": [ { "detail": "User id for Sheena Nelson has been updated.", "user_id": { "previous": "US.13491208655302741918", "current": "US.99225512874400319256" }, "timestamp": "1750030073" } ]}
Merge, don't insert
When one arrives, find Sheena through whichever identifier still matches, in this order:
- The current BSUID, in case the update is a replay and you already applied it.
- The previous BSUID.
- The previous parent BSUID.
- The current phone number.
Then write the new BSUID onto row 1 and repoint her open conversation at it. Trying the current BSUID first makes the handler idempotent, which matters because Meta redelivers webhooks. Repointing the conversation, and not only the contact, is what stops a live chat from continuing against a stale record.
Kapso applies this same resolution to the contacts it stores. The steps above are for your own records.
Expect to need a reconciler
Write the one-off job that finds records whose identifiers conflict and relinks them, and give it a dry-run mode.
However careful the live path is, some rows will already be wrong by the time you deploy it. Plan the reconciliation pass rather than assuming you caught everything on the way in.