Event properties
Properties (props) are the extra fields of an event that describe its context and let you filter and segment your data in detail. Metriox has two kinds of properties: system and custom.
Two kinds of properties
| Kind | Who fills it in | Prefix | Can you send it? |
|---|---|---|---|
| System | The platform | $ | No (read-only) |
| Custom | You | no $ | Yes |
System properties
System properties are captured by the platform automatically and are read-only — you cannot send or override them. Their names always start with a $ and are grouped into sections:
| Section | What it describes |
|---|---|
$tg | Telegram context: the chat, the sender, the message, the interaction |
$event | Event classification: origin (telegram / custom) and type |
$source | Capture source: channel, producer, sdk_version |
$lookup | Geo data derived from the IP address: country, region, city |
$web | Web context (for events from a browser or WebApp) |
$sender | The sender, when it differs from the user |
All sections are the same for every source — an event from a bot token, from an SDK, and from a WebApp are all normalized into the same structure (see One format).
You can filter and build segments on system properties exactly as you would on ordinary ones. For example:
| Property | What it describes |
|---|---|
$tg.chat_id | the chat identifier |
$tg.message_id | the message identifier |
$tg.direction | who sent the message — inbound (the user) or outbound (the bot) |
$tg.entities | text formatting — bold, links, code |
$tg.inline_keyboard | the buttons the bot's message offered |
$event.type | the event category (message, interaction, payment, …) |
$source.channel | the channel the event was captured through |
You can always see the full list of available system properties right in the filter builder — it shows only the fields that actually occur in your data.
The $event and $source sections
$event classifies the event; $source records where it came from:
| Field | Possible values |
|---|---|
$event.origin | telegram (generated by Telegram) · custom (your own business event) |
$event.type | message · interaction · payment · membership · business · poll · reaction · boost · platform |
$source.channel | telegram_bot_mtproto · telegram_bot_api · telegram_webapp |
$source.producer | worker_mtproto · sdk_dotnet · sdk_js · api |
$source.sdk_version | the SDK or worker version, when it is known |
The $meta section: bookkeeping markers
| Field | Meaning |
|---|---|
$meta.unbilled_duplicate | true — the event was stored but not charged, because another connection had already reported the same Telegram action |
It appears only on the second copy, and only when both connections are active. Its absence means the
event was counted against the quota normally — a false value is never written.
An event carrying this marker behaves no differently in reports: it is stored in full, appears in
funnels, retention and breakdowns, and keeps its own $source.channel. The marker concerns billing only.
The $tg section: Telegram fields
$tg is a flat set of fields ($tg.chat_id, $tg.chat_type, …), identical across every source. The values are normalized too: chat_type is always lowercase (private/group/supergroup/channel) and update_type is snake_case. Different connection methods physically see different sets of fields, so the Source column shows where each field is available:
| Field | Type | Source |
|---|---|---|
chat_id, chat_type | Number/String | all |
from_id | Number | all |
message_id, update_type, is_edited | Number/String/Boolean | all |
callback_data, callback_id | String | all |
direction | String | all |
entities | String | all |
reaction_emoji, reactions, reactions_removed | String | all |
reaction_change, reaction_count_items | String/Number | all |
old_status, new_status | String | all |
is_outgoing | Boolean | bot token (MTProto) |
media_type, has_media, views | String/Boolean/Number | bot token (MTProto) |
from_is_bot, from_username | Boolean/String | SDK (Bot API) |
message_type, text_len, is_reply | String/Number/Boolean | SDK (Bot API) |
command, command_params, command_token | String | messages with a command |
entities_count, has_url_entity, has_mention_entity | Number/Boolean | SDK (Bot API) |
webapp_user_id, webapp_auth_date, webapp_chat_type | Number/String | WebApp |
$tg.direction — who sent the message
A single field with two values: inbound (user to bot) and outbound (bot to user). The platform fills it in itself on every source, so a "bot replies only" filter behaves identically for a bot token, an SDK, and a WebApp.
The table above still lists the raw observations it is derived from: is_outgoing (an MTProto flag) and from_is_bot (a Bot API flag). They are kept as they are, but filtering and segmenting is easier on direction — you do not need to know which source recorded the event.
The Bot API never delivers a bot's own outgoing messages back to it — no such update exists. So for a bot on the Bot API there will be no outgoing messages in your data until the bot reports them itself. Our C# SDK can do that automatically: wrap your client with WithMetrioxCapture(...) and every message you send lands in analytics with no extra code (see SDK).
The MTProto worker does not need this: it sees outgoing messages on its own. Conversely, it does not receive the bot's direct messages (DMs) — only the SDK brings those in.
$tg.entities — text formatting
Telegram does not send marked-up text. It sends plain text plus a list of spans: a type, an offset, and a length. $tg.entities stores that list as a compact JSON string:
[{ "type": "bold", "offset": 0, "length": 5 }, { "type": "text_link", "offset": 6, "length": 4, "url": "https://metriox.com" }]
type— the span type in Bot API terms:bold,italic,underline,strikethrough,spoiler,code,pre,text_link,mention,hashtag,bot_command,custom_emoji, and others;offset/length— the offset and length in UTF-16 (the same unit Telegram uses, and the same one JavaScript indexes strings by). This is Telegram's own model: it sends text plus spans rather than marked-up text — hence offsets instead of markdown;url— the link target, present only ontext_link(on aurlentity the text itself is the address, so there is no separate field).
This is exactly why bold text and clickable links show up in a conversation: the interface slices the text at those offsets and never parses it as markup. The field is meant for rendering — you do not need to filter on it; for conditions like "the message contained a link" there is $tg.has_url_entity.
Before 26.07.2026 the keys were single letters (t/o/l/u), and in events recorded earlier you will still see exactly those. The platform reads both forms, so there is nothing to do: old conversations render as before, while new events are written with the long names. The same applies to $tg.inline_keyboard and $tg.reactions.
$tg.inline_keyboard — buttons under the message
The keyboard the bot attached to an outgoing message, as a compact JSON string:
[{ "text": "💰 Buy", "callback_data": "buy" }, { "text": "Documentation", "url": "https://metriox.com" }]
text— the button caption as the user sees it (always present);callback_data— the payload of a callback button. The platform uses it to find the caption of the button that was pressed, which is why the conversation shows "💰 Buy" instead ofbuy;url— the target of a link button.
The remaining button types (switch-inline, web app, payment, game, contact request, and so on) carry neither a payload nor an address, so they do not appear in the list. Keyboard rows are flattened into a single list, in order.
The field is filled in either by the MTProto worker (which sees the bot's own sends) or by the SDK — for bots on the Bot API, only the bot itself can report what it sent, see SDK. Keyboards do not appear retroactively: messages sent before you connected do not have one.
Reactions — which one, and what happened to it
Reaction events are captured by both the MTProto worker and the SDK, with the same set of fields.
| Field | What it means |
|---|---|
$tg.reaction_emoji | a single reaction as a flat string — this is the field to group by |
$tg.reactions | the full list as a compact JSON string |
$tg.reactions_removed | the reactions the user removed |
$tg.reaction_change | added · removed · changed · updated |
$tg.reaction_count_items | how many distinct reactions the message currently has |
$tg.reaction_emoji is either the emoji itself (👍), or custom:{id} for a custom emoji, or paid for a paid reaction. The field is always filled in whenever the reaction is known: a custom emoji has no Unicode character, and if the field were left empty such reactions would silently drop out of any grouping. The custom: prefix cannot collide with a real emoji.
$tg.reactions stores its list the same way $tg.entities does — as a compact JSON string:
[{ "type": "emoji", "emoji": "👍", "total_count": 3 }, { "type": "custom_emoji", "custom_emoji_id": "5361675167" }]
type— the kind of reaction:emoji,custom_emoji,paid;emoji— the emoji, present only onemoji;custom_emoji_id— the document identifier, present only oncustom_emoji;total_count— how many users left this reaction. Present only in the per-message summary; the "a specific user changed their reaction" event has no counter.
$tg.reaction_change is computed from the before and after state:
added— there was no reaction, and now there is one;removed— there was one and it was taken away (exactly what was removed is in$tg.reactions_removed);changed— one reaction was replaced by another;updated— a per-message summary: such an update carries no previous state, so the direction cannot be determined. A change in the counters (total_count) alone does not count as a reaction change.
Some fields of the $tg section can additionally be reported by the Telegram SDK — for example $tg.inline_keyboard (the keyboard the bot attached to an outgoing message; see SDK). The SDK sends such fields with a bare tg. prefix (without the $), and the platform moves them into the $tg section itself. This does not break the "a name cannot start with $" rule: $ names stay read-only, and tg. is a reserved prefix for Telegram fields.
Custom properties
Custom properties are your own arbitrary fields, sent along with the event. They are what makes the analytics meaningful for your product.
{
"button_name": "buy_subscription",
"plan": "premium",
"price": 299,
"currency": "RUB",
"is_trial": false
}
Naming rules
- Latin letters, digits, the dot
., and the underscore_only (for examplescreen_name,payment.method,step1). - A name must start with a Latin letter or
_and cannot start with$— that prefix is reserved for the platform. - The maximum name length is 128 characters.
- A value belongs to one of the four data types: String, Number, DateTime, Boolean.
$?The $ prefix is reserved for the platform's system properties. This guarantees that your fields never collide with the automatically captured ones and that the data structure stays predictable. Other analytics platforms take the same approach (PostHog and Mixpanel, for example).
Recommendations
- Use
snake_case:user_id,is_premium,payment_amount. - Make names descriptive:
subscription_planrather thanplan. - Keep the type of any given property the same across all events (see Data types).
- Do not put personal data in properties.
This recommendation used to read "in plain form", which sounded like advice to hash it. That is not the point: for data about your bots' end users you are the operator, and Metriox processes it on your instructions. Whatever you put in properties, you are collecting on your own responsibility.
Property values are not inspected: the platform validates key names, types and limits, but never looks inside a value. A phone number placed in phone will be stored.
Message text is a separate case — it is not a property, and it is not stored at all by default. See Message text capture below.
Message text capture
The text a user typed to your bot is held in a dedicated event field, not in properties. It is stored by default, and you can switch that off per bot.
The reason is that what it will contain is not knowable in advance: a phone number, an address, health details. Some of that falls into the special categories of personal data, which need separate consent from the subject — and only the bot's owner can obtain that, not the platform.
Switch it off in the bot's settings, with the Store message text toggle. While it is on, you are instructing us to store the text and confirming that you have a lawful basis for it, including consent where that is required. Switching off applies going forward: the text of new events is not stored, and text already stored is deleted when the event retention period expires.
Works regardless of the setting:
- event names and counts;
- commands:
$tg.commandand$tg.command_tokenare parsed out of the text before it is discarded; - every custom property you send yourself.
Goes away if you switch the setting off:
- message text search and conversation view in the interface;
$tg.command_params— the command's arguments, i.e. whatever the user wrote after its name.
If the setting is off and an SDK or the HTTP API sends text anyway, the event is accepted, the text is dropped, and the response carries a message_text_not_captured diagnostic. It is not an error: the event was stored. But seeing it means you are transmitting personal data that is discarded on arrival, and the cheaper fix is to stop sending it.
What's next?
- Events — the full event structure and the canonical names
- Data types — how types are chosen and how they behave
- Filters — filtering on system and custom properties