Verified Contacts
The CRM table user_exid stores every alternative identifier a user can sign in with — phone numbers, email addresses, OAuth provider ids. Until 2026-05 those rows looked the same regardless of how they were added: an OTP confirmation, an OAuth login, or a manual operator action all produced an undifferentiated row. AML, KYC, and payout-eligibility checks could not tell whether a contact had been verified or only typed in by support.
The user_exid_flags bitmask now distinguishes those cases:
| Bit | Name | When set |
|---|---|---|
1 | Verified | The contact was confirmed end-to-end by the user (OTP code, magic link, OAuth handshake completed). |
2 | ManuallyAdded | The contact was created by an operator through /CrmAdmin/ExternalIds (or merged from another user) and has not yet been verified. |
4 | Primary | Reserved for the user's primary contact of a given type (not yet wired into routing). |
128 | Deleted | Soft-delete; the row is kept for audit and federation continuity. |
Verified and ManuallyAdded are independent: an operator can pre-create a contact (ManuallyAdded) and the user can later verify it (Verified is added on top), at which point the operator UI shows both badges.
Where Verified is set
Verification is triggered atomically as soon as the corresponding flow completes. There is no background reconciliation — if you don't see the bit, the user did not finish the flow.
| Flow | Code site |
|---|---|
| Phone OTP confirmation | IdentityController.confirm_phone |
| Email OTP confirmation | IdentityController.confirm_email |
| Magic-link login (email) | AuthController.magic |
OTP login (login_phone / login_email) | AuthController — sets Verified only when a new CRM user is created during that login; an existing user's contact is not re-upserted as Verified by the login call |
| OAuth provider id | CRM social contact resolution / explicit link confirmation |
| OAuth provider with verified email | CRM social contact resolution, only when the provider exposes verified email state |
| Telegram bot phone/email confirmation | Chat bot calls CRM social contact resolution; phone requires the user's own Telegram contact, email code is bound to the same Telegram user |
| Federated session establish | FederatedAuthController (AutoLink + TryLinkVerifiedContact) |
OAuth providers do not all guarantee a verified email at the protocol level. The platform splits them by trustworthiness:
| Provider | Email considered verified? |
|---|---|
| Yes | |
| Apple | Yes |
| GitHub | Yes |
| Discord | Yes, only when Discord returns verified = true |
| No | |
| VKontakte | No |
| Telegram | No |
For providers in the right column the user must complete an OTP confirmation on email separately before the contact is marked Verified.
CRM social matching uses only existing contacts that already have the Verified bit. An operator-added or otherwise unverified email/phone can block automatic linking, but it is not trusted as the owner of a social account.
Where ManuallyAdded is set
| Surface | Effect |
|---|---|
/CrmAdmin/ExternalIds → "Add" | Sets ManuallyAdded, does not set Verified. |
/CrmAdmin/Users → "Merge" | Existing flags are preserved (operator merge is non-destructive). |
The /CrmAdmin/ExternalIds page lists each row with colour-coded badges (Verified / Manual / Primary / Deleted / Unknown), and exposes a "Mark verified" action next to "Delete" when the row has neither Verified nor Deleted. Pressing it sets Verified and emits the audit action CrmAuditAction.ExidVerified.
API surface
The flag manipulation is exposed to inter-module callers through ext_id_upsert. The DTO accepts atomic bitwise updates:
{
"userId": 17234567890001,
"extId": "user@example.com",
"idType": 7,
"setFlags": 1,
"clearFlags": 0
}
idType is the LoginType enum (sequential byte): 1 PassKey, 2 Fido2Key, 3 UserKey, 6 Phone, 7 Email, 8 Google, etc. The example above marks an email contact (7) as Verified (setFlags = 1).
setFlags and clearFlags are applied atomically: flags = (flags & ~clearFlags) | setFlags. They are independent of the other fields in UserExidIn — the rest of the row uses the standard "null = don't update" semantics.
Use the module's verified-contact service or admin action instead of issuing raw database updates. The service updates the record atomically and invalidates the in-memory cache.
How to use this in your module
| Decision | Read |
|---|---|
| Is this email/phone safe for AML / KYC / payout eligibility? | user_exid_flags & Verified |
| Should I show "verify your email" UI? | user_exid_flags & Verified == 0 |
| Should I show an operator badge in the CRM? | user_exid_flags & ManuallyAdded |
| Was this contact removed by support? | user_exid_flags & Deleted |
Don't infer verification from the presence of the row alone — operators routinely pre-create contacts as part of customer onboarding, and treating those as verified would weaken the AML pipeline.
Audit
Every change is logged to crm_audit with one of:
CrmAuditAction.ExidAddedCrmAuditAction.ExidVerifiedCrmAuditAction.ExidDeletedCrmAuditAction.UserMerged
This gives operators a single place to investigate when a contact's status changed unexpectedly.
Related
- Multi-Factor Auth
- OAuth Providers
- Magic Links
- ItBuild modules integration — how AuthProxy forwards CRM identity to backing modules through
X-Crm.