Skip to main content

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-Key header. 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

FieldTypeDescription
botIdUUIDBot ID from your Metriox settings
auth.initDatastringThe initData string from window.Telegram.WebApp.initData
eventsarrayList 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

FieldRequiredTypeDescription
eventIdUUIDUnique event ID (used for deduplication)
eventNamestringEvent name
eventDateISO 8601Event date and time
eventTypestringEvent category — see the list below
textstringText 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.

No floating-point bucket here

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.

Props split by type

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

LimitValue
Property key^[A-Za-z_][A-Za-z0-9_.]{0,127}$ — up to 128 characters
String value length4096 characters, then truncated with a warning
Properties per event1024; the surplus is dropped
Events per request1000

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."
}
]
}
FieldDescription
acceptedHow many events were ingested (an accepted event may still carry warnings)
rejectedHow many were dropped — each has at least one diagnostic with severity: "error"
not_consumed_duplicatedHow many events were discarded as a repeat of an earlier eventId in the same request
stored_not_billedHow 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
diagnosticsThe problems found: severity: "warning" means repaired and stored, "error" means event dropped
diagnostics_truncatedtrue 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");
SDK

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.

Server-side only

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

HeaderValue
X-API-KeyBot token
Content-Typeapplication/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:

  1. Open the Metriox app and go to Projects → the project you want
  2. In the bots table, on the row of the bot you want, click Tokens
  3. In the Manage Tokens dialog, click Create Token
  4. 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

FieldRequiredTypeDescription
eventsarrayList of events (maximum 1000 per request; an empty list is rejected)
usersarrayProfiles of the people in this batch — who they are, not what they did
botobjectA snapshot of the bot itself: telegramBotId, name, description, starsAmount
versionstringRequest contract version. Do not send it — an unrecognised value gives 400

Event fields

FieldRequiredTypeDescription
eventIdUUIDUnique event ID (used for deduplication)
platformUserIdstringThe person's Telegram id, as a string. See below
eventOriginstring"platform" or "custom" — see below
eventTypestringEvent category — the same list as the WebApp endpoint
eventNamestringEvent name
eventDateISO 8601Event date and time
textstringMessage 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 here

Unlike 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. Flat tg.<field> keys from the canonical registry are promoted into the $tg section, 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

LimitValue
Events per request1000
Request rate120 requests per minute per token, with headroom for a burst of 300
Event propertiesThe 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

CodeDescription
202Accepted — events accepted (the body carries the diagnostics, as with the WebApp endpoint)
400Empty batch, more than 1000 events, an unrecognised version, or every event rejected
401No X-API-Key header, or the token is unknown or revoked
402Payment Required — the plan's event allowance is exhausted
403SDK / API ingestion is switched off in the bot's settings — see SDK
429Too Many Requests — the request rate was exceeded
503Metriox 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:

  • eventOrigin is "platform". Without it tg.currency and tg.total_amount stay flat custom properties and the reports will not find them.
  • tg.total_amount is an integer in propsLong. 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.
  • platformUserId is 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_payload is your order number and tg.provider_payment_charge_id is the acquirer's transaction id. Both are canonical fields, both are optional, and both make reconciliation far easier.

After that the Revenue report (ReportsMonetization) 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.

CodeDescription
202Accepted — events accepted (the body carries the diagnostics)
400Bad Request — empty batch, more than 1000 events, or every event was rejected
401Unauthorized — empty or invalid initData, or its auth_date has expired
402Payment Required — the plan's event allowance is exhausted
409Conflict — the bot is not linked to a project, not configured in Telegram, or WebApp ingest is turned off
503Service 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