Telegram Bot Contacts: Avoid Saving Someone Else's Phone in Your CRM
Check a contact before saving a phone number to your CRM: a reply button, ID comparison, rejection rules, and a reproducible local test.
Telegram Bot Contacts: Avoid Saving Someone Else's Phone in Your CRM
Do not save every incoming contact.phone_number as the sender's phone number. To link a contact automatically in a private chat, compare message.contact.user_id with message.from.id. If the contact ID is missing or different, leave the number unlinked and ask the user to share their own contact using a button.
This is a policy for your own Telegram Bot API handler, not instructions for configuring a 24go.site feature. It assumes that your server has already authenticated delivery and validated the update's structure. Matching IDs links the contact to the Telegram user in that event. It does not verify a phone number by SMS, establish identity, or grant permission to send marketing messages.
Ask for the right contact first
In KeyboardButton, request_contact: true means that pressing the button sends the user's phone number as a contact. This is available only in private chats. Use a regular reply keyboard, not an inline button.
For example, pass this object as reply_markup in your bot's existing sendMessage call:
{
"keyboard": [[
{"text": "Share my phone number", "request_contact": true}
]],
"resize_keyboard": true,
"one_time_keyboard": true
}
Explain the purpose in the message: “Share your phone number if you would like a sales representative to call about this enquiry. You can also continue the conversation here.” The button's presence does not mean the number has arrived. Process the incoming message, not the fact that you sent a keyboard.
A user can also send a contact manually. The Contact object's user_id is optional. Your handler therefore needs a missing-ID branch, even if every successful test with your button has returned one.
Decide before writing to the CRM
Compare the user ID in message.from.id, not a name, username, or phone string. User.id identifies a user or bot. It is not a phone number.
| Input | Handler decision |
|---|---|
| Chat is not private | Do not run this linking procedure |
| Message has no contact | Continue the normal conversation; leave the phone unchanged |
Sender ID or contact.user_id is missing | Leave the contact unlinked |
| Both IDs exist but differ | Do not save the contact as the sender's phone |
| IDs match and a number is present | Allow the next step under your storage policy |
A match should not silently overwrite a previously confirmed CRM number. Retain the source, receipt time, sender ID, and check result. Define a separate rule for updating an existing phone number. Do not merge records for different people just because their phone strings match.
A local test without a bot or CRM
The code below checks only which branch is selected for artificial inputs. It does not receive HTTP requests, verify a webhook secret, normalize numbers, or store them. It needs Python 3 and no additional libraries. Save it as contact_policy_test.py and run python contact_policy_test.py without -O, which disables assert.
import copy
import json
def contact_status(message):
if message.get("chat", {}).get("type") != "private":
return "outside_private"
contact = message.get("contact")
if not isinstance(contact, dict):
return "no_contact"
sender_id = message.get("from", {}).get("id")
contact_id = contact.get("user_id")
if type(sender_id) is not int or type(contact_id) is not int:
return "unlinked"
if sender_id != contact_id:
return "different_user"
phone = contact.get("phone_number")
if not isinstance(phone, str) or not phone.strip():
return "unlinked"
return "same_user"
base = {
"chat": {"type": "private"},
"from": {"id": 1001},
"contact": {"user_id": 1001, "phone_number": "TEST_ONLY"},
}
cases = [copy.deepcopy(base) for _ in range(7)]
cases[1]["contact"]["user_id"] = 2002
del cases[2]["contact"]["user_id"]
del cases[3]["contact"]
cases[4]["chat"]["type"] = "group"
cases[5]["contact"]["phone_number"] = ""
cases[6]["contact"]["user_id"] = "1001"
results = [contact_status(case) for case in cases]
assert results == [
"same_user", "different_user", "unlinked", "no_contact",
"outside_private", "unlinked", "unlinked",
]
print(json.dumps(results))
Expected output:
["same_user", "different_user", "unlinked", "no_contact", "outside_private", "unlinked", "unlinked"]
TEST_ONLY is deliberately not a real phone number. same_user means only that the input passed the checks shown; it does not mean the string can be called. The final case rejects a string ID: the example expects numeric IDs from Bot API JSON. If your library converts types, define that boundary explicitly and test the conversion.
If you get an AssertionError, investigate which rule changed rather than removing the check. Validate the structure before calling this function: for example, chat and from must be objects of the expected shape. This is not a general parser for arbitrary JSON.
Check the real bot separately
- Send the reply button in a private test chat, press it, and inspect the resulting
from.idandcontact.user_id. Do not publish the real phone number in the test log. - In a controlled test, send another test participant's contact with their permission. Expect automatic linking to be refused if the IDs differ; if the ID is missing, expect
unlinked. - Send ordinary text after the contact. It should not erase the stored phone number. Declining to share a number should not interrupt the available Telegram conversation.
- Check the inbound HTTP boundary. setWebhook supports
secret_tokenand theX-Telegram-Bot-Api-Secret-Tokenheader. Your handler must validate its configured secret before processing the event. Comparing IDs inside a forged request proves nothing by itself. - Redeliver the same event in your test environment. It should not create a second write operation. That is a separate webhook idempotency problem, not a property of contact checking.
Where this check stops
Request a phone number only for a purpose the user understands. Restrict access, set a retention period, and provide a way to correct or delete it. Linking a contact to a Telegram ID does not establish permission for advertising calls, verify legal identity, or guarantee that the number is currently reachable. Assess data-processing and messaging requirements separately for your use case.
Readiness means that a contact with an unknown or different user's ID does not become the sender's phone number, declining does not interrupt the conversation, and redelivery does not add an extra record. Connect the procedure to your production CRM only after these checks.