Go up

Migrating from Telegram to Migram

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.

Overview

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:

  • Telegram-compatible mode -- the subject of this page. Same URL shape, envelope, and parameter names as Telegram's Bot API.
  • Native mode -- Migram's own camelCase parameters and 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.

Switching your code

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.

Response envelope and errors

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 } }
StatusWhen you'll see it
400A 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.
401The token in the URL is missing, malformed, or doesn't match a registered bot.
403The bot isn't a participant in the named conversation.
404The method name doesn't exist at all, or names a real Telegram method Migram has no equivalent for -- see Unsupported methods below.
405An HTTP verb other than GET or POST.
409getUpdates was called while a webhook is active, or a newer getUpdates call replaced this one -- see getUpdates and webhooks below.
429Rate limited. Back off for parameters.retry_after seconds -- see Rate limits.
500Something failed inside Migram itself.
502The 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.

Rate limits

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.

Parameter reference by method

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.

Sending messages

MethodTelegram parameter -> nativeWhat's different
sendMessagechat_id -> conversationId, text -> text, parse_mode -> parseMode, reply_markup -> replyMarkupMigram 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.
sendPhotochat_id -> conversationId, photo -> photoUrl or an uploaded file, caption -> captionNo parse_mode/caption_entities support -- captions on every media-sending method are stored as plain text.
sendDocumentchat_id -> conversationId, document -> documentUrl or an uploaded file, caption -> captionNo thumbnail or disable_content_type_detection.
sendVideochat_id -> conversationId, video -> videoUrl or an uploaded file, caption -> caption, duration -> duration, width -> width, height -> heightNo supports_streaming, thumbnail, or has_spoiler.
sendVoicechat_id -> conversationId, voice -> voiceUrl or an uploaded file, caption -> caption, duration -> duration--
sendAnimationchat_id -> conversationId, animation -> animationUrl or an uploaded file, caption -> captionNo duration, width, or height -- unlike sendVideo, these aren't tracked for animations.
sendStickerchat_id -> conversationId, sticker -> stickerIdOnly 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.
sendLocationchat_id -> conversationId, latitude -> lat, longitude -> lngTelegram'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).
sendVenuechat_id -> conversationId, latitude -> latitude, longitude -> longitude, title -> title, address -> address, foursquare_id -> foursquareIdNo foursquare_type, google_place_id, or google_place_type.
sendContactchat_id -> conversationId, phone_number -> phoneNumber, first_name -> firstName, last_name -> lastNameNo vcard.
sendDicechat_id -> conversationId, emoji -> emojiMigram adds a sixth face, 🎳 (bowling, 1-6), on top of Telegram's five (🎲🎯🏀⚽🎰).
sendPollchat_id -> conversationId, question -> question, options -> options, type -> type, is_anonymous -> isAnonymous, allows_multiple_answers -> allowsMultipleAnswers, correct_option_id -> correctOptionId, explanation -> explanationNo 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.
sendMediaGroupchat_id -> conversationId, media -> mediaEach 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.
sendChatActionchat_id -> conversationId, action -> actionNo record_video_note/upload_video_note -- Migram has no video notes.

Editing and deleting

MethodTelegram parameter -> nativeWhat's different
editMessageTextchat_id -> conversationId, message_id -> messageId, text -> textNo 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.
deleteMessagechat_id -> conversationId, message_id -> messageId--

Commands and the menu button

MethodTelegram parameter -> nativeWhat's different
setMyCommandscommands -> commands, scope -> scope, language_code -> languageCodeCompat 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.
getMyCommandsscope -> scope, language_code -> languageCodeNative'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.
setChatMenuButtonmenu_button.type -> type, menu_button.text -> text, menu_button.web_app.url -> urlTelegram 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.

Callback and inline queries

MethodTelegram parameter -> nativeWhat's different
answerCallbackQuerycallback_query_id -> callbackQueryId, text -> text, show_alert -> showAlert, url -> url, cache_time -> cacheTime--
answerInlineQueryinline_query_id -> inlineQueryId, results -> results, cache_time -> cacheTime, is_personal -> isPersonal, next_offset -> nextOffsetTelegram 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.

Updates and webhooks

MethodTelegram parameter -> nativeWhat's different
getUpdatesoffset -> offset, limit -> limit, timeout -> timeoutSame names and semantics; timeout is capped at 50 seconds either way -- see getUpdates and webhooks below.
setWebhookurl -> url, drop_pending_updates -> dropPendingUpdatesNo 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.
deleteWebhookdrop_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.

Mini Apps and getMe

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.

Numeric IDs in depth

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:

  • The first time your bot sees a given conversation, message, or user arrive through a getUpdates poll, Migram allocates that entity a numeric alias, scoped to your bot. Every response from that point on reports the number, not the string. Allocation happens on that inbound sighting specifically, not the first time you send to something -- a send-first bot that hasn't polled yet has no alias to use.
  • Sending that number back in 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.
  • You can also pass Migram's native id directly as a string, since Telegram's own field type for both 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.
  • Aliases are permanent once allocated and are never recycled onto a different entity -- if a conversation is later deleted, its alias simply stops resolving to anything (an honest 400, not a silent wrong answer).
  • Your bot has its own numeric alias too, used in the compat token and returned as 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.

Media uploads and file IDs

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).

getUpdates and webhooks

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:

  • arrives with 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;
  • carries a UUID string in update_id, not a number -- only getUpdates polling gets the numeric rewrite;
  • otherwise uses the same snake_case field names as Telegram's own webhook deliveries (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.

Unsupported methods

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:

  • Forwarding and copying -- forwardMessage, copyMessage, and their plural forwardMessages/copyMessages forms.
  • Group and channel administration -- 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.
  • Chat and member lookups -- getChat, getChatMember, getChatAdministrators, getChatMemberCount.
  • Sticker set management -- 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.
  • Media types with no Migram equivalent -- sendAudio and sendVideoNote (there's no music-player-style audio message or round video note type).
  • Payments, Telegram Passport, and games -- none of these subsystems exist on Migram, so their entire method families 404.

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.

What Migram doesn't do at all

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:

  • No MTProto. Migram bots only ever speak the plain HTTPS request/response shapes documented on this page and in the Bot API reference. There's no binary MTProto transport, and no MTProto client library will ever connect to Migram -- both calling conventions described here are ordinary HTTP.
  • No user/client API. Telegram separately exposes a client API that lets a script log in and act as an ordinary user account -- the basis for "userbot" libraries like Telethon or Pyrogram running in client mode. Migram has no equivalent: the only automatable identity is a bot account created through @botmigi, and there's no supported way to script a regular Migram user's own account.

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.

BotFather -> @botmigi

@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.

Migration checklist

  1. Create a bot with @botmigi and use the numeric token it shows first -- see Switching your code if your library validates the token client-side.
  2. Swap your base URL from api.telegram.org to api.migram.org. Nothing else about the URL or method name needs to change.
  3. Check any method you call against Parameter reference by method for parameters that don't carry over, especially caption formatting, sticker uploads, and webhook filtering.
  4. If your bot ever sends before it polls, read Numeric IDs in depth first -- a numeric 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.
  5. If you register a webhook, adapt your receiver for the three real differences in getUpdates and webhooks -- header names, a UUID update_id, and Migram's own signature scheme -- or switch that bot to polling.
  6. Confirm you're not calling anything in Unsupported methods; there's no workaround for those beyond redesigning that part of the bot.
  7. Two things on this page are known follow-ups rather than permanent limits, so watch the changelog if either blocks you today: Telegram-shaped webhook delivery payloads (see getUpdates and webhooks), and real silent-send support behind 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.