Official SDKs
Use the official SDKs for a convenient Metriox integration in your application.
Available SDKs
JavaScript/TypeScript
C#
Python
JavaScript: connecting a Mini App
The JS SDK is built for Telegram Mini Apps: it picks up initData, splits properties by type, queues events and handles retries for you.
<script src="https://cdn.jsdelivr.net/npm/metriox-javascript/dist/metriox-tg-webapp.min.js"></script>
<script>
const mx = window.MetrioxTG.init({
botId: "<YOUR_BOT_ID>",
auth: () => ({ initData: window.Telegram?.WebApp?.initData || "" }),
auto: true,
});
mx.track("purchase_completed", { price: 299 }, { type: "payment" });
</script>
There is no projectId to pass: the project is resolved from botId server-side. Up to 0.2.0 the SDK demanded one at init even though the ingest never used it — it is now optional and has no effect.
For React there is a separate metriox-javascript/react entry point with a provider and hooks.
Event category
track()'s third argument sets the $event.type category: message, interaction, payment, membership, business, poll, reaction, boost, platform. Without it the event is recorded as platform.
page() and interaction() set their own category. Up to 0.2.0 the SDK sent custom and page — neither category exists, so every event arrived carrying an event_type_unknown warning.
What is collected automatically
Every event carries the launch and device context: session_id, seq, tg_platform, tg_client_version, tg_color_scheme, tg_viewport_h, tg_is_expanded, tg_is_fullscreen, tg_is_active, path, referrer, language, timezone, screen_w/screen_h, viewport_w/viewport_h, dpr_x100. Turn it off with context: false.
Nothing from initData is duplicated: the user, chat type, chat_instance and start_param are taken from the signed string server-side.
With auto: true the SDK additionally records page views, SPA navigation, clicks on data-mx elements, form submits, unhandled errors, and Telegram lifecycle events — main, secondary, back and settings button presses, popup and invoice results, and theme, viewport, fullscreen and activation changes. QR and clipboard contents are never sent: only the fact that the event happened. You can enable a subset: auto: { page: true, tg: true }.
Python: connecting in one line
The Python SDK covers the three popular Telegram bot libraries: aiogram 3.x, python-telegram-bot 20+, and pyTelegramBotAPI 4.x. Connecting wraps what you already have:
pip install "metriox[aiogram]" # or metriox[ptb], metriox[telebot]
from metriox.integrations.aiogram import setup_metriox
bot = Bot(TOKEN)
dp = Dispatcher()
metriox = setup_metriox(bot, dp, api_key="...", platform_bot_id="my_bot")
That is the whole integration. From then on every incoming update and every message the bot sends is captured, with no further calls. python-telegram-bot and pyTelegramBotAPI expose the same setup_metriox(...); only the import differs.
The SDK has no required dependencies — the transport is the standard library's urllib, so adding Metriox cannot drag in a second HTTP client or conflict with a version you already run.
The Bot API never delivers a bot's own messages back to it, so without the SDK half the conversation is missing from your analytics — the same reason the C# wrapper above exists. The Python SDK intercepts a single send chokepoint, so every send*/edit*/copy* method is covered, including ones the library adds later.
Custom events inside your handlers
Enrichment is optional and happens inside handlers you already wrote:
@dp.message(Command("buy"))
async def buy(message: Message):
metriox.enrich(plan="premium") # rides along on THIS update's event
metriox.track("purchase_completed", price=299) # a custom event; user id comes from context
await message.answer("Done") # captured automatically as outbound
enrich() needs nothing passed to it: the event for the update being handled lives in a contextvars slot for the duration of the handler. track() reads the user id from the same place — outside a handler, pass user_id= explicitly or the event is rejected on ingest.
When a handler runs outside the capture window — block=False in python-telegram-bot, or a TeleBot(threaded=True) worker pool — the update's event has already been sent, so enrich() there deliberately returns False. track() works in both cases and still inherits the user id.
Bot replies in the conversation (C#)
Bot API never sends a bot its own outgoing messages — no such update exists. So without help from the bot, its half of the dialogue is missing from analytics: the conversation shows only the user's messages, and it all reads as one side of the exchange.
The simplest fix is to wrap the client once:
var bot = new TelegramBotClient(token).WithMetrioxCapture(sender, platformBotId: "my_bot");
await bot.SendMessage(chatId, "Choose a plan", replyMarkup: markup);
// the message is already recorded: no separate call needed
The wrapper intercepts every method that returns a message — SendMessage, SendPhoto, SendDocument, EditMessageText, CopyMessage, and the rest — so you never have to maintain a list of methods by hand. Along with the message, Metriox automatically records its text, formatting ($tg.entities), inline keyboard, and the outbound direction.
Only sends made through the returned client are recorded. If part of your code keeps working with the original TelegramBotClient, those messages will not reach analytics.
An analytics failure never breaks sending: if recording the event fails, the message has already gone out, and its result is returned unchanged.
Individual messages by hand
If a message is built outside the wrapped client, you can send the event yourself:
var sent = await bot.SendMessage(chatId, "Choose a plan", replyMarkup: markup);
var ev = TelegramOutgoingMessageMapper.ToBotEvent(sent, platformBotId: "my_bot");
sender.TryEnqueue(ev);
Who your users are (C#)
In the dashboard a person can show up as a bare numeric id — no name, no @username. That is not a Telegram limitation: every Bot API update carries the full from object (name, @username, language, Premium), but an event stores only the identifier — a name describes the person, not the moment, and lives in a separate profile.
Pass the whole update and the profile fills itself in:
var mapper = new TelegramUpdateToBotEventMapper(platformBotId: "my_bot");
// before: sender.TryEnqueue(mapper.ToBotEvent(update));
sender.TryEnqueueUpdate(mapper, update);
TryEnqueueUpdate queues both the event and the sender's identity. Identity is read from any update that carries one — a message, a button tap, an inline query, joining a chat, blocking the bot — so a bot whose users only tap buttons stops being a list of numbers.
If you build the event by hand, pass the identity as the second argument:
sender.TryEnqueue(ev, TelegramUserSnapshotExtractor.From(update));
The WithMetrioxCapture wrapper already reports who the bot is writing to: in a private chat, the chat object IS the user.
Upgrading the SDK is not mandatory: Metriox also recovers @username from the events themselves. But first name, last name, language and Premium travel only on from, so a full profile requires passing the update.
The keyboard of an outgoing message
Metriox likewise only sees an outgoing message's keyboard if the bot reports it (for the same reason). The wrapper and ToBotEvent pass it along automatically; below is how to build the value yourself if you assemble the event by hand. Then the conversation shows the buttons you offered, and a pressed callback shows its label instead of the raw payload.
The SDK serializes the keyboard into a compact string, which the platform stores in $tg.inline_keyboard (callback buttons keep their data, link buttons keep their url; other types are skipped). The keys are the Bot API's own field names — text, callback_data, url; the field format is described in Properties. Send it as tg.inline_keyboard on a message event with eventOrigin = "platform" and tg.from_is_bot = true — then it is shown as a message from the bot.
Before 2026-07-26 the SDKs wrote single-letter keys (t/d/u for keyboards, t/o/l/u for formatting). The platform reads both spellings, so a bot already deployed on an older SDK keeps working unchanged — update whenever it suits you.
C#
// after the bot has sent the message
var sent = await bot.SendMessage(chatId, "Choose a plan", replyMarkup: markup);
// build the event and put it in the send queue
var ev = TelegramOutgoingMessageMapper.ToBotEvent(sent, platformBotId: "my_bot");
sender.TryEnqueue(ev);
If you assemble the event by hand, InlineKeyboardSerializer.ToCompactJson(markup) is available separately.
JavaScript/TypeScript
import { serializeInlineKeyboard } from "metriox-javascript";
const markup = {
inline_keyboard: [[{ text: "Buy", callback_data: "buy" }, { text: "Documentation", url: "https://metriox.com" }]],
};
serializeInlineKeyboard(markup);
// => '[{"text":"Buy","callback_data":"buy"},{"text":"Documentation","url":"https://metriox.com"}]'
Send the result as tg.inline_keyboard on a Telegram event with eventOrigin = "platform" — usually server-side, because the Bot API never delivers a bot's own sends back to it.
WebApp events used to be classified as custom and were never carried into $tg at all. The ingest now stamps them platform-origin itself, so a tg.inline_keyboard sent from a Mini App lands in $tg exactly as it would from any other source. You do not send eventOrigin on a WebApp request — the endpoint has no such field.
Text formatting
Telegram does not send marked-up text. It sends plain text plus a list of spans (bold, link, code) with offsets. To make formatting appear in the conversation, pass that list as tg.entities:
import { serializeMessageEntities } from "metriox-javascript";
serializeMessageEntities(message.entities);
// => '[{"type":"bold","offset":0,"length":5},{"type":"text_link","offset":6,"length":4,"url":"https://metriox.com"}]'
In C#, MessageEntitySerializer.ToCompactJson(sent.Entities) does the same; with the WithMetrioxCapture wrapper it already happens on its own. The field format is described in Properties.
Event identifiers
The SDK computes event_id deterministically from Telegram coordinates (chat + message, or a query id), by the same rules as the bot-token connection. Redelivering one update therefore does not double your data.
If a bot is connected both by token and through the SDK, one action gets the same event_id from both sources. Two rows are still stored — they carry different $source.channel values — but only one is charged against your event quota. See Events for details.
The ready-made keys are also available directly if you build events yourself:
import { tgEventKeys, telegramEventId } from "metriox-javascript";
const eventId = await telegramEventId(tgEventKeys.message(chatId, messageId));
Switching off SDK ingestion
The bot's settings have an "Accept events from the SDK / API" toggle. It is on by default — the /tg path is open to anyone holding your API key.
Turn it off when you want Metriox to record only what it captures from Telegram itself (the bot-token connection). This is the only way to express "MTProto only", and it does not require revoking your API key.
While the toggle is off, /tg answers 403, not a fake success. That is deliberate: on a 202 the SDK would consider the events delivered and drop them from its queue, leaving you with neither the data nor a reason.
403 is a permanent error, so a correct client stops retrying instead of queueing forever.
Turning off both this toggle and "Enable bot polling" leaves the bot recording nothing at all — the form warns about that combination.
You no longer enter the numeric bot id
The settings used to have a field for the bot's numeric id. It is gone: the id is the prefix of the token itself (<id>:<secret>), so the server reads it when the token is saved.
That is more than a convenience. The field was labelled "optional", yet a blank value made every Mini App event fail: the id is part of the string Telegram signs initData over, so without it there is nothing to verify the signature against.
What's next?
- REST API — for more flexible control
- Events — how to structure events
- Data types — which types to use