SMSGatewayCenter Blog

Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients

A field by field reference for the SMSGatewayCenter Telegram REST API. Covers the eight endpoints under rest/tg/v1, the chat ID identity model, the three names one message identifier travels under, the payload type flip on error, and a three way comparison of the Telegram, SMS and RCS delivery rows.

Featured image for Telegram Messaging API: Eight Endpoints, the Chat ID Model, and the Error Shape That Breaks Typed Clients
Abstract technical diagram showing one message stream branching into three separate messaging channel columns in teal, blue and orange.
Three channels on one platform, three different wire contracts underneath.

TABLE OF CONTENTS

  1. The Short Answer
  2. TL;DR
  3. One Base Path, Eight Endpoints, One Bot
  4. The Identity Model: Chat IDs, Not Phone Numbers
  5. Bot Setup, the Endpoint That Configures Infrastructure
  6. Building Your Audience: Recipients Is a Query, Not a Table
  7. Sending: One Endpoint, Eleven Message Types
  8. One Identifier, Three Names, Two JSON Types
  9. Templates: Two Actions, Two Envelopes
  10. The Delivery Report Field by Field
  11. The Payload Type Flip on Error
  12. Telegram, SMS and RCS Side by Side
  13. The Inbox and the Webhook That Is Not Yours
  14. Summary and Analytics: Which Number to Trust
  15. Endpoint Reference Table
  16. Your First Telegram Send in Six Steps
  17. Working Code in Four Languages
  18. Ten Mistakes That Cost a Sprint
  19. Choosing Between Telegram, SMS and RCS
  20. Production Checklist
  21. Unspecified Behaviour and How to Code Around It
  22. FAQs

The Short Answer

The SMSGatewayCenter Telegram API is eight REST endpoints under a single base path, https://unify.smsgateway.center/rest/tg/v1/. You connect one bot per account with a token from Telegram BotFather, the platform registers an inbound webhook with Telegram on your behalf, and from that point you address people by Telegram chatId rather than by mobile number. Sends go to /send as form encoded POST. Everything else is a GET: /setup, /recipients, /templates, /inbox, /dlr, /summary and /analytics. All eight take output=json and all eight accept an apikey request header in place of the password, which is the variant to use everywhere.

The part that costs people a day is not the endpoint list. It is that every Telegram endpoint returns its main payload as an object or array on success and as an empty array on error. A Go, Rust, Java or C# client that binds setup to a struct will unmarshal the success case cleanly and throw on the 403. Bind the payload loosely, branch on the top level status string before you touch anything else, and the family becomes easy.

TL;DR

  • One base path, one bot, one auth header. All eight endpoints sit under rest/tg/v1/, and all eight accept the apikey header. A legacy alias without the v1 segment works on seven of them. The account is bound to exactly one Telegram bot, connected through /setup with a BotFather token.
  • Chat IDs, not phone numbers. Telegram addresses a conversation, not a handset. chatId and telegramUserId are two different numbers for the same person and only chatId is addressable. You may send by phoneNumber instead, but only for contacts who shared their number with your bot.
  • Your audience is opt in and it is a query. /recipients returns distinct inbox contacts for a date range. It is not a durable subscriber table. Maintain your own.
  • One identifier, three names. The send response publishes uniqueId unquoted, and uuId and transactionId as the same quoted string. The delivery report documents only uuId and transactionId as matching what you sent. Persist uuId.
  • globalErrorCode is binary here. 0 delivered, 1 failed. On SMS it is a rich taxonomy and on RCS it carries values such as 5007. Do not share an error mapping table across channels.
  • isFinal exists and nothing else on the platform has it. The delivery row tells you outright whether the status is terminal, so your reconciler knows when to stop polling.
  • Three timestamps, two of them formatted. submitTime, dispatchedTime and deliveryTime are unquoted millisecond epochs. submitTime and deliveryTime have Formatted string twins. dispatchedTime does not.
  • Cost is called charges and it is a quoted string. SMS calls it amount and returns an unquoted float. RCS calls it amount and returns a quoted string with four decimals. Three channels, three contracts.
  • The webhook in /setup is inbound to the platform, not outbound to you. There is no documented push to your server on this channel. Reconciliation is polling.
  • Presentation fields leak into the contract. /analytics returns hex colour codes. /setup returns a formMode field. Bind what you need and ignore the rest.

One Base Path, Eight Endpoints, One Bot

Every messaging channel on this platform has its own base path, and the base path is the thing that tells you which conventions apply. SMS sends go to SMSApi/send. WhatsApp goes to WAApi/send. RCS spreads itself across two generations, RCSApi/ for sends and templates and rest/rcs/v1/ for reporting, with different spellings of the format parameter on each.

Telegram is the cleanest of the four. All eight endpoints live under one path:

https://unify.smsgateway.center/rest/tg/v1/

and the format parameter is spelled output on all of them, matching the rest/ generation convention rather than the format spelling used by RCSApi/. Seven of the eight also accept a legacy alias with the v1 segment removed, for example /rest/tg/inbox. Write the v1 form. The alias exists for older integrations and gives you nothing.

The single most consequential structural fact is that the account holds one bot. Not one bot per brand, not one per environment. The send endpoint has no bot selector parameter at all, and the documentation states plainly that the message is sent using the Telegram bot linked to the account making the call. Compare RCS, where botId is a required send parameter and rest/rcs/v1/bots lists several. On Telegram there is nothing to select because there is nothing to choose between.

That has an immediate deployment consequence. If staging and production share an account, they share a bot, and a save call from either one overwrites the token for both. Give non production its own account. This is the same shape as the single SMS webhook slot described in the delivery report ingestion article, and it bites in the same way: the resource is account scoped, the API offers no identifier, and nothing in the parameter list warns you.

Authentication follows the platform rule that holds everywhere: a token is an authorization, never an identity. userid is required on every single call regardless of how you authenticate. If you send the apikey header you still send userid alongside it. The authentication documentation covers the four mechanisms available across the platform; on Telegram the two that matter are userid plus password, and userid plus an apikey header.

The apikey header is accepted on every endpoint in the family, and on every other API on the platform. Some of the Telegram pages show only the userid and password cURL variant, so the header is easy to miss on /inbox, /dlr and /summary, but it works there exactly as it does on /setup and /send. Build one authenticated client, give it the header, and use it everywhere.

The only auth detail that genuinely varies is whether output is required:

Endpointoutput requiredAuth
/setupYesapikey header, or password
/sendOptionalapikey header, or password
/recipientsYesapikey header, or password
/templatesYesapikey header, or password
/inboxYesapikey header, or password
/dlrYesapikey header, or password
/summaryYesapikey header, or password
/analyticsYesapikey header, or password

Prefer the header in every environment. It keeps the account password out of your configuration, out of your request logs and, on the GET endpoints, out of the query string, which is the single easiest credential leak to create on this API. Send output=json on all eight; it costs nothing on /send and removes one thing to remember.

Diagram of the eight SMSGatewayCenter Telegram REST endpoints grouped by function under the shared rest/tg/v1 base path, with the shared authentication and error handling rules that apply to all of them.
Eight endpoints, one base path, one bot. The grouping is by what the endpoint does to your system, not by HTTP verb.

The Identity Model: Chat IDs, Not Phone Numbers

This is the conceptual jump, and everything downstream depends on getting it right.

SMS addresses a handset. You have a mobile number, you send to it, and the recipient’s consent is a legal and regulatory matter handled outside the protocol through DLT registration and consent capture. The number exists whether or not the person has ever heard of you.

Telegram addresses a conversation. A chatId comes into existence only when a person opens your bot and sends it something, usually /start. Before that moment there is no address to send to. Consent is not a compliance overlay bolted onto an addressable identifier; consent is what creates the identifier. That is a better privacy model and a harder growth model, and both facts shape how you build.

The recipients row makes the identity structure explicit:

{
    "chatId": "123456789",
    "telegramUserId": "987654321",
    "displayName": "Rahul",
    "firstName": "Rahul",
    "username": "rahul_demo"
}

Note carefully: chatId and telegramUserId are different values. They are not two spellings of one number. chatId identifies the conversation between that person and your bot and is the only one you can send to. telegramUserId identifies the Telegram account and appears as a filter on /inbox but is not accepted by /send. If you store one column and call it “telegram id” you will eventually put the wrong number in it, and the failure mode is a send that is accepted and goes nowhere useful. Store both, name them exactly as the API does, and make the send path read chatId and only chatId.

Both come back as quoted strings on read even though they are numeric and even though the send endpoint accepts them as bare digits in a form field. Keep them as strings end to end. Telegram chat IDs for groups and channels are negative and can be large, and there is no upside to parsing them.

username is the public @handle without the leading @. It is user changeable and must never be a key. displayName and firstName carry the same value in the documented sample, and there is no lastName field on the row at all even though the contact card message type has one. Treat every name field as decorative, present it in your support UI, and key nothing on it.

The third addressing route is phoneNumber, passed on /send instead of chatId. The documentation is precise about its limits: it matches contacts who shared their phone number with your bot, which in Telegram means the person tapped a share contact button at some point. It is a convenience for bridging an existing CRM keyed on mobile numbers, not a way to reach arbitrary handsets. If you are trying to reach someone who has never interacted with your bot, Telegram is not the channel; that send belongs on SMS, and multi channel fallback is the mechanism for expressing that preference once rather than branching on it in every call site.

One request rule, stated as a validation rule on the send page and worth honouring in your own code before the API ever sees it: use either chatId or phoneNumber, never both in the same request.


Bot Setup, the Endpoint That Configures Infrastructure

/setup has no analogue anywhere else on the platform. SMS sender IDs and RCS bots are provisioned through approval workflows with their own CRUD families. Telegram gives you a single endpoint that creates the identity, stores the credential and wires the inbound plumbing in one call.

It takes three actions, and unusually for this platform each has documented aliases:

ActionMethodAliasesWhat it does
getGETempty, list, statusReturns connection status, feature flags and botChatUrl
savePOSTconnect, updateCreates or updates the bot, then registers the inbound webhook
registerwebhookPOSTregister_webhook, webhookRe-registers the inbound webhook only

Write the canonical spelling in your code. The aliases are there for compatibility and every extra accepted spelling is a way for two services in your estate to drift apart while both appear to work.

The parameters are few. botName is a display name of two to one hundred characters, required on save. botHandle is the bot username without the @, four to thirty two characters of letters, numbers and underscore, used to build https://t.me/{handle}. botToken is the BotFather token in digits:token form and is described as create only: optional on update, where omitting it keeps the existing token.

That last detail deserves attention because it is a security property, not a convenience. There is no way to read the bot token back out. Contrast apikey/read, which returns your API key in plaintext and therefore stores it recoverably. The Telegram token is write only through this API. Treat that as the intended design and match it in your own systems: put the token in a secret manager, pass it once at connect time, and never log the request body of a save call.

The success payload is the most information dense object in the family:

{
    "status": "success",
    "setup": {
        "isConfigured": true,
        "isActive": true,
        "formMode": "update",
        "botName": "My Store Support",
        "botHandle": "mystorebot",
        "botChatUrl": "https://t.me/mystorebot",
        "hasBotToken": true,
        "tps": 10,
        "canRegisterWebhook": true,
        "uiSendingEnabled": true,
        "apiAccessEnabled": true,
        "maxAgents": 5,
        "saved": true,
        "webhook": {
            "status": "success",
            "message": "Webhook registered successfully."
        }
    },
    "statusCode": "200",
    "reason": "Your Telegram bot has been connected and the webhook registered."
}

Several things here are worth naming.

These are real JSON booleans. isConfigured, isActive, hasBotToken, canRegisterWebhook, uiSendingEnabled, apiAccessEnabled and saved are true, not "1" and not 1. Almost nothing else on this platform does that. Group and contact rows use quoted "1". RCS bot and template rows use unquoted integer 1. The SMS delivery row has exactly one genuine boolean, flashMsg. If you have a shared helper that coerces platform truthiness from strings and integers, it will work here by accident, but write the Telegram binding against real booleans and do not let the helper hide the difference.

tps is a number you can read. It is an unquoted integer and it is the rate at which your account may send. This is the first per account throughput figure the platform exposes programmatically anywhere, and it is worth wiring into your sender rather than hardcoding a guess. Read it at startup, use it to size your token bucket, and re-read it after any plan change. The general approach to shaping a sender around a published rate is covered in rate limiting and backpressure in messaging systems; the difference on Telegram is that you no longer have to infer the number.

apiAccessEnabled and uiSendingEnabled are separate flags. Your account can send from the panel while the API is closed, which is exactly the state that produces the send error "Telegram API sending is disabled for this account." Check apiAccessEnabled in your deployment smoke test and fail the deploy loudly rather than discovering it on the first real broadcast.

webhook is a nested envelope with its own status. And its text field is called message, while the outer text field is called reason. Two levels, two vocabularies. A parser that searches the whole document for a key called status will find two and pick whichever comes first.

formMode is a user interface concept. It tells you whether the panel would render a create form or an edit form. It has no business being in an API response and it is a clear sign of what these endpoints are: the panel’s own backend, promoted to a public contract. That is not a criticism, it is a fact you can use. Bind the fields you actually need, ignore the rest, and never assert on a field you cannot explain the purpose of.

The botChatUrl field exists so your own apps can render a QR code client side from https://t.me/{botHandle}. There is no server side QR image endpoint, which is stated outright on the page. Generate it locally with any QR library; the URL is the whole payload.


Building Your Audience: Recipients Is a Query, Not a Table

Read the description of /recipients slowly, because the wording carries the whole architecture: it fetches distinct Telegram inbox contacts for a date range.

Your audience, as exposed by the API, is a projection over inbound messages, filtered by when those messages arrived. It is not a subscriber table with a stable primary key that you can page through from the beginning of time. A parent who subscribed to your school bot in April and has not messaged since will not appear in a query for the last seven days.

The defensive shape is straightforward and you should build it on day one, before you have enough subscribers for the problem to hurt.

Maintain your own telegram_subscriber table keyed on chat_id. Run a sweep job that walks /recipients from the date your bot went live up to today in windows of a week or two, pages through each window at limit=200, and upserts every row it sees. Record first_seen_at on insert and last_seen_at on every touch. After the initial backfill, a nightly sweep over the last few days keeps it current for the cost of a handful of requests. Your broadcast code then reads your table, never the API.

This gives you three things the API alone cannot: a stable audience that does not shrink when someone goes quiet, a place to record suppression and preference state, and a join key for the rest of your data model. The schema considerations are the same ones covered in the outbound message table, and the subscriber table is its natural sibling.

The paging contract is the rest/ generation standard: page starting at 1, limit defaulting to 50 with a maximum of 200. Offset paging, not cursors. That differs from the SMS delivery report, which uses an opaque cursors.next token fed back as startCursor. Do not share a pagination helper between the two.

The date parameters accept YYYY-MM-DD or a full date and time, and carry the aliases from and to. The envelope echoes what it actually used, expanded:

"fromDate": "2026-07-14 00:00:00",
"toDate": "2026-07-14 23:59:59",

That echo is genuinely useful. A date only input is expanded to the full day, inclusive at both ends, and the response tells you so. Log the echoed values rather than the values you sent, and your window arithmetic becomes auditable. No timezone is stated anywhere in the family, which is the standard situation on this platform, and the way to stay correct regardless is in the unspecified behaviour section below.


Sending: One Endpoint, Eleven Message Types

POST https://unify.smsgateway.center/rest/tg/v1/send, content type application/x-www-form-urlencoded.

msgType selects among eleven values: text, photo, audio, document, video, animation, voice, video_note, location, contact and poll. This is a far wider surface than any other channel here. SMS offers text and unicode. RCS offers text with the richness pushed into the template. Telegram lets you send a poll.

The parameter set is small because the complexity is pushed into two JSON string fields:

ParameterRequiredNotes
useridYesRequired even with the apikey header
passwordConditionalRequired when apikey is not sent
msgTypeYesOne of the eleven values above
chatIdConditionalComma separated for multiple recipients. Not with phoneNumber
phoneNumberConditionalCountry code, digits only. Not with chatId
textConditionalBody for text, caption for media
mediaPayloadConditionalJSON string, extra fields for non text types
replyMarkupOptionalJSON string in Telegram Bot API reply_markup format
outputOptionalUse json

The length limit changes with msgType and nothing warns you. A text message allows 4096 characters. A caption on a media message allows 1024. If your code composes a body and then decides whether to attach an image, you have a path where adding a picture silently invalidates a message that was fine a moment ago. Validate against the limit that applies to the type you are about to send, not against a constant. This is the Telegram equivalent of the GSM 03.38 segment counting trap that shows up on SMS, discussed in SMS length calculation and how credits are counted, except that here the failure is a rejection rather than a surprise bill.

mediaPayload is a JSON string, not a JSON object. You serialise it and put the resulting string in a form field. Its documented fields:

FieldUsed forNotes
mediaUrlphoto, audio, document, video, animation, voice, video_notePublic https:// URL
latitude, longitudelocationLatitude -90 to 90, longitude -180 to 180
phoneNumber, firstName, lastNamecontactPhone may include a leading +
pollQuestion, pollOptions, isAnonymouspollQuestion max 300 characters, 2 to 10 options as a JSON array, isAnonymous is 0 or 1

Two traps live in that table.

phoneNumber means two different things in one request. At the top level it selects the recipient. Inside mediaPayload it is the number printed on a contact card you are sending to someone else. A helper that builds request bodies from a flat dictionary will collide these. Namespace them in your own code before they meet the wire.

Phone formats are inconsistent between the two positions. The top level phoneNumber is documented as digits only with country code. The contact card phoneNumber may include a leading +. Normalise on the way in and format on the way out, per position.

Media must be a public HTTPS URL. The platform fetches it; you are not uploading bytes. That means your media host is now in the delivery path, and a signed URL that expires in sixty seconds may expire before the fetch happens. Use stable URLs with a long or absent expiry, and serve them from somewhere with real availability.

replyMarkup is passed through to Telegram in the Bot API’s own reply_markup format, so inline keyboards, reply keyboards, force reply and remove keyboard all work. This is worth pausing on: it means the interactive surface of your messages is governed by Telegram’s own Bot API documentation, not by this platform’s. When a button behaves oddly, the answer is in Telegram’s spec.

Multiple recipients go in chatId as a comma separated list, exactly as SMS puts multiple numbers in mobile. And that brings us to the response.


One Identifier, Three Names, Two JSON Types

Here is the documented success response for a send:

{
    "status": "success",
    "message": "Message sent successfully.",
    "uniqueId": 17123456789012,
    "uuId": "17123456789012",
    "transactionId": "17123456789012",
    "msgId": "42"
}

One value appears three times under three field names, and it appears both as an unquoted JSON number and as a quoted string in the same object.

The correlation advice follows directly from what the delivery report page states, which is that uuId and transactionId are the same value returned when you send. It does not extend that guarantee to uniqueId, and the delivery report’s own sample row shows uniqueId as "90001" while uuId on that same row is "1234567890123456789". Two samples, two different relationships. Persist uuId, reconcile on uuId, and treat uniqueId as an internal number you happen to be able to see. Store it if you like, as a string, and never join on it.

The unquoted uniqueId is a numeric literal in a JSON document and is therefore subject to the language you parse it in. At fourteen digits the documented sample is comfortably inside JavaScript’s safe integer range, but nineteen digit identifiers exist elsewhere on this platform, and the Node.js integration tutorial covers what happens when one of those lands in a double. Since uuId gives you the same information as a string, there is no reason to go near the number.

msgId is "42", a quoted small integer, and it is the Telegram message ID: the identifier Telegram itself assigned inside that chat. Note that msgId now means three unrelated things across three channels. On the SMS delivery row it is a repeating free form label that shows the same value on different transactions. On RCS it is an opaque token. On Telegram it is a genuine Telegram side message number. A shared deserialiser that maps msgId to one concept is wrong on at least two channels.

Now the two structural facts about this response.

It carries no statusCode and no reason. Every other endpoint in the family returns both. /send returns message instead. So within one product, on one base path, the success envelope of the write endpoint does not match the success envelope of the seven read endpoints. Branch on status, read the human text from message on send and from reason everywhere else, and never write a generic is_ok(payload) helper that keys on the presence of statusCode.

The error response has two keys and nothing else:

{
    "status": "error",
    "message": "Telegram API sending is disabled for this account."
}

Every identifier field disappears. In a typed language, only status and message may be bound as required. Everything else is optional.

And there is no per recipient result. Send to three comma separated chat IDs and you get back one identifier and one status. There is no array of outcomes, and no invalidMobile style field of the kind SMS and RCS both return. You cannot tell from the response which of the three were accepted.

So make the decision that stays correct either way: send one recipient per call for anything you need to account for individually. The reasoning is in the unspecified behaviour section below.

Diagram tracing a Telegram message identifier from the send response through to the delivery report row, showing which field names carry the same value and which do not.
Follow uuId. It is the one name that means the same thing at both ends.

Templates: Two Actions, Two Envelopes

GET https://unify.smsgateway.center/rest/tg/v1/templates

Telegram templates are a convenience, not a gate. Unlike DLT registered SMS content, which cannot be delivered in India without an approved template, and unlike RCS, where the template is the message, a Telegram template is a saved compose payload you may choose to reuse. Nothing stops you sending arbitrary text.

Two actions, and this endpoint is the only one in the family that offers more than list:

action=list returns every active template as a summary row:

{
    "templateId": 12,
    "templateName": "Order Confirmation",
    "msgType": "text",
    "mediaUrl": ""
}

action=get with templateId returns the full compose payload:

{
    "templateId": 12,
    "templateName": "Order Confirmation",
    "msgType": "text",
    "body": "Hi {name}, your order #{orderId} has shipped!",
    "mediaUrl": "",
    "mediaPayloadJson": "",
    "buttonsJson": "",
    "useReplyMarkup": 0
}

Four things to take from this.

The placeholder syntax is single curly braces. {name}, {orderId}. That is a third distinct syntax on one platform. DLT templates use {#var#}. RCS templates use square brackets, [name]. If you have a substitution routine, it is channel specific, and sharing it across channels will produce messages that deliver successfully with the placeholder still visible in the body. Worse, naive string replacement carries its own hazards in several languages; the $& expansion problem documented in the Node.js tutorial applies here identically.

useReplyMarkup is an unquoted integer 0, not a boolean. The setup endpoint hands you real booleans; this row does not. Same family, same response generation, different convention. Coerce explicitly.

Two fields are JSON encoded strings. mediaPayloadJson and buttonsJson hold serialised JSON inside a JSON string, so reading a template means a second parse. Both are empty in the documented sample, and an empty string is not valid JSON. Guard for empty before parsing or your template reader will throw on every text template.

The two actions return different envelope shapes. list returns templatesList as an array plus totalRecords, page and limit. get returns template as a single object with none of the paging fields. Same URL, same endpoint, two response shapes selected by a query parameter. Model them as two distinct operations in your client.

The error shapes differ too, and the page says so explicitly: for get validation or not found errors the payload uses an empty template array rather than templatesList, with status codes such as 400 or 404. Which leads directly to the next section.


The Delivery Report Field by Field

GET https://unify.smsgateway.center/rest/tg/v1/dlr

This is the endpoint you will spend the most time with, because it is the only way to learn what happened to a message. Here is the documented row, annotated by JSON type:

{
    "logId": "501",
    "uniqueId": "90001",
    "uuId": "1234567890123456789",
    "transactionId": "1234567890123456789",
    "chatId": "123456789",
    "phoneNumber": "919999999999",
    "msgId": "42",
    "message": "Your order is confirmed.",
    "msgType": "text",
    "charges": "0.50",
    "sendMethod": 1,
    "globalErrorCode": 0,
    "deliveryStatus": "delivered",
    "vendorErrorCode": "",
    "channel": 1,
    "channelName": "API",
    "isFinal": 1,
    "submitTime": 1712345678901,
    "submitTimeFormatted": "2026-07-07 10:14:38",
    "dispatchedTime": 1712345680000,
    "deliveryTime": 1712345681000,
    "deliveryTimeFormatted": "2026-07-07 10:14:41"
}

Five identifiers on one row

logId, uniqueId, uuId, transactionId and msgId. uuId and transactionId are the same value. logId is the row’s own key in the reporting store. msgId is Telegram’s message number. uniqueId is an internal number and, as established above, is not the same value you saw on the send response. Key your reconciliation on uuId, store logId for support conversations, and store msgId if you ever need to correlate with something a user forwarded you from inside Telegram.

isFinal is the field no other channel has

isFinal: 1 tells you the status will not change again. On SMS and on RCS you must infer terminality from the status vocabulary, which means hardcoding a list of terminal status strings and hoping a new one never appears. Telegram answers the question directly.

Use it. Your reconciler should stop polling a row the moment isFinal comes back truthy, and should keep polling with backoff while it is falsy. That removes the most common cause of the stuck row problem, and it removes the need to guess how long to wait before giving up. Combined with the pending counter on the summary endpoint you get two independent readings of the same question, which is the sort of redundancy worth having in a reconciler.

globalErrorCode is binary and that is a real difference

0 is delivered, 1 is failed. That is the entire code space, and deliveryStatus is described as a readable label derived from it.

On SMS, globalErrorCode is a rich taxonomy: 17 is a template mismatch, and the whole catalogue is queryable from the delivery error code list endpoint. On RCS it carries codes such as 5007. On Telegram it is a flag.

So a shared error mapping table across channels will either overmatch, treating Telegram’s 1 as whatever 1 means on SMS, or silently fail to explain any Telegram failure at all. Key your mapping on channel plus code, never on code alone. And note that the granular reason, when there is one, is meant to live in vendorErrorCode, which is an empty string on the documented successful row. Capture it, do not branch on it until you have seen real values.

deliveryStatus is lowercase here: "delivered", "failed". On RCS and SMS the equivalent values are uppercase. Compare case insensitively or normalise on ingest.

The field names are swapped relative to SMS, again

On the Telegram and RCS delivery rows, the outcome field is deliveryStatus. On the SMS delivery row, the outcome field is status, and on Telegram status at envelope level means whether the API call itself worked. This is the same swap documented for RCS, and it now holds across two of the three channels, which makes it the convention rather than the exception. Porting SMS reporting code to either newer channel means renaming this field, and the failure if you do not is quiet: your code reads status, finds "success", and records every message as delivered.

Three timestamps, one of them without a twin

submitTime, dispatchedTime and deliveryTime are unquoted millisecond epochs. submitTime and deliveryTime each have a Formatted string companion. dispatchedTime does not. If your ingest maps epoch and formatted fields in pairs, this one will fall out of the pattern.

The three together are genuinely useful, more so than the two you get on SMS. submitTime is when you handed the message over. dispatchedTime is when it left toward Telegram. deliveryTime is when it landed. The gap between the first two is your platform queue depth; the gap between the last two is Telegram’s. When a broadcast feels slow, that split tells you which side to look at, and it is the kind of measurement that turns observability for messaging pipelines from a dashboard into a diagnosis.

charges is a quoted string, and it is a third spelling

"0.50". Two decimal places, quoted. SMS calls the same concept amount and returns an unquoted float. RCS calls it amount and returns a quoted string with four decimals. Parse to a decimal type, never a float, and write one adapter per channel.

sendMethod is ambiguous here and channel is not

The row carries sendMethod: 1 with no explanation on the delivery report page. The summary endpoint documents sendMethod as a direction filter where 1 is outgoing and 2 is incoming, which strongly suggests the same meaning on this row, but the delivery page does not say so. Meanwhile channel: 1 with channelName: "API" is documented plainly: 1 API, 2 UI, 3 Agent.

Use channel and channelName when you want to know where a message came from. They are documented, they come as a matched code and label pair, and the label saves you a lookup table. Leave sendMethod alone on this row.

Worth noting in passing that sendMethod now means three unrelated things across the platform: a request parameter with string values on SMS and RCS sends, an undocumented integer on the Telegram delivery row, and a direction filter on the Telegram summary.

Filters

The available filters are generous: fromDate and toDate on submit time with from and to aliases, chatId, uuId aliased to transactionId, uniqueId, msgId, msgType, channel, phoneNumber, page and limit. Results come back newest first by submitTime.

There is one filter you should not use. status is documented as an alias for globalErrorCode. In a response where status at top level means whether the API call succeeded, accepting status as a request parameter meaning something entirely different is an invitation to a confusing bug. Write globalErrorCode=1 when you want failures. It is longer and it is unambiguous.


The Payload Type Flip on Error

This is the finding that will cost you the most if you meet it in production rather than reading it here.

Line up the error responses across the family:

{ "status": "error", "setup": [],          "statusCode": "403", "reason": "..." }
{ "status": "error", "templatesList": [],  "statusCode": "403", "reason": "..." }
{ "status": "error", "recipientsList": [], "statusCode": "403", "reason": "..." }
{ "status": "error", "inboxList": [],      "statusCode": "403", "reason": "..." }
{ "status": "error", "dlrList": [],        "statusCode": "403", "reason": "..." }
{ "status": "error", "summaryList": [],    "statusCode": "403", "reason": "..." }
{ "status": "error", "analytics": [],      "statusCode": "403", "reason": "..." }

For the list shaped endpoints this is unremarkable: an empty array where an array was expected. For setup and analytics it is not. Both return a JSON object on success and an empty array on error. The templates endpoint does the same thing on action=get, which the page states directly: the error payload uses an empty template array instead of the object.

This is the classic PHP serialisation artifact, where an empty associative array encodes as [] rather than {}. Understanding why it happens does not make it go away, and the consequence is concrete. In Go, json.Unmarshal of [] into a struct returns an error. In Rust, serde fails the same way. In Java with Jackson, or C# with System.Text.Json, you get a deserialisation exception. In every one of those languages, your client works perfectly against a healthy account and blows up with a parse error the first time the account hits a 403, which means the error you actually see in your logs is a JSON error rather than “Telegram API access is not enabled for this account.”

The correct client shape, and you should apply it to all eight endpoints without exception:

  1. Parse the response into a loose representation first: map[string]any, serde_json::Value, JsonNode, dict.
  2. Read the top level status string. Nothing else.
  3. If it is not "success", read reason on the seven read endpoints or message on /send, log it with statusCode, and return. Do not touch the payload key.
  4. Only on success, bind the payload key into your typed structure.

In Go specifically, declare the payload field as json.RawMessage and unmarshal it in step four. In Rust, serde_json::Value. In Jackson, JsonNode. That two stage parse costs you about ten lines and it makes every failure in the family legible.

Python and JavaScript will not crash, which is a mixed blessing: they will hand you an empty list where your code expects a dictionary, and you will get an AttributeError or a silent undefined somewhere further downstream and further from the cause. The two stage parse is worth writing in those languages too.


Telegram, SMS and RCS Side by Side

The platform now runs three outbound channels with independently designed contracts. This is the comparison nobody has written down, and it is the reason a shared deserialiser is not merely inelegant but incorrect.

ConceptSMSRCSTelegram
Send base pathSMSApi/sendRCSApi/sendrest/tg/v1/send
Format parameteroutputformatoutput
Recipient identifiermobilemobilechatId or phoneNumber
Sender identitySender ID, 6 charsbotIdThe one account bot, implicit
Identity is selectable per sendYesYesNo
Send response shapeUnion of four, keyed on sendMethodOne shape, all methodsOne shape
Send response has statusCodeYesYesNo
Send response text fieldreasonreasonmessage
Per recipient outcome on sendinvalidMobileinvalidMobileNone
Caller supplied correlation tokenNoneidentifierNone
Scheduling parameterscheduleTimeNoneNone
Template requiredIn India, yesYesNo
Placeholder syntax{#var#}[name]{name}
Delivery outcome fieldstatusdeliveryStatusdeliveryStatus
Outcome value casingUppercaseUppercaseLowercase
globalErrorCode spaceRich taxonomyRich, e.g. 5007Binary, 0 or 1
Terminality flagNoneNoneisFinal
Cost field nameamountamountcharges
Cost JSON typeUnquoted floatQuoted string, 4 dpQuoted string, 2 dp
Cost semanticsPer partPer messagePer message
Recipient number JSON typeUnquoted numberQuoted stringQuoted string
Timestamps per delivery row243
Read and click trackingNoYesNo
Paging modelOpaque cursorpage and limitpage and limit
Delivery push to your serverOne account webhookNot documentedNot documented
Summary counter JSON typeQuoted stringsUnquoted integersUnquoted integers
Summary covers inboundNoNoYes

Two rows in that table deserve emphasis for anyone running more than one channel.

Telegram is the only channel that tells you a row is finished. If you are building a unified reconciler, that is a capability you can use on one third of your traffic and must emulate on the rest.

Telegram is the only summary that mixes directions. sendMethod 1 is outgoing and 2 is incoming, and both appear in the same list. Summing total across unfiltered rows gives you a number that means nothing. Always pass sendMethod=1 when you want outbound volume.


The Inbox and the Webhook That Is Not Yours

GET https://unify.smsgateway.center/rest/tg/v1/inbox

Here is a sentence that catches people out. /setup registers a webhook. That webhook is registered with Telegram, pointing at SMSGatewayCenter. It is how the platform receives inbound messages. It is not a push to your application.

The inbox endpoint is explicitly read only, and there is no documented outbound push for this channel. Inbound Telegram messaging is a polling integration, and you should design it as one rather than waiting for a callback that is not coming.

The row:

{
    "incomingId": "101",
    "chatId": "123456789",
    "telegramUserId": "987654321",
    "firstName": "Rahul",
    "username": "rahul_demo",
    "phoneNumber": "919999999999",
    "message": "Hello",
    "msgType": "text",
    "mediaUrl": "",
    "receivedAt": 1712345678901,
    "receivedAtFormatted": "2026-07-07 10:14:38",
    "isRead": 0
}

message is plain text here, which is worth noting against RCS, where the equivalent field holds double encoded JSON. mediaUrl is populated when someone sends you a picture or a file, so an inbound handler needs a media branch. msgType mirrors the outbound type vocabulary.

Filters: fromDate and toDate, chatId, telegramUserId, phoneNumber, isRead, page and limit. Newest first by receivedAt.

Note what isRead is and is not. It is a filterable field and a row value. There is no endpoint in this family that sets it. Something else, presumably the panel, owns that flag. So isRead tells you what a human in the panel has looked at, which is a different question from what your integration has processed.

Build the poller on your own cursor. Keep the highest receivedAt you have successfully handled, poll from slightly before it to allow for clock skew and late arrivals, deduplicate on incomingId, and advance only after your handler has committed. Four moving parts:

  1. A window. From last_processed_at minus a small overlap, to now.
  2. A page loop. limit=200, increment page until you have fewer rows than the limit.
  3. A dedupe. incomingId is the natural key. A unique index on it makes the poller idempotent and lets you run two instances without coordination.
  4. A commit. Advance last_processed_at only after handling succeeds, so a crash replays rather than skips.

Replying is a send. /inbox gives you the chatId, /send takes it, and the conversation continues. There is no threading concept and no reply-to parameter, so a reply is simply a new message to the same chat.


Summary and Analytics: Which Number to Trust

Two aggregate endpoints exist and they answer different questions.

GET /rest/tg/v1/summary returns bucketed counters:

{
    "summaryId": "88",
    "sendMethod": 1,
    "sendMethodName": "Outgoing",
    "total": 120,
    "success": 115,
    "failed": 3,
    "pending": 2,
    "summaryTime": 1712345678901,
    "summaryTimeFormatted": "2026-07-07 10:14:38"
}

Counters are unquoted integers, which matches RCS and differs from the SMS summary where they are quoted strings except for pending. sendMethod filters direction. summaryId is a quoted string.

GET /rest/tg/v1/analytics returns dashboard shaped data across five actions: overview, today, messagebreakdown, subscriberstats and subscribertrend. Date ranges cannot exceed 31 days, the same cap the SMS dashboard endpoint carries.

The analytics payload contains the most interesting number in the family, and also the clearest evidence of where these endpoints came from:

"messageBreakdown": [
    { "label": "Submitted", "value": 120, "labelColor": "#0B62A4" },
    { "label": "Delivered", "value": 115, "labelColor": "#01c0c8" }
]

Those are hex colour codes in an API response. Along with formMode on the setup endpoint, they tell you these are the panel’s own data sources exposed as a public contract. The practical rule that follows applies to the whole family: bind the fields your code needs and ignore everything else. Do not assert on field counts, do not fail on unknown keys, and do not build a feature on a field whose purpose you cannot state.

Two more analytics notes worth acting on.

total means submitted. The page states it: total is submitted messages, success is delivered. That is the correct denominator and it matters. A delivery rate computed as delivered divided by delivered plus failed freezes at a healthy looking constant whenever reports stop arriving, because both terms stop moving together. Divide by submitted and a reporting outage shows up as a falling rate, which is what you want. This is the denominator trap covered at length in observability for messaging pipelines, and the Telegram analytics endpoint hands you the right denominator by default.

subscriberTrend publishes the same series three times. There is a labels array, a parallel counts array, and a series array of objects each carrying date, label and count. Parallel arrays are an index alignment bug waiting to happen. Read series and ignore the other two.

subscriberStats is not date filtered and returns four fixed windows: today, week, fifteenDays and month. Subscriber counts are described as unique new Telegram customer mappings by creation date, which means this is your opt in growth rate. On a channel where the audience only grows by consent, that is the number that tells you whether the channel is becoming viable, and it is the one to put on a wall.

For daily reconciliation, derive your own totals from /dlr rows you have ingested and use /summary and /analytics as an independent cross check. When your number and theirs disagree, you have found either an ingest gap or a bucketing difference, and either is worth knowing about before finance asks.


Endpoint Reference Table

All paths relative to https://unify.smsgateway.center/rest/tg/v1/.

EndpointMethodActionsPayload keyPurpose
setupGET, POSTget, save, registerwebhooksetup (object)Connect the bot, register the inbound webhook, read account flags and tps
sendPOSTnonenoneSend one of eleven message types to chat IDs or a phone number
recipientsGETlistrecipientsListDistinct inbox contacts for a date range
templatesGETlist, gettemplatesList / templateList saved templates or fetch one compose payload
inboxGETlistinboxListIncoming messages, read only
dlrGETlistdlrListDelivery reports with eleven filters
summaryGETlistsummaryListBucketed counters, both directions
analyticsGEToverview, today, messagebreakdown, subscriberstats, subscribertrendanalytics (object)Dashboard metrics and subscriber growth

Success status codes, tabulated the way you should tabulate every CRUD family on this platform:

EndpointSuccess statusCodeNotes
setup"200"Plus a nested webhook.status
sendabsentReturns status and message only
recipients"200"
templates"200"Both actions
inbox"200"
dlr"200"
summary"200"
analytics"200"

Seven of eight publish "200" as a quoted string. The send endpoint publishes no status code at all. Compare RCS, where seven of eight publish "200" and RCSApi/template/list publishes no status field of any kind, and SMS, where SMSApi/group/delete returns "224" on success. Tabulating this per family takes five minutes and has caught a live anomaly in every family it has been applied to.

The documented account level error is uniform and useful:

{ "status": "error", "dlrList": [], "statusCode": "403", "reason": "Telegram API access is not enabled for this account." }

Treat 403 as a configuration problem, not a transient one. Do not retry it.


Your First Telegram Send in Six Steps

  1. Create the bot. Open BotFather in Telegram, run /newbot, choose a name and a username, and copy the token it gives you. It looks like 123456789:AA.... Put it straight into your secret manager.
  2. Connect it. POST /rest/tg/v1/setup with action=save, botName, botHandle and botToken. Read the response. Confirm isConfigured, isActive and apiAccessEnabled are all true, and note the tps value.
  3. Subscribe yourself. Open https://t.me/{botHandle} on your own phone and send /start. You are now the first row in your audience.
  4. Find your chat ID. GET /rest/tg/v1/recipients with today’s date. Your chatId is in the list. This round trip is also the fastest way to confirm the inbound webhook is actually working, because your /start only became a recipients row by arriving through it.
  5. Send. POST /rest/tg/v1/send with msgType=text, your chatId and a short text. Save the uuId from the response.
  6. Reconcile. GET /rest/tg/v1/dlr filtered by that uuId. Read deliveryStatus, globalErrorCode and isFinal. You have now exercised the entire loop, and you have a real payload to write your parser against rather than a sample from a page.

Step 6 is worth doing before you write any binding code at all. One real response is worth more than any published sample, because it tells you what your account actually returns rather than what the documentation says it should.


Working Code in Four Languages

Four short samples, each demonstrating a different trap rather than four translations of the same call.

cURL: the baseline send

curl --location 'https://unify.smsgateway.center/rest/tg/v1/send' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --header 'apikey: YOUR_API_KEY' \
  --data-urlencode 'userid=YourUsername' \
  --data-urlencode 'msgType=text' \
  --data-urlencode 'chatId=123456789' \
  --data-urlencode 'text=Your order is confirmed.' \
  --data-urlencode 'output=json'

Note userid is present even though the apikey header is. That is not redundancy, it is required.

Python: the two stage parse that survives a 403

import requests

BASE = "https://unify.smsgateway.center/rest/tg/v1"

def tg_get(path, api_key, userid, **params):
    params.update({"userid": userid, "output": "json"})
    r = requests.get(f"{BASE}/{path}", params=params,
                     headers={"apikey": api_key}, timeout=30)
    r.raise_for_status()
    body = r.json()

    # Stage one: read only the status string.
    if body.get("status") != "success":
        raise RuntimeError(
            f"{path} failed: {body.get('reason')} "
            f"(statusCode={body.get('statusCode')})"
        )

    # Stage two: only now is the payload key safe to touch.
    return body

reports = tg_get("dlr", API_KEY, USERID,
                 fromDate="2026-07-01", toDate="2026-07-07",
                 globalErrorCode=1, limit=200)

for row in reports["dlrList"]:
    print(row["uuId"], row["deliveryStatus"], row["isFinal"])

Without the status check, a 403 hands you {"dlrList": []} and the loop runs zero times in silence. Zero failures and an access problem look identical.

Go: the raw message that stops the unmarshal from exploding

type tgEnvelope struct {
    Status     string          `json:"status"`
    StatusCode string          `json:"statusCode"`
    Reason     string          `json:"reason"`
    Setup      json.RawMessage `json:"setup"`
}

type tgSetup struct {
    IsConfigured     bool   `json:"isConfigured"`
    IsActive         bool   `json:"isActive"`
    APIAccessEnabled bool   `json:"apiAccessEnabled"`
    BotChatURL       string `json:"botChatUrl"`
    TPS              int    `json:"tps"`
}

func readSetup(body []byte) (*tgSetup, error) {
    var env tgEnvelope
    if err := json.Unmarshal(body, &env); err != nil {
        return nil, err
    }
    if env.Status != "success" {
        return nil, fmt.Errorf("setup: %s (%s)", env.Reason, env.StatusCode)
    }
    var s tgSetup
    if err := json.Unmarshal(env.Setup, &s); err != nil {
        return nil, err
    }
    return &s, nil
}

Declaring Setup as a struct instead of json.RawMessage compiles, passes every test against a working account, and fails on the 403 with a type error that names nothing useful. The booleans are real booleans, so bool is correct here and would not be on any other channel.

PHP: sending a photo without colliding the two phoneNumber fields

$mediaPayload = json_encode([
    'mediaUrl' => 'https://cdn.example.com/receipts/inv-8841.jpg',
]);

$fields = [
    'userid'       => $userid,
    'password'     => $password,
    'msgType'      => 'photo',
    'chatId'       => $chatId,          // recipient
    'text'         => 'Your invoice is attached.',  // caption: 1024 max
    'mediaPayload' => $mediaPayload,
    'output'       => 'json',
];

$ch = curl_init('https://unify.smsgateway.center/rest/tg/v1/send');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query($fields),
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 30,
]);
$body = json_decode(curl_exec($ch), true);
curl_close($ch);

if (($body['status'] ?? '') !== 'success') {
    throw new RuntimeException($body['message'] ?? 'unknown telegram error');
}
$uuId = $body['uuId'];   // the quoted one. Never $body['uniqueId'].

Two things in eleven lines: the caption limit is 1024 and not 4096 because msgType is photo, and the error branch reads message rather than reason because this is the send endpoint. Both are easy to get wrong and neither produces an obvious symptom.

For the wider platform, the PHP and Python integration tutorials cover the SMS surface in depth. The official SDKs do not currently cover the Telegram endpoints, so the calls above are the reference implementation.


Ten Mistakes That Cost a Sprint

  1. Binding the payload key to a struct in a typed language. Works until the first 403, then fails with a JSON error that hides the real message. Two stage parse.
  2. Reconciling on uniqueId. The delivery report only guarantees uuId and transactionId match what you sent. Reconcile on uuId.
  3. Reading status from a delivery row. There is no status on the row. The outcome is deliveryStatus, and status at envelope level is about the API call.
  4. Sharing a globalErrorCode mapping across channels. Binary on Telegram, rich taxonomy on SMS and RCS. Key on channel plus code.
  5. Treating /recipients as a subscriber table. It is a date ranged query over inbound messages. Keep your own table.
  6. Sending to many chat IDs in one call and assuming all landed. One identifier comes back for N recipients and there is no per recipient result.
  7. Composing a message body, then attaching an image. The limit drops from 4096 to 1024 when msgType stops being text.
  8. Using chatId and telegramUserId interchangeably. Different numbers, and only chatId is addressable.
  9. Waiting for a delivery webhook. The webhook in /setup points at the platform, not at you. Poll /dlr.
  10. Summing summary rows without filtering direction. Incoming and outgoing share the list. Pass sendMethod=1.

Choosing Between Telegram, SMS and RCS

DimensionChoose SMSChoose RCSChoose Telegram
Recipient has not interacted with youYesYesNo, impossible
Guaranteed reach on any handsetYesNoNo
Regulated OTP and transactional alerts in IndiaYesNoNo
Rich media in the messageNoYesYes
Interactive buttonsNoYesYes
Polls and surveysVia long codeNoYes, natively
Read and click trackingNoYesNo
Two way conversationVia long codePoll the inboxPoll the inbox
Per message carrier feeYesYesNo Telegram fee
Template approval before sendingRequired in IndiaRequiredNot required
Message length160 per segmentNo segment concept4096, or 1024 as a caption
Terminal status is explicitNoNoYes, isFinal
Scheduling through the APIYesNoNo
Good for a school parent broadcastWorkable, costs add upHandset dependentStrong fit
Good for a password reset codeStrong fitNoNo
Good for an order status updateStrong fitStrong fitStrong fit if subscribed

The honest summary: Telegram is excellent for ongoing communication with a community that has chosen to hear from you, and useless for reaching someone who has not. It complements SMS rather than replacing it, and the sensible architecture routes critical universal messages to SMS and routine community messages to Telegram, with multi channel fallback expressing the preference once rather than in every call site.


Production Checklist

Setup and identity

  • Non production uses a separate account, because the bot is account scoped
  • All eight endpoints called with the apikey header, never with a password in a query string
  • Bot token stored in a secret manager, never logged, never read back
  • save request bodies excluded from request logging
  • Deployment smoke test asserts isConfigured, isActive and apiAccessEnabled
  • tps read at startup and used to size the sender’s rate limiter
  • maxAgents checked against how many support seats you expect
  • botChatUrl rendered as a QR code client side
  • A documented runbook step calls registerwebhook if inbound messages stop

Data model

  • telegram_subscriber table keyed on chat_id as a string
  • telegram_user_id stored separately and never used for sending
  • first_seen_at and last_seen_at maintained by the sweep
  • Backfill sweep over /recipients from bot go-live date completed
  • Nightly sweep job scheduled and monitored
  • Outbound table stores uuId as the correlation key
  • logId and msgId stored for support lookups
  • All identifiers stored as text, never integers
  • charges parsed into a decimal type, never a float

Sending

  • One recipient per call wherever per message accounting matters
  • Length validated against 4096 or 1024 depending on msgType
  • chatId and phoneNumber mutually exclusive, enforced before the call
  • Contact card phoneNumber namespaced separately from recipient phoneNumber
  • Media URLs public, HTTPS, and stable for longer than the fetch window
  • replyMarkup validated against the Telegram Bot API format
  • Send errors read message, not reason
  • Rate limiter honours the tps value from setup

Reporting

  • Two stage parse implemented on all eight endpoints
  • 403 treated as configuration, not retried
  • Reconciler stops polling when isFinal is truthy
  • globalErrorCode mapping keyed on channel plus code
  • deliveryStatus compared case insensitively
  • dispatchedTime handled without assuming a Formatted twin
  • Queue latency measured as dispatchedTime minus submitTime
  • Delivery rate computed with submitted as the denominator
  • /summary queried with sendMethod=1 for outbound totals
  • subscriberTrend read from series, not the parallel arrays
  • Daily totals derived from ingested rows and cross checked against /summary

Inbox

  • Poller keyed on incomingId with a unique index
  • Cursor advanced only after the handler commits
  • Overlap window on each poll to absorb clock skew
  • Media branch handles a populated mediaUrl
  • Own processed flag maintained rather than relying on isRead
  • Replies sent to chatId from the inbox row

Unspecified Behaviour and How to Code Around It

Some behaviours on this API are not pinned down by anything you can read. Each item below gives you the choice that stays correct whichever way the behaviour actually resolves, so none of them require you to wait for an answer before shipping.

One. Send one recipient per call when you need per message accounting. A comma separated chatId list returns a single identifier and a single status, with no per recipient array. Whether the platform fans out internally and records N delivery rows, or treats the batch as one unit, is not something you want to discover during a reconciliation dispute. Loop in your own code, one call per chat ID, pace it with the tps value from setup, and every message gets its own uuId. Slower on the wire, and it makes your outbound table and the delivery report agree row for row. Use the comma separated form only for fire and forget notices where nobody will ever ask what happened to one particular person.

Two. Treat every timestamp as opaque until you have calibrated it once. No timezone is stated for any date field in the family, and the Formatted strings carry no offset. Send one message to yourself, note the wall clock time on your phone to the second, then read the row back and compare submitTimeFormatted against it. That single observation tells you the offset for every timestamp in the family. Until you have done it, store the raw millisecond epochs, which are unambiguous, and render the Formatted strings only as opaque display text. Never parse a Formatted string into a timestamp type and never use one in arithmetic.

Three. Persist uuId and reconcile on uuId only. The send response offers three names for what appears to be one value, and the delivery report guarantees only two of them match. Store all three columns if you like, as strings, but write exactly one join and write it on uuId. If the relationship between uniqueId and uuId is different from what a sample suggests, your reconciliation is unaffected.

Four. Cache the template body, do not resolve it at send time. A template is a saved compose payload rather than an enforced gate, and nothing documents what happens if the template changes between the moment you read it and the moment you send. Fetch with action=get, substitute your {name} placeholders locally, send the resulting literal text, and store the exact string you sent alongside the message. Your outbound record is then self describing and does not depend on a template that someone may edit next week.

Five. Guard every JSON-in-JSON field for emptiness before parsing. mediaPayloadJson and buttonsJson arrive as strings containing serialised JSON, and the documented sample shows both as empty strings. An empty string is not valid JSON and will throw in every language. Write one helper that returns an empty structure for null, empty and whitespace, and route both fields through it. Correct whether the platform sends "", null or omits the key.

Six. Empty the meaning out of sendMethod on the delivery row. The field is present with value 1 and no explanation on the delivery report page, while the summary endpoint documents the same name as a direction filter. Use channel and channelName when you need to know where a message originated. Both are documented, they arrive as a matched code and label, and your code stays correct regardless of what sendMethod turns out to mean on that row.

Seven. Write globalErrorCode=1 rather than status=1 when filtering failures. The two are documented as aliases on the request, but status already means the API call outcome in the response of that same endpoint. Using the unambiguous spelling costs you nothing and removes a class of bug where a reader, or a future maintainer, mistakes a request filter for a response assertion.

Eight. Maintain your own processed marker for the inbox. isRead is filterable and appears on every row, and no endpoint in this family sets it. Whatever owns that flag, it is not answering the question your integration cares about. Track your own cursor and your own per message processed state, keyed on incomingId, and treat isRead as information about human activity in the panel rather than about your pipeline.

Nine. Derive daily totals from the rows you ingested, and use /summary as a check. The summary bucket granularity is not stated and the documented summaryTime is not midnight, so the bucket may or may not be a calendar day. Sum your own ingested delivery rows for your reporting numbers, then compare against /summary on a schedule. Agreement is reassuring; disagreement points at either an ingest gap or a bucketing difference, and you want to find both.

Ten. Bind only the fields you use, and fail on none that you do not. Hex colour codes and a formMode field sit in these responses today. Fields of that kind change without notice because they exist to serve a screen. Configure your deserialiser to ignore unknown properties, never assert on the shape of a response beyond the keys you read, and a cosmetic change on the platform side stays cosmetic on yours.

Eleven. Re-register the webhook as a routine recovery step, not an emergency one. registerwebhook exists as a standalone action, which tells you re-registration is an expected operation rather than a repair. Put it in your runbook: if inbound messages stop arriving and the account is otherwise healthy, call it once and re-check /inbox. Cheap, safe to repeat, and it resolves the case where the registration lapsed without anyone noticing.

Twelve. Assume the bot token cannot be recovered, because it cannot. No endpoint returns it, and update calls accept its absence as “keep the current one”. If you lose the token, the recovery path runs through BotFather, not through this API. Store it where you store your other unrecoverable secrets and back it up accordingly.


FAQs

What is the base URL for the SMSGatewayCenter Telegram API?

https://unify.smsgateway.center/rest/tg/v1/ followed by the endpoint name: setup, send, recipients, templates, inbox, dlr, summary or analytics. A legacy alias without the v1 segment is accepted on seven of the eight. Use the v1 form.

Can I send a Telegram message to any phone number?

No. Telegram addresses conversations, and a conversation exists only after the person has messaged your bot. You may send by phoneNumber instead of chatId, but only for contacts who shared their number with your bot, which again requires prior interaction. To reach someone cold, use SMS.

How many Telegram bots can one account have?

One. The send endpoint has no bot selector, and the message goes out on the bot linked to the account making the call. Separate environments need separate accounts.

How do I get a chat ID?

Have the person open https://t.me/{yourBotHandle} and send /start. Then call /rest/tg/v1/recipients for that date range and read the chatId from the row. Store it in your own table; do not re-query it at send time.

What is the difference between chatId and telegramUserId?

chatId identifies the conversation with your bot and is the only value you can send to. telegramUserId identifies the Telegram account and is useful as an inbox filter. They are different numbers and are not interchangeable.

Which identifier should I store to track a message?

uuId. It is a quoted string on both the send response and the delivery report, and the delivery report documentation confirms it matches what the send returned. transactionId carries the same value. Do not reconcile on uniqueId.

Why does my strongly typed client crash only when something goes wrong?

Because the payload key changes JSON type on error. setup and analytics return an object on success and an empty array on error, and the list endpoints return an empty array in place of their rows. Parse the envelope loosely, check status first, and bind the payload only on success.

Is there a webhook that pushes Telegram delivery reports to my server?

Not a documented one. The webhook registered by /setup points from Telegram to SMSGatewayCenter so the platform can receive inbound messages. Both delivery reports and inbound messages are polled by your application, from /dlr and /inbox respectively.

How do I know when a delivery status is final?

Read isFinal on the delivery row. Telegram is the only channel on this platform that publishes an explicit terminality flag, so your reconciler can stop polling a row as soon as it is truthy instead of guessing from the status vocabulary.

Do Telegram messages need an approved template like DLT SMS?

No. Templates on this channel are saved compose payloads you may reuse, not a compliance gate. You can send arbitrary text. Regulated Indian SMS is a different matter entirely and is covered in the DLT registration guide.

What is the maximum message length?

4096 characters for a text message, 1024 characters for a caption on a media message. The limit changes with msgType, so validate against the type you are about to send rather than against a constant.

How many messages per second can I send?

Read the tps field from the /setup response. It is an unquoted integer and reflects the rate configured on your account. Size your rate limiter from that value rather than hardcoding one.

Can I schedule a Telegram message through the API?

No scheduling parameter is documented on /send. Hold scheduled sends in your own job store and dispatch at the appropriate time.

How does Telegram pricing compare with SMS?

Telegram does not charge a per message carrier fee for standard bot messaging, so the recurring cost is the SMSGatewayCenter platform fee under either a per message or a subscription model. SMS carries per message carrier and DLT costs, which is why the per SMS rate is only part of the picture, as explained in what actually drives bulk SMS cost. Exact Telegram rates depend on your account, so ask for a quote on the Bulk Telegram Messaging page.


Building on Telegram alongside SMS or WhatsApp?

Telegram API access is enabled per account. Get in touch to have it switched on, or read the full Telegram developer documentation to start integrating today.


Recent Articles


Save this interesting page on your favorite Social Media

Blog Author logo

SMS Gateway Center Desk

SMS Gateway Center is one of the largest and leading SMS Provider in India. It is run by a large professional team to cater small companies to large corporate companies. SMS Gateway Center is associated with the best operators in India covering the entire states in India. SMS Gateway Center has been serving through its SMS Resellers in more than 20 states in India. To become our SMS Reseller, kindly contact us

Looking for the best business communication solutions, get in touch!