This guide is for bot developers coming from Telegram, whether you're pointing an existing python-telegram-bot, grammY, aiogram, or Telebot client at Migram, or writing new code with Telegram's own conventions in mind.
Looking for the quick reference instead? See Telegram-compatible mode in the full Bot API reference. This page goes deeper on every divergence.
Migram is an independent chat platform, not a Telegram fork or a compatible re-implementation of Telegram's protocol. What it does offer is an HTTP surface, described below, that speaks Telegram's own Bot API request and response shapes closely enough that a lot of existing bot code runs against Migram with a base-URL change and little else. Where Migram's bot platform genuinely works differently -- fewer method types, different parameter support, a different identifier scheme -- this page says so plainly rather than pretending otherwise. Nothing here fakes a success response for something Migram didn't actually do.
Two calling conventions exist side by side and answer to the same underlying bot account:
Authorization: Bearer header, documented in full in the Bot API reference.You can mix them freely on the same bot token -- there's no migration step that locks you into one or the other.
Create a bot with @botmigi -- Migram's answer to BotFather -- the same way you'd create one with Telegram's BotFather: a display name, then a username ending in bot. In response to /newbot, @botmigi shows two forms of the token, compat form first: a numeric one, <numeric id>:<secret>, and underneath it the native one, <24-character id>:<secret>. Same secret both times -- only the prefix differs.
Use the numeric form. Some Telegram libraries -- aiogram is a concrete example -- parse the token client-side before making any request at all, check that the part before the colon is an integer, and derive their own bot.id from it; handing one of these the native hex-id form fails locally, before Migram ever sees a request. Libraries that don't inspect the token accept either form, but there's no reason to rely on that when @botmigi already hands you the numeric one. getMe's own result.id matches this same numeric alias, so a library that computed its own bot.id from the token prefix sees that number confirmed back.
Point your existing base URL at Migram instead of Telegram, keeping the token and method name where they already are in the path:
https://api.telegram.org/bot<token>/METHOD_NAME -> https://api.migram.org/bot<token>/METHOD_NAME
Where exactly that substitution goes in your code depends on your library, and it's a real footgun: some libraries want the bare origin and append /bot<token> themselves, others want the origin with /bot already on the end and just glue the token straight after it. Get it backwards and every request goes to a URL that's simply malformed, not to an error that names the problem.
python-telegram-bot concatenates its base_url directly against the token with no separator in between -- its own default is "https://api.telegram.org/bot", /bot included. Match that shape:
Bot(token=TOKEN, base_url="https://api.migram.org/bot")
grammY's apiRoot wants the bare origin instead -- it appends /bot<token> itself:
new Bot(token, { client: { apiRoot: "https://api.migram.org" } })
aiogram's TelegramAPIServer.from_base() also takes the bare origin, for the same reason:
TelegramAPIServer.from_base("https://api.migram.org")
Using a different library? Check whether its base-URL option expects the /bot segment already included or adds it for you -- that's the one question that decides which form to hand it.
That URL and token substitution is enough for most simple bots -- one that calls sendMessage, getMe, and polls getUpdates with plain text needs nothing further. Method names are matched case-insensitively, exactly like Telegram, and both GET and POST are accepted no matter which verb Telegram documents for that method -- any other verb gets a 405. JSON, application/x-www-form-urlencoded, and multipart/form-data bodies are all accepted, so whichever your library already sends keeps working.
Everything past this point in the guide covers where a bot doing more than that will notice a difference.
Success and failure both come back shaped like Telegram's own responses:
{ "ok": true, "result": ... }
{ "ok": false, "error_code": 400, "description": "Bad Request: chat not found" }
A failure that carries extra structured data -- today, only a 429's retry delay -- nests it under parameters, matching Telegram's own convention:
{ "ok": false, "error_code": 429, "description": "Too Many Requests: retry after 3", "parameters": { "retry_after": 3 } }
| Status | When you'll see it |
|---|---|
| 400 | A required parameter is missing or fails validation, or names a chat or message Migram can't find. Telegram itself answers "not found" with 400, not 404, and this mode matches that. |
| 401 | The token in the URL is missing, malformed, or doesn't match a registered bot. |
| 403 | The bot isn't a participant in the named conversation. |
| 404 | The method name doesn't exist at all, or names a real Telegram method Migram has no equivalent for -- see Unsupported methods below. |
| 405 | An HTTP verb other than GET or POST. |
| 409 | getUpdates was called while a webhook is active, or a newer getUpdates call replaced this one -- see getUpdates and webhooks below. |
| 429 | Rate limited. Back off for parameters.retry_after seconds -- see Rate limits. |
| 500 | Something failed inside Migram itself. |
| 502 | The bot platform couldn't reach Migram's backend. |
Unlike a native failure, a compat failure has no separate top-level error field -- description is the whole story, and error_code always matches the HTTP status exactly. Anything Migram knows that Telegram's own response shape has no field for -- native string ids, for instance -- rides under an additive migram key alongside the Telegram-shaped fields; a standard Telegram client library ignores keys it doesn't recognize, so this never breaks parsing.
Every send, edit, delete, action, and answer call is capped at 30 requests per second per bot, and separately at 20 requests per minute against any single conversation. Both apply per bot, regardless of which calling convention issued the request -- a burst split across native and compat calls still shares one bucket. Crossing either cap returns HTTP 429 with a Retry-After header, seconds, matching parameters.retry_after in the body.
Every method below shares its name between the two calling conventions -- only the parameter names, and in a few places the supported parameter set, differ. Each method has a closed list of what it accepts. Anything Telegram documents for a method that isn't listed as mapped here has no Migram equivalent at all, and sending it -- nonempty -- is rejected with 400 naming the parameter, never silently dropped:
{ "ok": false, "error_code": 400, "description": "Bad Request: parameter reply_to_message_id is not supported by Migram. See https://migram.org/docs/bots/migrating" }
The one exception is a short list of boolean hints Telegram itself treats as advisory rather than load-bearing -- disable_notification, protect_content, disable_web_page_preview, has_spoiler, supports_streaming, and a couple of others in the same spirit. Omitted or sent as false, each of these is accepted as a no-op, since that's already the only behavior Migram has for it. Sent as explicit true, it gets the same 400 as any other parameter Migram can't honor -- accepting a "yes, do the thing" and quietly doing nothing would misrepresent what happened.
| Method | Telegram parameter -> native | What's different |
|---|---|---|
| sendMessage | chat_id -> conversationId, text -> text, parse_mode -> parseMode, reply_markup -> replyMarkup | Migram also accepts its own messageEffectId extension (no Telegram equivalent) under the same name in both conventions. parse_mode accepts Markdown or HTML only -- no MarkdownV2. |
| sendMessageDraft | -- no Telegram equivalent -- | A Migram-only extension for streaming a message into existence before finalizing it. Exposed under its native name in both conventions since there's nothing to alias from. |
| sendPhoto | chat_id -> conversationId, photo -> photoUrl or an uploaded file, caption -> caption | No parse_mode/caption_entities support -- captions on every media-sending method are stored as plain text. |
| sendDocument | chat_id -> conversationId, document -> documentUrl or an uploaded file, caption -> caption | No thumbnail or disable_content_type_detection. |
| sendVideo | chat_id -> conversationId, video -> videoUrl or an uploaded file, caption -> caption, duration -> duration, width -> width, height -> height | No supports_streaming, thumbnail, or has_spoiler. |
| sendVoice | chat_id -> conversationId, voice -> voiceUrl or an uploaded file, caption -> caption, duration -> duration | -- |
| sendAnimation | chat_id -> conversationId, animation -> animationUrl or an uploaded file, caption -> caption | No duration, width, or height -- unlike sendVideo, these aren't tracked for animations. |
| sendSticker | chat_id -> conversationId, sticker -> stickerId | Only a sticker already in Migram's own catalog can be sent, by id. There's no URL or multipart upload path for arbitrary sticker files the way Telegram allows -- a bot that tries to send a custom .webp/.tgs sticker file gets a 400, not a successful send. |
| sendLocation | chat_id -> conversationId, latitude -> lat, longitude -> lng | Telegram's sendLocation has no caption parameter at all -- Migram's is an extension, accepted under that name in both conventions. No live_period, horizontal_accuracy, heading, or proximity_alert_radius (no live locations). |
| sendVenue | chat_id -> conversationId, latitude -> latitude, longitude -> longitude, title -> title, address -> address, foursquare_id -> foursquareId | No foursquare_type, google_place_id, or google_place_type. |
| sendContact | chat_id -> conversationId, phone_number -> phoneNumber, first_name -> firstName, last_name -> lastName | No vcard. |
| sendDice | chat_id -> conversationId, emoji -> emoji | Migram adds a sixth face, 🎳 (bowling, 1-6), on top of Telegram's five (🎲🎯🏀⚽🎰). |
| sendPoll | chat_id -> conversationId, question -> question, options -> options, type -> type, is_anonymous -> isAnonymous, allows_multiple_answers -> allowsMultipleAnswers, correct_option_id -> correctOptionId, explanation -> explanation | No explanation_parse_mode, explanation_entities, open_period, close_date, or is_closed -- polls can't be scheduled to auto-close or closed after the fact via the API. |
| sendMediaGroup | chat_id -> conversationId, media -> media | Each item supports only type ("photo" or "video"), media, and caption -- no audio or document albums, and no per-item parse_mode. Telegram's own sendMediaGroup returns an array with one Message per item; Migram's album is a single real message, so compat mode returns a one-element array holding that one Message, with the individual items listed under a migram.media_group extension key instead of as separate array entries. |
| sendChatAction | chat_id -> conversationId, action -> action | No record_video_note/upload_video_note -- Migram has no video notes. |
| Method | Telegram parameter -> native | What's different |
|---|---|---|
| editMessageText | chat_id -> conversationId, message_id -> messageId, text -> text | No parse_mode or reply_markup on edit -- the replacement text is stored exactly as sent, and any existing keyboard is untouched. On success, compat mode returns the real, current Message object for the edited message (not just true), matching what Telegram itself returns when editing an ordinary chat message. |
| deleteMessage | chat_id -> conversationId, message_id -> messageId | -- |
| Method | Telegram parameter -> native | What's different |
|---|---|---|
| setMyCommands | commands -> commands, scope -> scope, language_code -> languageCode | Compat mode's scope.type accepts only default, all_private_chats, or all_group_chats in this release -- chat and chat_member (Telegram's per-chat and per-member scopes) are rejected with 400, even though native mode itself does support them when called with its own camelCase parameters. No all_chat_administrators or chat_administrators in either convention. |
| getMyCommands | scope -> scope, language_code -> languageCode | Native's own endpoint only accepts these as query parameters (GET); compat mode also accepts a POST JSON body, since that's how most Telegram libraries call it. Same chat/chat_member restriction as setMyCommands. |
| deleteMyCommands | -- scope and language_code have no effect -- | Called with no arguments, clears the default-scope list, matching Telegram's own no-argument behavior. Unlike setMyCommands/getMyCommands, this method doesn't accept a scope to target at all -- sending a nonempty scope or language_code is rejected with 400 rather than silently ignored. |
| setChatMenuButton | menu_button.type -> type, menu_button.text -> text, menu_button.web_app.url -> url | Telegram nests the button in a menu_button object with a nested web_app.url; native flattens all three fields to the top level. The button is always bot-wide -- Telegram's optional per-chat chat_id targeting has no native equivalent, and sending it is rejected with 400. |
| Method | Telegram parameter -> native | What's different |
|---|---|---|
| answerCallbackQuery | callback_query_id -> callbackQueryId, text -> text, show_alert -> showAlert, url -> url, cache_time -> cacheTime | -- |
| answerInlineQuery | inline_query_id -> inlineQueryId, results -> results, cache_time -> cacheTime, is_personal -> isPersonal, next_offset -> nextOffset | Telegram defines a different result schema per type (InlineQueryResultPhoto, InlineQueryResultArticle, and so on); Migram flattens all of them into one shape, so per-type fields like photo_url, thumbnail_url, and video_url all collapse onto a single mediaUrl/thumbUrl pair -- see InlineQueryResult in the reference. |
| Method | Telegram parameter -> native | What's different |
|---|---|---|
| getUpdates | offset -> offset, limit -> limit, timeout -> timeout | Same names and semantics; timeout is capped at 50 seconds either way -- see getUpdates and webhooks below. |
| setWebhook | url -> url, drop_pending_updates -> dropPendingUpdates | No certificate, ip_address, or max_connections at all. A nonempty allowed_updates or secret_token is rejected with 400 in this release rather than silently ignored -- Migram always delivers every update type it has, and signs deliveries with its own X-Migi-Signature HMAC scheme instead of checking a secret_token header. See getUpdates and webhooks for the full webhook-delivery picture, including why this is worth reading closely if you're switching a webhook bot rather than a polling one. |
| deleteWebhook | drop_pending_updates -> dropPendingUpdates | -- |
| getWebhookInfo | -- no parameters -- | The returned object reports only url, has_custom_certificate (always false), and pending_update_count. Telegram's richer fields -- ip_address, last_error_date, last_error_message, max_connections, allowed_updates -- are omitted rather than faked, since Migram doesn't track them. |
getMe takes no parameters in either convention. validateWebAppData and generateWebAppInitData are Migram-only conveniences with no Telegram Bot API method behind them -- Telegram validates initData client-side with a signature check your own backend re-implements, rather than through a server endpoint. Both are exposed under their native names in both conventions since there's nothing Telegram-shaped to alias from.
Telegram's own libraries assume chat.id and from.id are 64-bit integers and message_id is a plain integer, because that's what Telegram's servers actually hand back. Migram's real identifiers were never numbers -- they're strings assigned by the database. This mode bridges the two rather than forcing a rewrite of every type check in your existing code:
chat_id or message_id resolves it back to the real conversation or message before the request reaches the exact same code path a native call would use -- there's no separate, parallel implementation to drift out of sync. Addressing a numeric chat_id your bot has never seen through a poll returns 400 ("chat not found") even when the conversation is perfectly real -- it's easy to miss in normal use, since a polling bot always receives an update before it replies to it, but it bites a send-first flow that tries to message a chat cold.chat_id and message_id is already "Integer or String" -- this works whether or not a numeric alias has been allocated yet, so a send-first bot has two ways around the 400 above: poll once first, or address the conversation by its native id string from the start.getMe's result.id -- see Token format in the reference. It's allocated and stored the same way as the conversation/message/user aliases above, but it's a separate kind of alias, not one you'll see show up as someone else's chat.id or from.id.chat.type in any response or update is always the real type Migram has on file for that conversation -- resolved from the backend, never guessed and never defaulted to private when the actual type is something else.update_id, as returned by getUpdates, is a separate, pre-existing per-bot counter that has nothing to do with this aliasing scheme -- it was already a plain increasing integer before Telegram-compatible mode existed.Photos, documents, videos, voice notes, and animations can be sent the way a Telegram library already sends them: an HTTP(S) URL string, or raw bytes in a multipart/form-data field (an attach:// reference works the same way as multipart bytes). A URL string passes straight through to native's own photoUrl/documentUrl/videoUrl/etc. parameters -- Migram doesn't fetch it server-side any more than a native call does today. Multipart or attach:// bytes are ingested into Migram's own media store first, and Migram issues a file_id for the result.
Reuse that file_id in a later media parameter to resend the same file without uploading it again. A file_id copied out of an actual Telegram chat is rejected with 400 ("wrong file identifier") rather than silently accepted -- the two platforms don't share a file registry, and there's no plan to bridge them.
Documented limitation: media metadata Telegram expects, like a photo's width and height, is only ever a real value, from a file Migram actually ingested and measured through multipart or attach://. A plain URL send is never measured server-side -- Migram passes the URL through the same way native has always done -- so those fields are simply absent from the response rather than filled in with something invented. Stickers are the one media type with no upload path at all: only a sticker that already exists in Migram's own catalog can be sent, addressed by stickerId (see Sending messages above).
Migram has no equivalent of Telegram's edited_message, channel_post, chat_member, poll_answer, or any of Telegram's other update kinds -- an allowed_updates filter would have nothing extra to exclude, which is why setWebhook doesn't accept one. Of the update types Migram does have, three -- message, callback_query, and inline_query -- map onto Telegram's own.
Migram also has a couple of its own update kinds that Telegram has no concept of at all: a bot being added to or removed from a chat, and a verification-flow event. Neither has a Telegram shape to translate into, so on this compat route one of these arrives as an Update carrying only its update_id and nothing your library recognizes; a stock Telegram handler skips it and advances past it exactly as it would for any update type it doesn't know about, which is the honest thing to do rather than inventing a fake message or callback_query to carry data that isn't either. The real event, with its actual content, stays available if you call the native surface instead.
Polling. getUpdates keeps the same numeric, ever-increasing update_id, the same 1-100 (default 100) limit, and accepts a timeout up to 50 seconds for long-polling -- lower than Telegram's typical ceiling, bounded by the infrastructure Migram's API runs behind. This mode rewrites each returned update's message, callback_query, or inline_query payload into Telegram's own field names and the numeric id aliases described above before handing it back, so a stock Telegram update handler reads it unmodified.
Webhooks. This is the one place Telegram-compatible mode doesn't change anything: a webhook you register with setWebhook receives Migram's existing native-shaped push payload, not a Telegram-shaped one, regardless of which calling convention you otherwise use. That payload:
X-Migi-Signature: sha256=<hex>, X-Migi-Timestamp, and X-Migi-Bot-Id headers instead of Telegram's X-Telegram-Bot-Api-Secret-Token header check;update_id, not a number -- only getUpdates polling gets the numeric rewrite;message, conversation_id, callback_query, and so on).If your webhook receiver expects Telegram's exact shape, you'll need to adapt it for these three differences, or poll getUpdates instead -- see the full Getting updates section for the complete payload reference and retry behavior.
Testing your migration and want a real chat_id/conversation_id to try it against without writing any code first? Message @idbot in Migram and it replies with one -- see Getting a conversation ID for details.
Calling a real Telegram method Migram has no equivalent for returns a 404 that names the method and points back at this page, instead of silently doing nothing or pretending to succeed:
{ "ok": false, "error_code": 404, "description": "Not Found: method forwardMessage is not supported by Migram. See https://migram.org/docs/bots/migrating" }
The categories that fall into this bucket today:
forwardMessage, copyMessage, and their plural forwardMessages/copyMessages forms.banChatMember, unbanChatMember, restrictChatMember, promoteChatMember, pinChatMessage, unpinChatMessage, setChatTitle, setChatDescription, setChatPhoto, exportChatInviteLink, leaveChat, and the rest of that family. Bots can't administer groups on Migram today; they can only send and receive messages in conversations they're already part of.getChat, getChatMember, getChatAdministrators, getChatMemberCount.createNewStickerSet, addStickerToSet, deleteStickerFromSet, uploadStickerFile, and getStickerSet. A bot can send an existing catalog sticker (see Media uploads and file IDs) but can't manage the catalog itself.sendAudio and sendVideoNote (there's no music-player-style audio message or round video note type).None of this is a temporary gap in the compat layer specifically -- these are things the native bot platform doesn't do either, so no calling convention gets you access to them.
Two things are worth stating plainly, since they're easy to assume by analogy with Telegram and aren't things this compat layer will ever add:
Two narrower Telegram concepts also don't exist on Migram at all, which is why parameters tied to them are always rejected rather than partially honored: forum topics/threads (message_thread_id and the forum-management methods have nothing to attach to), and Telegram Business accounts (business_connection_id and the related methods have no Migram counterpart).
There's also no separate downloadable Bot API server binary to self-host, the way Telegram publishes one -- api.migram.org is the only server, for every bot, in both calling conventions.
@botmigi is Migram's answer to BotFather, and the command set will feel immediately familiar: /newbot to create a bot and receive its token, /mybots to list and manage the bots you own, /token and /revoke to re-show or rotate a token, /setname, /setdescription, /setabouttext, and /setuserpic for profile editing, /setcommands for the command menu, /setinline for inline mode, /setjoingroups and /setprivacy for group behavior, and /setwebhook to point at a delivery URL. See the full command list in the Bot Manual.
api.telegram.org to api.migram.org. Nothing else about the URL or method name needs to change.chat_id your bot hasn't seen through a poll yet doesn't exist, and a send-first flow needs to either poll once or address the conversation by its native id string instead.update_id, and Migram's own signature scheme -- or switch that bot to polling.disable_notification rather than a 400 when it's set to true.Migram is an independent project, not affiliated with, endorsed by, or associated with Telegram FZ-LLC or Telegram Messenger Inc.