Telegram Bot Contacts: Avoid Saving Someone Else's Phone in Your CRM

6 min read

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.

InputHandler decision
Chat is not privateDo not run this linking procedure
Message has no contactContinue the normal conversation; leave the phone unchanged
Sender ID or contact.user_id is missingLeave the contact unlinked
Both IDs exist but differDo not save the contact as the sender's phone
IDs match and a number is presentAllow the next step under your storage policy
A missing ID is not evidence of deception. A mismatch is not a reason to accuse the user either: they may have shared a colleague's contact. Reply neutrally: “We could not link this contact to your Telegram profile. If you would like us to call you, please use the button to share your own contact.” Enquiries on someone else's behalf need a separate process, not a weaker check.

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

  1. Send the reply button in a private test chat, press it, and inspect the resulting from.id and contact.user_id. Do not publish the real phone number in the test log.
  2. 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.
  3. 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.
  4. Check the inbound HTTP boundary. setWebhook supports secret_token and the X-Telegram-Bot-Api-Secret-Token header. Your handler must validate its configured secret before processing the event. Comparing IDs inside a forged request proves nothing by itself.
  5. 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.
The local example does not establish the results of these integration tests. Record the client, time, selected branch, and test CRM change separately. Technical logs generally need the result and internal record references, not full contacts or secrets copied into them unnecessarily.

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.