Track Telegram Bot Starts Without Overwriting the First Known Source
Pass a source through start, validate the campaign code, and keep first-known and last-known sources separate. Includes a local Python test and attribution limits.
Track a Telegram Bot's Start Source Without Losing the First One
Assign each placement a short code and a link such as https://t.me/YOUR_BOT_USERNAME?start=channel_a. When your bot receives /start channel_a, check the code against a registry and record the event. Store the first known source separately from the most recent known source, so a later start does not overwrite the first.
This attributes observed bot starts, not clicks. The code identifies the parameter that accompanied a start; it does not prove where someone found the link. The design below covers a bot you control in a private chat and includes a local Python test. It is not a guide to setting up a 24GO feature.
What Telegram sends to the bot
Telegram's deep-linking documentation describes passing a parameter through start. Allowed characters are A-Z, a-z, 0-9, _, and -; the parameter can be up to 64 characters long. A short code such as channel_a needs no encoding.
Replace YOUR_BOT_USERNAME with your bot's username. These are placeholders, not links to a demonstration service:
https://t.me/YOUR_BOT_USERNAME?start=channel_a
https://t.me/YOUR_BOT_USERNAME?start=webinar_b
The bot-link specification calls for a Start button when a parameter is supplied, including when the user has already started the bot. Confirm that your server receives the command after the user acts. Opening the link alone does not mean your server has received an event. Check the interface separately in each client you support.
Do not confuse start for a private chat with startgroup, which is used when adding a bot to a group. Arbitrary utm_source fields in a link do not replace handling start. If the bot needs a campaign identifier, send a payload it recognizes: the source code.
Define what a source means before storing it
Create a registry in which channel_a identifies one authorized placement and webinar_b another. Store each code's description, usage period, and owner on your server. Do not put phone numbers, email addresses, access tokens, or other sensitive data in the link. Base64url changes the representation of data; it does not encrypt it.
Keep the event log separate from the user's attribution record:
| Field | What to store | Rule |
|---|---|---|
observed_start_at | When the server received the command | Do not label it the click time |
source_status | known, missing, or invalid_or_unknown | Do not invent a source when no code is present |
source_code | A validated code, or no value | Do not trust arbitrary text |
first_known_source | The first accepted code | Preserve it on later starts |
last_known_source | The most recent accepted code | Update only for a known code |
missing status.
Use a stable internal user key and restrict access appropriately. In production, update the record atomically so concurrent events cannot both claim to be first. Delivery logs and event deduplication solve different problems; neither turns a repeated /start into a new lead.
A local test that preserves the first code
This example tests only your policy for an argument already extracted from /start. It does not run a bot, receive webhooks, or connect to a CRM. You need Python 3, with no extra dependencies. Save the code as attribution_test.py and run python attribution_test.py:
import json
import re
KNOWN = {"channel_a", "webinar_b"}
PATTERN = re.compile(r"[A-Za-z0-9_-]{1,64}")
def valid_payload(value):
return isinstance(value, str) and PATTERN.fullmatch(value) is not None
def observe(state, payload):
if payload is None or payload == "":
status = "missing"
elif not valid_payload(payload) or payload not in KNOWN:
status = "invalid_or_unknown"
else:
status = "known"
if state["first_known"] is None:
state["first_known"] = payload
state["last_known"] = payload
return status
assert valid_payload("a" * 64)
assert not valid_payload("a" * 65)
assert not valid_payload("has space")
state = {"first_known": None, "last_known": None}
inputs = [None, "channel_a", "webinar_b", "made_up", "", "webinar_b"]
statuses = [observe(state, value) for value in inputs]
assert statuses == ["missing", "known", "known", "invalid_or_unknown", "missing", "known"]
assert state == {"first_known": "channel_a", "last_known": "webinar_b"}
print(json.dumps({"statuses": statuses, "state": state}, sort_keys=True))
Expected output:
{"state": {"first_known": "channel_a", "last_known": "webinar_b"}, "statuses": ["missing", "known", "known", "invalid_or_unknown", "missing", "known"]}
Format validation and registry lookup are separate checks. A string of 64 letters passes the format check but is not a known source unless it appears in KNOWN. An unknown or missing code leaves previously accepted sources intact. Repeating webinar_b leaves both stored values unchanged.
This is a synthetic, in-memory test for one user. It does not demonstrate Telegram delivery, crash-safe storage, or concurrent processing. An AssertionError means the result differs from the stated policy: investigate the change rather than deleting the assertion. Do not run it with python -O, which disables assert statements.
Check your own bot before distributing links
- Add two codes to your registry and implement a
/starthandler that extracts the command argument and passes it to your validator. The example above does not implement that handler. - Open the first link with a test account and complete the Start action if the client offers it. Confirm that the server received the expected command and stored
channel_ain both fields. - Open and start the second link using the same account. Expect
first_known_source=channel_aandlast_known_source=webinar_b. Do not create a second lead record merely because another start occurred. - Send
/startwithout an argument, then/start made_up. The event log should record the corresponding statuses while preserving the known sources. - Repeat the test on the Telegram clients you support. Record the client, version, time, command received, and database result. Do not include your bot token in the report.
What these records can and cannot tell you
A public link can be forwarded, and its code can be typed manually. A whitelist confirms that you recognize the code; it does not prove that the user came from the intended placement. Even a signed parameter does not prevent someone from copying the entire link. Do not use an ordinary campaign code to grant access, pay rewards, or confer other privileges.
You can report accepted commands and, separately, distinct internal users with a known code over an explicitly defined period. Repeated starts by one user are not new enquiries. Qualification needs its own criterion, and a sale needs separate confirmation from your sales system.
The bot alone does not provide a click-through-rate denominator: it does not show all link openings that never led to a start. Even your own redirect counter may include link previews and bots. Do not divide starts by incomparable view counts and call the result an audience-wide conversion rate.
Finally, receiving /start is not, by itself, consent to further marketing. Explain the purpose of data collection, restrict access, and set a retention period. Assess messaging permissions and applicable requirements separately. The outcome of this design is an auditable source for an observed start, not permission to message someone or proof of ROI.