HTTP API
Use the HTTP API to send events directly from a Telegram Mini App or from your own server. There are two endpoints, and choosing between them is choosing how you authenticate:
- Telegram WebApp / Mini App — from Mini App code; the user is identified by a signed
initData. - Server → Metriox — from your backend; authenticated with a bot token in the
X-API-Keyheader. This is the path our server-side SDKs use, and the one you need to send Metriox an event Telegram never saw — a payment taken through external acquiring, for example.
Telegram WebApp / Mini App
This is the main endpoint for apps built on Telegram Mini App. Authentication uses initData from the Telegram WebApp SDK — Metriox verifies the signature with the Ed25519 algorithm.
Endpoint
POST https://ingest.metriox.com/telegram/webapp
Request structure
{
"botId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"auth": {
"initData": "<window.Telegram.WebApp.initData>"
},
"events": [
{
"eventId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"eventName": "button_click",
"eventDate": "2026-04-26T10:00:00Z",
"eventType": "interaction",
"propsString": {
"button_name": "checkout",
"plan": "premium"
},
"propsLong": {
"price": 299
},
"propsBool": {
"is_trial": false
}
}
]
}
Request fields
| Field | Type | Description |
|---|---|---|
botId | UUID | Bot ID from your Metriox settings |
auth.initData | string | The initData string from window.Telegram.WebApp.initData |
events | array | List of events (maximum 1000 per request) |
There is no projectId to send — the endpoint has no such field. The project is resolved from botId server-side, and a caller-supplied project id is deliberately not trusted: otherwise it could be used to write events into someone else's project, and onto their bill.
Event fields
| Field | Required | Type | Description |
|---|---|---|---|
eventId | ✅ | UUID | Unique event ID (used for deduplication) |
eventName | ✅ | string | Event name |
eventDate | ✅ | ISO 8601 | Event date and time |
eventType | ✅ | string | Event category — see the list below |
text | ❌ | string | Text content (up to 4096 characters, then truncated) |
propsString | ❌ | {key: string} | String event properties |
propsLong | ❌ | {key: number} | Integer properties |
propsBool | ❌ | {key: boolean} | Boolean properties |
This endpoint has no eventOrigin or platformUserId field, and neither is needed. The server stamps the origin itself, and the user comes from the signed initData — which is what makes Mini App identity trustworthy.
The WebApp endpoint carries three property buckets only: strings, integers and booleans. There is no propsFloat — send one and it is silently ignored. Either scale a fractional value into an integer in a unit that reads clearly (rating_x100: 450), or send it as a string.
eventType categories
message · interaction · payment · membership · business · poll · reaction · boost · platform
A value outside this list does not reject the event: it is stored as platform and an event_type_unknown warning is added to the response. When no category fits, send platform directly.
Unlike a flat props: {}, Metriox uses a separate field for each data type. That is what makes numeric comparison operators (>, <) work in filters.
The SDK does this for you.
Property limits
| Limit | Value |
|---|---|
| Property key | ^[A-Za-z_][A-Za-z0-9_.]{0,127}$ — up to 128 characters |
| String value length | 4096 characters, then truncated with a warning |
| Properties per event | 1024; the surplus is dropped |
| Events per request | 1000 |
Keys beginning with $ are reserved for the platform: such a property is dropped with a prop_key_reserved warning, while the event itself is still accepted.
Response
202 Accepted:
{
"accepted": 2,
"rejected": 1,
"not_consumed_duplicated": 0,
"stored_not_billed": 0,
"diagnostics_truncated": false,
"diagnostics": [
{
"event_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"code": "event_missing_identity",
"severity": "error",
"path": "event_name",
"message": "event_name is required."
}
]
}
| Field | Description |
|---|---|
accepted | How many events were ingested (an accepted event may still carry warnings) |
rejected | How many were dropped — each has at least one diagnostic with severity: "error" |
not_consumed_duplicated | How many events were discarded as a repeat of an earlier eventId in the same request |
stored_not_billed | How many events were stored but not charged, because another connection had already reported the same Telegram action. This is not a rejection: the events are saved and appear in reports |
diagnostics | The problems found: severity: "warning" means repaired and stored, "error" means event dropped |
diagnostics_truncated | true when there were more than 100 diagnostics and the list was cut |
The diagnostics are worth reading: they name the specific property and the reason, rather than just saying something was wrong with the request.
If every event in the batch is rejected, the response is 400 with the same body. That is deliberate — resending such a request is pointless, it can never pass.
JavaScript example
async function trackEvent(eventName, props = {}, eventType = "platform") {
const initData = window.Telegram.WebApp.initData;
const propsString = {};
const propsLong = {};
const propsBool = {};
for (const [key, value] of Object.entries(props)) {
if (typeof value === "string") propsString[key] = value;
else if (typeof value === "boolean") propsBool[key] = value;
else if (Number.isInteger(value)) propsLong[key] = value;
// this endpoint takes no fractional numbers — send them as strings
else if (typeof value === "number") propsString[key] = String(value);
}
const res = await fetch("https://ingest.metriox.com/telegram/webapp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
botId: "YOUR_BOT_ID",
auth: { initData },
events: [
{
eventId: crypto.randomUUID(),
eventName,
eventDate: new Date().toISOString(),
eventType,
propsString,
propsLong,
propsBool,
},
],
}),
});
// only 5xx is worth retrying (along with 408 and 429) — other 4xx will not change
if (!res.ok && res.status >= 500) scheduleRetry();
}
// Usage
trackEvent("button_click", { button_name: "checkout", price: 299, is_trial: false }, "interaction");
For convenience, use the JavaScript SDK — it splits properties by type, manages the queue and retries, and collects the Mini App context for you.
How long initData lasts
initData is accepted while its auth_date is under 15 minutes old. The string may be reused: every batch in a session goes out with the same initData, and that is expected — Telegram issues it once at launch and never refreshes it. Redelivery is guarded by eventId, not by making initData single-use.
The practical consequence: a fresh string requires the app to be opened again. Send events as they happen rather than holding them until close.
Server → Metriox
The second endpoint takes events from your own server: from a Telegram webhook handler, from a background job, from the backend that takes the money. Authentication is a bot token in a header rather than initData, so it can be called from anywhere that has an HTTP client.
This is the same path the server-side SDKs — C# and Python — use.
The token can write events into your project and spend your quota. It has no business in a browser, a mobile app, or a Mini App — there is nowhere to hide it there. For a Mini App use the WebApp endpoint above: it identifies the user from a signed initData rather than from a shared secret.
Endpoint
POST https://ingest.metriox.com/tg
POST /telegram is the same handler under a second address. Write new code against /tg.
Authentication
| Header | Value |
|---|---|
X-API-Key | Bot token |
Content-Type | application/json |
The token identifies both the bot and the project: there is no botId and no projectId to send — the endpoint has no such fields. One token writes events for exactly one bot.
Where to get a token
Tokens are issued per bot, not per project:
- Open the Metriox app and go to Projects → the project you want
- In the bots table, on the row of the bot you want, click Tokens
- In the Manage Tokens dialog, click Create Token
- Copy the value straight away — it is not shown a second time
Tokens are revoked in the same dialog. A request carrying a revoked or unknown token gets 401.
Request structure
{
"events": [
{
"eventId": "3f1b2c7e-0a44-4d1e-9c2b-8d6f5a1e7b90",
"platformUserId": "123456789",
"eventOrigin": "custom",
"eventType": "interaction",
"eventName": "plan_selected",
"eventDate": "2026-04-26T10:00:00Z",
"propsString": {
"plan": "premium"
},
"propsLong": {
"seats": 3
},
"propsFloat": {
"discount_rate": 0.15
},
"propsBool": {
"is_trial": false
}
}
],
"users": [
{
"telegramUserId": 123456789,
"username": "ivanov",
"firstName": "Ivan",
"isPremium": false
}
]
}
Request fields
| Field | Required | Type | Description |
|---|---|---|---|
events | ✅ | array | List of events (maximum 1000 per request; an empty list is rejected) |
users | ❌ | array | Profiles of the people in this batch — who they are, not what they did |
bot | ❌ | object | A snapshot of the bot itself: telegramBotId, name, description, starsAmount |
version | ❌ | string | Request contract version. Do not send it — an unrecognised value gives 400 |
Event fields
| Field | Required | Type | Description |
|---|---|---|---|
eventId | ✅ | UUID | Unique event ID (used for deduplication) |
platformUserId | ✅ | string | The person's Telegram id, as a string. See below |
eventOrigin | ✅ | string | "platform" or "custom" — see below |
eventType | ✅ | string | Event category — the same list as the WebApp endpoint |
eventName | ✅ | string | Event name |
eventDate | ✅ | ISO 8601 | Event date and time |
text | ❌ | string | Message text content (up to 4096 characters) |
propsString | ❌ | {key: string} | String event properties |
propsLong | ❌ | {key: number} | Integer properties |
propsFloat | ❌ | {key: number} | Fractional properties |
propsBool | ❌ | {key: boolean} | Boolean properties |
The ✅ fields are required at parse time: a request whose event is missing any one of them will not be accepted.
propsFloat does exist hereUnlike the WebApp endpoint, the server path carries four property buckets rather than three. A fractional number can be sent as it is.
The users[] profiles are optional but worth sending: an event carries a person's id, while the name, @username, language and Premium flag ride only on the profile. Without them your users show up in the interface as bare numeric ids.
platformUserId is required
This is the person's numeric Telegram id, sent as a string: "123456789".
An event without it is rejected. The response carries a diagnostic with severity: "error", code event_missing_identity and path platform_user_id; the event lands in the rejected count and is not stored. If no event in the batch has one, the whole request gets 400.
Refusing is the best available outcome here. An event that is not attached to a person can neither be joined to the rest of their history nor counted in unique users, so storing it anyway would quietly corrupt every user-based metric. Metriox tells you what is missing instead.
The practical consequence for a server-side integration: capture the person's id when they start something — next to the order number when the invoice is raised, say — so you have it to hand when the event finally happens.
eventOrigin: platform or custom
This field decides how Metriox reads your properties.
"platform"— the event describes the same thing Telegram itself would describe. Flattg.<field>keys from the canonical registry are promoted into the$tgsection, and the row becomes indistinguishable in shape from one our own capture would have written."custom"— your own business event. The properties are stored as they are and nothing is promoted anywhere.
Getting this wrong fails silently. An event marked "custom" is accepted and stored, but tg.total_amount stays a flat custom property called tg.total_amount rather than becoming $tg.total_amount. The built-in reports look for canonical fields and will not see that row: the data is in the system, just not where it is expected, and silently so.
The reverse holds and is just as quiet: with "platform", a tg.<field> key that is not in the registry stays flat — only known fields are promoted.
Limits
| Limit | Value |
|---|---|
| Events per request | 1000 |
| Request rate | 120 requests per minute per token, with headroom for a burst of 300 |
| Event properties | The same rules as the WebApp endpoint — see "Property limits" |
Exceeding the rate gives 429 with no queueing: the request is refused immediately rather than made to wait. The limit is counted per token, so one noisy process does not spend the allowance of your other bots.
Metriox sets no separate request-body size limit of its own: the 1000-event cap is what bounds a batch in practice, and a proxy in front of the service may add limits of its own.
Server endpoint response codes
| Code | Description |
|---|---|
| 202 | Accepted — events accepted (the body carries the diagnostics, as with the WebApp endpoint) |
| 400 | Empty batch, more than 1000 events, an unrecognised version, or every event rejected |
| 401 | No X-API-Key header, or the token is unknown or revoked |
| 402 | Payment Required — the plan's event allowance is exhausted |
| 403 | SDK / API ingestion is switched off in the bot's settings — see SDK |
| 429 | Too Many Requests — the request rate was exceeded |
| 503 | Metriox temporarily could not resolve the project's plan — retry later |
429 and 503 are worth retrying. The other codes are a verdict: the same request will not pass on a second attempt either.
The response body is the same as the WebApp endpoint's: accepted, rejected, diagnostics and the rest of the counters. The diagnostics are worth reading — they name the specific field and the reason.
A payment taken through external acquiring
Most Telegram bots do not take money through Telegram Payments; they use their own acquiring, where the payment happens on your backend and Telegram never learns about it. You do not need an event of your own for that — send the same successful_payment Metriox would have recorded for a payment made inside Telegram.
From your payment provider's webhook, once the payment is confirmed:
curl -X POST https://ingest.metriox.com/tg \
-H "X-API-Key: $METRIOX_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"eventId": "9c1e4b7a-2d55-4a10-8f3b-6e0d9a2c4471",
"platformUserId": "123456789",
"eventOrigin": "platform",
"eventType": "payment",
"eventName": "successful_payment",
"eventDate": "2026-04-26T10:00:00Z",
"propsString": {
"tg.currency": "RUB",
"tg.invoice_payload": "order-10245",
"tg.provider_payment_charge_id": "acq-77f0c1e9"
},
"propsLong": {
"tg.total_amount": 49900
}
}
],
"users": [
{ "telegramUserId": 123456789, "username": "ivanov", "firstName": "Ivan" }
]
}'
What matters here:
eventOriginis"platform". Without ittg.currencyandtg.total_amountstay flat custom properties and the reports will not find them.tg.total_amountis an integer inpropsLong. That is how the field is declared in the canonical registry and how our own capture writes it. The same amount as a string would put a value of a different type into the same field.- The amount is in the currency's smallest units. 499.00 RUB is
49900. The revenue report reports the result in those same units. platformUserIdis the buyer's Telegram id, not your internal user id.- There is no need to send
text— a payment has no message body. tg.invoice_payloadis your order number andtg.provider_payment_charge_idis the acquirer's transaction id. Both are canonical fields, both are optional, and both make reconciliation far easier.
After that the Revenue report (Reports → Monetization) starts showing these payments with no configuration at all: it sums $tg.total_amount over successful_payment events that arrived on the Bot API channel and groups them by $tg.currency — which is exactly what you have just sent.
The report carries a "Bot API SDK only" badge. That is about the channel, not about how you send: a request to /tg arrives on the same channel as SDK events, so payments from your own acquiring are counted.
Response codes
These are the WebApp endpoint's codes. For the server-side /tg, see the table in its own section above.
| Code | Description |
|---|---|
| 202 | Accepted — events accepted (the body carries the diagnostics) |
| 400 | Bad Request — empty batch, more than 1000 events, or every event was rejected |
| 401 | Unauthorized — empty or invalid initData, or its auth_date has expired |
| 402 | Payment Required — the plan's event allowance is exhausted |
| 409 | Conflict — the bot is not linked to a project, not configured in Telegram, or WebApp ingest is turned off |
| 503 | Service Unavailable — a temporary problem on the Metriox side, retry later |
408, 429 and 5xx are worth retrying. Any other 4xx is a verdict: the same request will not pass on a second attempt either.
What counts against the quota
The quota is counted in events. User profiles (users[], and the ones Metriox derives from the events themselves) are not charged when they arrive together with events — you are already paying for the action that disclosed the identity, and one Telegram update should not cost twice.
A profile sent on its own costs one event. Practical upshot: keep users[] in the same request as the events — it is both cheaper and fewer requests.
A request with no events at all is rejected (400), so a bulk upload of your own user base as a lone users[] is not possible: a profile appears together with the person's first action.
What's next?
- SDK — the recommended way to integrate
- Events — event structure
- Data types — which property types are supported