<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: charlie-morrison</title>
    <description>The latest articles on DEV Community by charlie-morrison (@charliemorrison).</description>
    <link>https://dev.to/charliemorrison</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3896832%2Fab355440-b976-4d9a-b9fe-762faf3e7836.png</url>
      <title>DEV Community: charlie-morrison</title>
      <link>https://dev.to/charliemorrison</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/charliemorrison"/>
    <language>en</language>
    <item>
      <title>Telegram editMessageText: Your Keyboard Vanishes (Tested)</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:46:10 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-editmessagetext-your-keyboard-vanishes-tested-44f2</link>
      <guid>https://dev.to/charliemorrison/telegram-editmessagetext-your-keyboard-vanishes-tested-44f2</guid>
      <description>&lt;p&gt;Most Bot API methods are interesting when they fail. Editing is the one where &lt;em&gt;success&lt;/em&gt; is the problem.&lt;/p&gt;

&lt;p&gt;A bot that edits its own messages -- a live scoreboard, a progress bar, a menu that changes as you click through it -- will at some point call &lt;code&gt;editMessageText&lt;/code&gt;, get &lt;code&gt;200 OK&lt;/code&gt;, and quietly ship a message missing something the user was relying on. Nothing in the response says so.&lt;/p&gt;

&lt;p&gt;So I measured the surface against a live throwaway bot: what an edit compares, what it replaces, which method applies to which message, the length boundaries, and what a delete does when you call it twice. Every verdict below was read back from the returned &lt;code&gt;Message&lt;/code&gt; object rather than inferred from &lt;code&gt;ok: true&lt;/code&gt;, and the finding this post is named after got an independent control run before I was willing to write it down.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A no-op edit is an error&lt;/strong&gt; , and the thing it compares is the content &lt;em&gt;and&lt;/em&gt; the reply markup as a pair -- not the text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An edit that omits&lt;code&gt;reply_markup&lt;/code&gt; deletes the inline keyboard.&lt;/strong&gt; 200 OK, buttons gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The edit method is fixed by how the message was sent&lt;/strong&gt; , not by what it currently contains.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The length limits on an edit are the send limits&lt;/strong&gt; -- 4096 for text, 1024 for a caption, and both refuse rather than truncate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;deleteMessage&lt;/code&gt; is not idempotent.&lt;/strong&gt; The second call is a 400.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r9mtqkbvcjjyr9c8hpu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5r9mtqkbvcjjyr9c8hpu.png" alt="Terminal output from a live Telegram Bot API probe showing that a repeated identical edit returns 400 message is not modified, that the same text with a new reply markup succeeds, that editMessageText with reply_markup omitted returns 200 OK with the inline keyboard removed, that editMessageText on a photo and editMessageCaption on a text message both return 400, that text is capped at 4096 and captions at 1024 characters, and that deleting the same message twice returns message to delete not found" width="800" height="583"&gt;&lt;/a&gt; Verbatim verdicts from the run. The red lines are the failures you catch in testing. The yellow line is the one that reaches production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One demo bot with nothing in production behind it, one private chat, and a standard-library probe. Each phase sends a fresh message, acts on it, and reads the resulting &lt;code&gt;Message&lt;/code&gt; object; every message created is deleted at the end, in a &lt;code&gt;finally&lt;/code&gt; block so a crash mid-run still cleans up.&lt;/p&gt;

&lt;p&gt;Transport errors are retried, so a network timeout can never be recorded as an API verdict -- a timeout logged as a measurement is worse than no measurement at all. A Telegram-level error, meaning an HTTP 400 with a JSON body, is treated as data rather than as a failure. That distinction is the whole point of the exercise: the 400s are the documented, honest half of this API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The no-op edit is an error, and it compares more than the text
&lt;/h2&gt;

&lt;p&gt;Send a message, then edit it to exactly the text it already has:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 Bad Request: message is not modified: specified new message content
and reply markup are exactly the same as a current content and reply
markup of the message
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The first half of that is widely known and widely worked around with a &lt;code&gt;try/except&lt;/code&gt; that swallows it. The second half is the part worth reading, because the error string is unusually forthcoming: it names &lt;strong&gt;content and reply markup&lt;/strong&gt; , together. That is the comparison.&lt;/p&gt;

&lt;p&gt;Which predicts something testable. If Telegram compared the text alone, then re-sending the same text with a &lt;em&gt;different&lt;/em&gt; keyboard would still be "not modified". It is not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edit&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Same text, same markup&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message is not modified&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same text, &lt;strong&gt;new&lt;/strong&gt; markup&lt;/td&gt;
&lt;td&gt;200 OK -- markup stored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changed text&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This matters for a very common bot shape: the message whose text is a fixed prompt and whose keyboard is the state -- a quiz that says "Pick one" above four options, a paginated list with a static header, a settings panel. All of those update the markup while the text stays put, and none of them will ever hit "not modified", as long as the keyboard genuinely differs.&lt;/p&gt;

&lt;p&gt;The inverse is where it bites. A bot that re-sends the &lt;em&gt;same&lt;/em&gt; keyboard on every tick -- a refresh loop, a poller that redraws unconditionally -- hits 400 on every tick where nothing changed. That is not a bug to suppress; it is the API telling you that you are making a network call to change nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The keyboard you did not mention is the keyboard you deleted
&lt;/h2&gt;

&lt;p&gt;This is the finding the post is named after, and the only one here that will not show up in your tests.&lt;/p&gt;

&lt;p&gt;Take a message that has an inline keyboard. Edit only its text, the way you would naturally write it -- chat id, message id, new text, done:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;editMessageText(chat_id=..., message_id=..., text="updated")
-&amp;gt; 200 OK
-&amp;gt; reply_markup in the returned Message: absent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The buttons are gone. The call succeeded. Nothing warned you.&lt;/p&gt;

&lt;p&gt;The mental model that produces this bug is that an edit is a patch -- you name the field you want changed and the rest is left alone. It is not. &lt;a href="https://core.telegram.org/bots/api#editmessagetext" rel="noopener noreferrer"&gt;The &lt;code&gt;editMessageText&lt;/code&gt; documentation&lt;/a&gt; lists &lt;code&gt;reply_markup&lt;/code&gt; as an ordinary optional parameter, and an omitted optional parameter reads naturally as "leave it". What actually happens is that the edit &lt;em&gt;replaces&lt;/em&gt; content and markup as a unit, and an absent keyboard is a keyboard set to nothing.&lt;/p&gt;

&lt;p&gt;I did not want to publish that on the strength of one absent field in one response, because "the response object didn't include it" is weaker evidence than it looks -- APIs omit empty fields for all sorts of reasons. So I ran a discriminator.&lt;/p&gt;

&lt;p&gt;After the markup-omitted edit, re-apply the &lt;strong&gt;same&lt;/strong&gt; keyboard the message originally had. There are only two possible outcomes, and they disagree:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If the keyboard is still on the message, re-applying it changes nothing -&amp;gt; &lt;code&gt;400 message is not modified&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the keyboard was removed, re-applying it &lt;em&gt;is&lt;/em&gt; a change -&amp;gt; &lt;code&gt;200 OK&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result: &lt;code&gt;200 OK&lt;/code&gt;. The keyboard was genuinely gone.&lt;/p&gt;

&lt;p&gt;And because a discriminator that always returns OK would prove nothing at all, the same call went to a second message that had been left untouched -- same keyboard, never edited. That one returned &lt;code&gt;400 message is not modified&lt;/code&gt;, exactly as it must if the test is measuring what I claim. Both halves agree: &lt;strong&gt;the markup-omitted edit removed the keyboard.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The practical rule:&lt;/strong&gt; if a message has an inline keyboard and you are editing its text, send the keyboard again in the same call. Not because the API is broken -- because "edit" here means "replace", and every field you leave out is a field you cleared.&lt;/p&gt;

&lt;p&gt;The production failure is quiet and delayed. The user sees the new text arrive, correctly, and the buttons they were about to press are simply gone. There is no error in your logs to correlate it with, because there was no error -- the same class of problem as an oversized inline keyboard being silently truncated instead of rejected.&lt;/p&gt;

&lt;h3&gt;
  
  
  Removing a keyboard on purpose
&lt;/h3&gt;

&lt;p&gt;The deliberate version is &lt;code&gt;editMessageReplyMarkup&lt;/code&gt; with an empty &lt;code&gt;inline_keyboard&lt;/code&gt;. In my run it returned &lt;code&gt;message is not modified&lt;/code&gt; -- because the previous phase had &lt;em&gt;already&lt;/em&gt; removed the keyboard, so an empty markup was no change at all. That error is a consequence of the finding above, not a separate rule. On a message that still has its buttons, the same call clears them and returns 200.&lt;/p&gt;

&lt;h2&gt;
  
  
  The method is fixed by how the message was sent
&lt;/h2&gt;

&lt;p&gt;A photo with a caption and a text message look similar in a chat and are not interchangeable to the API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageText&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;photo message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;there is no text in the message to edit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageCaption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;photo message&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;editMessageCaption&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;text message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;there is no caption in the message to edit&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The symmetry is the useful part. Neither method degrades into the other, and the error text tells you precisely which assumption you got wrong. Whether a message carries text or a caption was decided at send time and an edit cannot change it.&lt;/p&gt;

&lt;p&gt;Generic code is where this surfaces -- a helper handed a stored &lt;code&gt;message_id&lt;/code&gt; that does not track whether the id came from &lt;code&gt;sendMessage&lt;/code&gt; or &lt;code&gt;sendPhoto&lt;/code&gt;. If you keep message ids in a database in order to edit them later, keep the message &lt;em&gt;type&lt;/em&gt; next to the id. The API will not infer it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boundaries on an edit are the boundaries on a send
&lt;/h2&gt;

&lt;p&gt;No surprises here, which is itself worth recording -- an edit does not get a different budget:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Edit to&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Text, 4096 chars&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;4096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text, 4097 chars&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text, empty string&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message text is empty&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caption, 1024 chars&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;1024&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caption, 1025 chars&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one refuses rather than truncating, and the previous content survives -- the same all-or-nothing behaviour the 4096-character limit shows on a send. A bot that grows a message by appending, such as a running log, therefore does not degrade at the ceiling: it stops updating, at full length, until someone notices.&lt;/p&gt;

&lt;p&gt;One detail for anyone matching on error strings: the style is inconsistent. &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt; and &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt; are uppercase constants, while &lt;code&gt;message text is empty&lt;/code&gt; is lowercase prose. Both arrive in the same &lt;code&gt;description&lt;/code&gt; field. Match the substring you actually saw; do not assume a house style exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What survives an edit
&lt;/h2&gt;

&lt;p&gt;The identity of the message is stable, which is the reassuring result of the set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;message_id&lt;/code&gt; is unchanged -- a stored reference stays valid across any number of edits.&lt;/li&gt;
&lt;li&gt;The original &lt;code&gt;date&lt;/code&gt; is preserved. An edit does not re-stamp the message as new.&lt;/li&gt;
&lt;li&gt;An &lt;code&gt;edit_date&lt;/code&gt; field appears on the returned object. That is what a client uses to show the "edited" marker, and it is a reliable way for your own code to tell an edited message from a fresh one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Deleting twice is an error
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;deleteMessage&lt;/code&gt; returns &lt;code&gt;true&lt;/code&gt;, and then:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delete&lt;/td&gt;
&lt;td&gt;200 OK, &lt;code&gt;result: true&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete the same message again&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message to delete not found&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit a deleted message&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;message to edit not found&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So deletion is not idempotent, which is an awkward property for cleanup code. The realistic path to hitting it is a retry, not a double-click: your delete succeeds on Telegram's side, the response is lost to a timeout, the retry fires the same call, and the second attempt returns 400. A wrapper that treats any non-2xx as a failure will report a cleanup that &lt;em&gt;worked&lt;/em&gt; as an error -- the retry manufactures a problem out of a success.&lt;/p&gt;

&lt;p&gt;Treat &lt;code&gt;message to delete not found&lt;/code&gt; as a synonym for "already gone" in cleanup paths. The &lt;a href="https://core.telegram.org/bots/api#deletemessage" rel="noopener noreferrer"&gt;&lt;code&gt;deleteMessage&lt;/code&gt; reference&lt;/a&gt; is worth reading for its other constraints too -- the ones about message age and permissions, which is where the next section starts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not measure
&lt;/h2&gt;

&lt;p&gt;There is a widely repeated claim that a bot cannot edit or delete a message older than 48 hours. I cannot confirm or deny it from this run, and I am not going to repeat it as though I had.&lt;/p&gt;

&lt;p&gt;The reason is structural rather than an oversight: a single run cannot produce a 48-hour-old message. Reaching backwards instead -- editing and deleting very low message ids in the same chat -- returns &lt;code&gt;message to edit not found&lt;/code&gt; and &lt;code&gt;message to delete not found&lt;/code&gt;, which is the &lt;em&gt;absence&lt;/em&gt; error, not an age error. Those ids never existed there, so the test measured nothing about age.&lt;/p&gt;

&lt;p&gt;What it does establish is that "not found" means "no such message" rather than "too old to touch". If you are handling the age rule, do not key on that string -- it answers a different question. The clean measurement needs a message left in place and re-probed two days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to change in your code
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Re-send&lt;code&gt;reply_markup&lt;/code&gt; on every text edit of a message that has buttons.&lt;/strong&gt; The single highest-value line in this post. Omitting it clears them, silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store the message type alongside the message id&lt;/strong&gt; if you plan to edit later. Text and caption are not interchangeable and the API will not guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not blanket-suppress&lt;code&gt;message is not modified&lt;/code&gt;.&lt;/strong&gt; It is a signal that a redraw path is firing when nothing changed -- on a rate-limited API, that is wasted budget. It is also harmless to hit deliberately, so it makes a cheap change-detector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat "message to delete not found" as success&lt;/strong&gt; in cleanup and retry paths.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Match error substrings, not error styles.&lt;/strong&gt; Some are uppercase constants, some are prose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you work through a library rather than raw HTTP, the same rules apply underneath -- wrappers such as &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.bot.html" rel="noopener noreferrer"&gt;python-telegram-bot's &lt;code&gt;Bot&lt;/code&gt; class&lt;/a&gt; pass &lt;code&gt;reply_markup&lt;/code&gt; straight through, so an omitted argument is an omitted parameter on the wire, with exactly the effect above.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-edit-message-rules-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Why does Telegram say "message is not modified"?&lt;/p&gt;

&lt;p&gt;An edit that changes nothing is an error rather than a no-op. The error text names both halves of the comparison -- content &lt;em&gt;and&lt;/em&gt; reply markup -- and Telegram compares the pair. The same text with a different keyboard succeeds; the same text with the same keyboard is a 400.&lt;/p&gt;

&lt;p&gt;Does editMessageText remove the inline keyboard?&lt;/p&gt;

&lt;p&gt;Yes, if you omit &lt;code&gt;reply_markup&lt;/code&gt;. An edit replaces content and markup together rather than patching the text, so an absent keyboard means no keyboard. The call returns 200 OK and the buttons are gone. Send the keyboard again with the edit to keep it.&lt;/p&gt;

&lt;p&gt;Can I use editMessageText on a photo?&lt;/p&gt;

&lt;p&gt;No -- it returns &lt;code&gt;there is no text in the message to edit&lt;/code&gt;. Use &lt;code&gt;editMessageCaption&lt;/code&gt;. The mirror holds as well: &lt;code&gt;editMessageCaption&lt;/code&gt; on a plain text message is refused the same way. Which method applies is decided by how the message was sent.&lt;/p&gt;

&lt;p&gt;What is the maximum length when editing a message?&lt;/p&gt;

&lt;p&gt;The send limits, unchanged: 4096 characters of text (4097 gives &lt;code&gt;MESSAGE_TOO_LONG&lt;/code&gt;) and 1024 characters of caption (1025 gives &lt;code&gt;MEDIA_CAPTION_TOO_LONG&lt;/code&gt;). Both refuse rather than truncate, and an empty string is rejected outright.&lt;/p&gt;

&lt;p&gt;Does editing change the message_id?&lt;/p&gt;

&lt;p&gt;No. The &lt;code&gt;message_id&lt;/code&gt; and the original &lt;code&gt;date&lt;/code&gt; both survive; Telegram adds an &lt;code&gt;edit_date&lt;/code&gt; field. Stored references remain valid across any number of edits.&lt;/p&gt;

&lt;p&gt;Is deleteMessage idempotent?&lt;/p&gt;

&lt;p&gt;No. The second delete returns &lt;code&gt;message to delete not found&lt;/code&gt;. This is most often reached by a retry after a lost response, so cleanup code should treat that error as "already gone" rather than as a failure.&lt;/p&gt;

&lt;p&gt;Can a bot edit a message older than 48 hours?&lt;/p&gt;

&lt;p&gt;I did not measure it -- a single run cannot age a message two days. Editing a non-existent id returns &lt;code&gt;message to edit not found&lt;/code&gt;, which is the absence error and says nothing about age. Do not key age handling on that string.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: the inline keyboard caps that truncate instead of erroring, the command list that refuses 101 entries and silently rewrites two things, and which MarkdownV2 characters break a message and which delete it -- the last one matters here, because an edit re-parses your entities from scratch.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-edit-message-rules-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link, and the measurements are reproducible with the script above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Telegram Bot Commands: 100 Max, and Two Rewrites That Don't Tell You</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 24 Aug 2026 20:37:24 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-bot-commands-100-max-and-two-rewrites-that-dont-tell-you-2m4i</link>
      <guid>https://dev.to/charliemorrison/telegram-bot-commands-100-max-and-two-rewrites-that-dont-tell-you-2m4i</guid>
      <description>&lt;p&gt;The command menu is the one part of a Telegram bot that users see before they type anything. It is also configured through an endpoint that answers &lt;code&gt;true&lt;/code&gt; and moves on, which means the list you sent and the list your users get are two different objects that you have no particular reason to compare.&lt;/p&gt;

&lt;p&gt;So I compared them. Every case below was set with &lt;code&gt;setMyCommands&lt;/code&gt; and then read back with &lt;a href="https://core.telegram.org/bots/api#getmycommands" rel="noopener noreferrer"&gt;getMyCommands&lt;/a&gt; before anything was concluded about it, because a boolean return value is not evidence about state.&lt;/p&gt;

&lt;p&gt;Four behaviours came out of it. Two are loud, well-documented 400s. Two are &lt;code&gt;200 OK&lt;/code&gt; with a menu you did not send.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The 100-command ceiling is a wall, not a truncation.&lt;/strong&gt; 101 commands are rejected outright and your old menu survives untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The charset is enforced, not normalised.&lt;/strong&gt; &lt;code&gt;Help&lt;/code&gt; is refused. It is not lowercased for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A leading slash is silently stripped.&lt;/strong&gt; &lt;code&gt;/help&lt;/code&gt; goes in, &lt;code&gt;help&lt;/code&gt; comes out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate names are silently deduplicated&lt;/strong&gt; , and the last description wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qr8tflgnxifb20lhq3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2qr8tflgnxifb20lhq3.png" alt="Terminal output from a live Telegram Bot API probe showing 100 commands accepted and 101 rejected with BOT_COMMANDS_TOO_MUCH, uppercase and hyphenated command names refused with BOT_COMMAND_INVALID, a leading slash stripped from /help, duplicate commands deduplicated with the last description kept, and chat scopes shadowing rather than replacing the default scope" width="800" height="609"&gt;&lt;/a&gt; Verbatim verdicts from the run. The red block is what the API tells you about; the yellow block is what it does without telling you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One throwaway demo bot with nothing in production behind it, and a probe that is standard library only. Each phase sets a list, reads it back, records the pair, and clears the list before the next phase so that no phase inherits the previous one's state.&lt;/p&gt;

&lt;p&gt;That last detail matters more than it sounds. The command list is bot-wide persistent server state, not a per-request parameter. A phase that forgets to clean up does not fail -- it silently contaminates the phase after it, and you get a result that is real but is about the wrong input.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ceiling is 100, and it refuses rather than truncates
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://core.telegram.org/bots/api#setmycommands" rel="noopener noreferrer"&gt;setMyCommands documentation&lt;/a&gt; states at most 100 commands, and that is exactly right. What it does not say is what happens on 101, and there are two plausible answers with very different consequences.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Commands sent&lt;/th&gt;
&lt;th&gt;Response&lt;/th&gt;
&lt;th&gt;Commands stored&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;200 OK&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;101&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0 -- nothing changed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;0 -- nothing changed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It refuses. The whole list is rejected as a unit, and the bot keeps whatever menu it already had.&lt;/p&gt;

&lt;p&gt;This is worth dwelling on because it is the &lt;em&gt;opposite&lt;/em&gt; of how the neighbouring endpoint behaves. An inline keyboard past its limit is truncated in silence -- 4000 buttons in a row return 200 OK with 12 stored. Same API, same kind of oversized array, and the failure modes are mirror images: the keyboard gives you a success and less data than you sent, the command list gives you an error and no change at all.&lt;/p&gt;

&lt;p&gt;Refusing is by far the friendlier of the two, but it has a failure mode of its own, and it is a deployment-shaped one. A bot that generates its command list dynamically -- one entry per configured workflow, per tenant, per feature flag -- crosses 100 on a Tuesday, gets a 400 during startup, logs it at whatever level your framework picked, and carries on serving a menu that is now several releases stale. Nothing is broken. The menu is simply frozen at the last list small enough to fit, and every user sees a version of the bot that no longer exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The charset is enforced, not cleaned up
&lt;/h2&gt;

&lt;p&gt;The documented rule is lowercase English letters, digits and underscores, 1 to 32 characters. Every part of that is true and enforced, and the important word is &lt;em&gt;enforced&lt;/em&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command sent&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;help&lt;/code&gt;, &lt;code&gt;top10&lt;/code&gt;, &lt;code&gt;my_cmd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;accepted, stored verbatim&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1cmd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted -- a digit may lead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Help&lt;/code&gt;, &lt;code&gt;HELP&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;my-cmd&lt;/code&gt;, &lt;code&gt;my.cmd&lt;/code&gt;, &lt;code&gt;my cmd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cyrillic, emoji&lt;/td&gt;
&lt;td&gt;400 &lt;code&gt;BOT_COMMAND_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;""&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;400 command must be non-empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33 characters&lt;/td&gt;
&lt;td&gt;400 command length must not exceed 32&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Uppercase is the one that catches people, because Telegram itself is case-insensitive when a user &lt;em&gt;types&lt;/em&gt; a command -- sending &lt;code&gt;/HELP&lt;/code&gt; in a chat reaches a bot that registered &lt;code&gt;help&lt;/code&gt;. It is easy to generalise from that to "the API will lowercase my list for me". It will not. It rejects the entire call, which means one capital letter in one generated entry takes down the whole menu update, not just its own row.&lt;/p&gt;

&lt;p&gt;If your command names come from anything user-editable or config-editable -- a tenant name, a workflow slug, a YAML file someone hand-writes -- normalise before you send: lowercase it, replace anything outside &lt;code&gt;[a-z0-9_]&lt;/code&gt;, truncate to 32, and drop entries that end up empty. The API's answer to a bad name is to discard the good ones next to it.&lt;/p&gt;

&lt;p&gt;Non-English commands are simply not possible. The description field takes any Unicode you like, so a Ukrainian or Arabic bot can have a fully localised menu of &lt;em&gt;descriptions&lt;/em&gt; hanging off ASCII command names, and that is the only shape available.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-setmycommands-limits-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Two rewrites that happen on the way in
&lt;/h2&gt;

&lt;p&gt;Everything above announces itself. These two do not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The leading slash is stripped
&lt;/h3&gt;

&lt;p&gt;Send &lt;code&gt;{"command": "/help"}&lt;/code&gt; and the call succeeds. Read it back and the stored command is &lt;code&gt;help&lt;/code&gt;. The slash is a display convention, not part of the name, and the API quietly normalises it away.&lt;/p&gt;

&lt;p&gt;On its own this is harmless -- arguably it is the API being helpful. It stops being harmless the moment you write the obvious reconciliation check:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sent = [{"command": "/help", "description": "Show help"}]
bot.set_my_commands(sent)
live = bot.get_my_commands()
assert [c.command for c in live] == [c["command"] for c in sent]  # fails
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The assertion fails on a bot that is configured perfectly correctly. Which way you resolve that matters: strip the slash in your own comparison, rather than "fixing" it by sending the slash-prefixed form everywhere, because the difference is only ever cosmetic and the check is the thing you actually want to keep.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplicate names are deduplicated, last one wins
&lt;/h3&gt;

&lt;p&gt;This is the one I would not have gone looking for. Send three commands where two share a name:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[{"command": "same",  "description": "first"},
 {"command": "same",  "description": "second"},
 {"command": "other", "description": "third"}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The response is &lt;code&gt;200 OK&lt;/code&gt;. The stored list has &lt;strong&gt;two&lt;/strong&gt; entries: &lt;code&gt;same&lt;/code&gt; with the description &lt;em&gt;second&lt;/em&gt; , and &lt;code&gt;other&lt;/code&gt;. The first occurrence is gone, and nothing in the response mentions that the list shrank.&lt;/p&gt;

&lt;p&gt;Duplicates are not something anyone writes deliberately, which is exactly why this bites -- they arrive from merging. A base command set plus a per-tenant set, a default menu extended by a plugin, a list assembled by concatenating two config files. The merge produces a collision, the collision resolves to whichever entry was appended last, and the menu ends up describing a command differently from what the code that registered it first believed.&lt;/p&gt;

&lt;p&gt;Order is otherwise preserved exactly. Sending &lt;code&gt;zebra, alpha, middle&lt;/code&gt; stores &lt;code&gt;zebra, alpha, middle&lt;/code&gt; -- no alphabetical sort, so the sequence in the menu is the sequence in your array and it is yours to control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The description field
&lt;/h2&gt;

&lt;p&gt;Less exciting, and it behaves exactly as advertised: 1 to 256 characters, both boundaries hard.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Description length&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;400 command description must be non-empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;256&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;257&lt;/td&gt;
&lt;td&gt;400 command description length must not exceed 256&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;An empty description is a 400 rather than a shrug, which is the right choice and worth knowing if you build descriptions from a translation catalogue: a missing key that resolves to &lt;code&gt;""&lt;/code&gt; takes down the entire &lt;code&gt;setMyCommands&lt;/code&gt; call, not just its own entry. In practice 256 is far more room than the menu can display comfortably; the useful limit is closer to whatever fits on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scopes shadow, they do not replace
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://core.telegram.org/bots/api#botcommandscope" rel="noopener noreferrer"&gt;Command scopes&lt;/a&gt; let you show different menus to different chats, and the mechanism is layered rather than destructive.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Default scope&lt;/th&gt;
&lt;th&gt;Chat scope&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;set default &lt;code&gt;[global1]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;--&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;then set chat scope &lt;code&gt;[local1]&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[local1]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;then delete chat scope&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[global1]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;[]&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Writing a chat-scoped list leaves the default completely intact, and deleting the chat scope leaves the default intact too -- that chat simply falls back to it at display time. So the scoped list is an override, and removing an override is safe.&lt;/p&gt;

&lt;p&gt;One consequence to keep in mind when debugging: &lt;code&gt;getMyCommands&lt;/code&gt; with no scope argument returns the &lt;em&gt;default&lt;/em&gt; scope, not "the commands this user sees". If a tester reports the wrong menu, querying the bare endpoint will happily show you a correct-looking list while the chat-scoped override that is actually being displayed sits somewhere you did not ask about. Pass the same scope you are debugging.&lt;/p&gt;

&lt;p&gt;For teardown, &lt;code&gt;deleteMyCommands&lt;/code&gt; and &lt;code&gt;setMyCommands&lt;/code&gt; with an empty array are equivalent -- both leave &lt;code&gt;getMyCommands&lt;/code&gt; returning &lt;code&gt;[]&lt;/code&gt;. Use whichever reads better; there is no hidden difference between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do with this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Normalise command names before sending.&lt;/strong&gt; Lowercase, strip to &lt;code&gt;[a-z0-9_]&lt;/code&gt;, cut to 32 characters, drop empties. One bad name rejects the whole list, so this is cheap insurance on any dynamically built menu.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deduplicate your list yourself, and decide which one wins.&lt;/strong&gt; The API's answer is "the last one", chosen for you and applied without comment. If you merge command sets from more than one source, collapse collisions where you can still see them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat a failed&lt;code&gt;setMyCommands&lt;/code&gt; as a real error at startup.&lt;/strong&gt; The call failing does not degrade the bot in any visible way -- it just freezes the menu. That makes it precisely the kind of failure that gets logged at &lt;code&gt;warning&lt;/code&gt; and lives for months.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the list back once in your test suite.&lt;/strong&gt; Compare with the slash stripped, and assert on the length too. Both silent rewrites on this page show up instantly in a round-trip and never show up in a return value.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last habit is the one that keeps paying out across this whole API. Most libraries -- &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.bot.html#telegram.Bot.set_my_commands" rel="noopener noreferrer"&gt;python-telegram-bot&lt;/a&gt; among them -- return the API's boolean straight through, so &lt;code&gt;True&lt;/code&gt; is the most any wrapper can honestly give you. It means the request was accepted. Whether the menu now matches the array you built is a separate question, and there is exactly one way to answer it.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How many commands can a Telegram bot have?&lt;/p&gt;

&lt;p&gt;One hundred. 101 returns &lt;code&gt;BOT_COMMANDS_TOO_MUCH&lt;/code&gt; and the whole list is rejected, so the previous menu stays live. There is no truncation to 100 -- it is all or nothing.&lt;/p&gt;

&lt;p&gt;Why does setMyCommands return BOT_COMMAND_INVALID?&lt;/p&gt;

&lt;p&gt;A command name contains something outside lowercase ASCII letters, digits and underscores. Uppercase is the usual culprit and is refused rather than lowercased; hyphens, dots, spaces, Cyrillic and emoji fail the same way. A digit may lead, so &lt;code&gt;1cmd&lt;/code&gt; is fine.&lt;/p&gt;

&lt;p&gt;Should I include the slash in setMyCommands?&lt;/p&gt;

&lt;p&gt;You can -- &lt;code&gt;/help&lt;/code&gt; is accepted and stored as &lt;code&gt;help&lt;/code&gt;. The slash is stripped silently, so any code comparing sent against stored needs to strip it too or it will report a mismatch on a correctly configured bot.&lt;/p&gt;

&lt;p&gt;What happens if two commands share a name?&lt;/p&gt;

&lt;p&gt;The call succeeds and the list is deduplicated without warning. The description from the &lt;em&gt;last&lt;/em&gt; occurrence survives. Watch for this when merging command sets from multiple sources.&lt;/p&gt;

&lt;p&gt;How long can a command and its description be?&lt;/p&gt;

&lt;p&gt;Command 1-32 characters, description 1-256. All four boundaries return a clear 400 when crossed, and empty values for either field are refused.&lt;/p&gt;

&lt;p&gt;Does a chat-scoped command list delete the global one?&lt;/p&gt;

&lt;p&gt;No. Scopes shadow rather than replace: the default scope keeps its own list, and deleting a chat scope leaves the default untouched so that chat falls back to it.&lt;/p&gt;

&lt;p&gt;Can Telegram bot commands be non-English?&lt;/p&gt;

&lt;p&gt;The names cannot -- they are ASCII only. Descriptions accept any Unicode, so a localised menu means English command names with translated descriptions.&lt;/p&gt;

&lt;p&gt;Is deleteMyCommands different from sending an empty array?&lt;/p&gt;

&lt;p&gt;No. Both leave &lt;code&gt;getMyCommands&lt;/code&gt; returning an empty list, for the same scope.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: the inline keyboard caps that truncate instead of erroring -- the mirror image of this endpoint -- plus which MarkdownV2 characters break a message and which delete it and which errors arrive before Telegram checks your chat exists.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-setmycommands-limits-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link, and the measurements are reproducible with the script above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram MarkdownV2: 14 of 18 Reserved Characters Error, 4 Delete Themselves</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 19 Aug 2026 20:21:25 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-markdownv2-14-of-18-reserved-characters-error-4-delete-themselves-3i3o</link>
      <guid>https://dev.to/charliemorrison/telegram-markdownv2-14-of-18-reserved-characters-error-4-delete-themselves-3i3o</guid>
      <description>&lt;p&gt;The Bot API documentation has one sentence about MarkdownV2 that every bot developer has read and nobody has tested: eighteen characters "must be escaped with the preceding character &lt;code&gt;\&lt;/code&gt;". It lists them, it moves on, and it treats all eighteen as the same kind of problem.&lt;/p&gt;

&lt;p&gt;They are not the same kind of problem. I sent 126 live &lt;code&gt;sendMessage&lt;/code&gt; calls at the API to find out where the differences are, and the split that came back is the one that actually decides whether a bug reaches your users:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fourteen of the eighteen fail with a 400.&lt;/strong&gt; Loud, logged, impossible to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Four of them are accepted&lt;/strong&gt; when the string contains two of the same character, and both characters are silently removed from the message that gets delivered.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A 400 is a bad afternoon. A message that sends successfully with pieces missing is a bug that survives your tests, your staging bot and your logs, and shows up as a support ticket six weeks later about a user whose name renders wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj986ne3ajl2e33l9ncuk.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj986ne3ajl2e33l9ncuk.png" alt="Terminal output from a live Telegram Bot API probe: all 18 reserved characters return 400 when unpaired; when paired, 14 still error while underscore, asterisk, tilde and backtick are accepted and vanish from the rendered text" width="800" height="551"&gt;&lt;/a&gt; Verbatim output from the probe. One occurrence of a reserved character always fails; two occurrences fail for fourteen of them and disappear for four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup, and why it needed almost no chat
&lt;/h2&gt;

&lt;p&gt;Two days ago, while measuring inline keyboard limits, I found that the Bot API answers structural errors before it checks whether your chat exists. That turned out to apply to parse errors too, and it makes this kind of probing much cheaper than it sounds.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ sendMessage chat_id=1 parse_mode=MarkdownV2 text="a.b"
   Bad Request: can't parse entities: Character '.' is reserved
                and must be escaped with the preceding character '\'

$ sendMessage chat_id=1 parse_mode=MarkdownV2 text="a\.b"
   Bad Request: chat not found

$ sendMessage chat_id=1                      text="a.b"
   Bad Request: chat not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Chat &lt;code&gt;1&lt;/code&gt; does not exist. Telegram parses the text first anyway, and only reaches for the chat once the markup is valid. So the second and third lines, both "chat not found", are the control: they prove the request got past the parser. Any string you want to check for parse validity can be checked with a bot token and no conversation at all.&lt;/p&gt;

&lt;p&gt;The part I &lt;em&gt;did&lt;/em&gt; need a real chat for is the interesting part. When a message is accepted, the API response contains the message object, and &lt;code&gt;result.text&lt;/code&gt; holds the visible text with all formatting stripped out. Comparing that string to the string I meant to send is what exposes the silent cases. I used my own account as the target and deleted every accepted message immediately after reading it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  One occurrence: the docs are exactly right
&lt;/h2&gt;

&lt;p&gt;First pass, one character at a time, in the middle of otherwise boring text: &lt;code&gt;a_b&lt;/code&gt;, &lt;code&gt;a*b&lt;/code&gt;, &lt;code&gt;a.b&lt;/code&gt;, and so on through all eighteen.&lt;/p&gt;

&lt;p&gt;All eighteen returned a 400. No exceptions, no partial credit. If you have been carrying a vague suspicion that half of that list is defensive over-documentation, drop it: &lt;code&gt;_ * [ ] ( ) ~ ` &amp;gt; # + - = | { } . !&lt;/code&gt; is a complete and accurate list of what breaks a MarkdownV2 message on its own.&lt;/p&gt;

&lt;p&gt;Most of them come back with the same generic line, naming the character. Two do not, and the difference is a small gift when you are debugging:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;a]b   Character ']' is reserved and must be escaped with the preceding character '\'
a[b   Can't find end of TextUrl entity at byte offset 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;An opening bracket does not report itself as reserved, because to the parser it is not junk. It is the start of a link that never got finished. The error names the entity it was building and gives you the byte offset where it started. That is the fastest debugging signal in the whole format, and it only appears for characters that open something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two occurrences: fourteen still fail, four disappear
&lt;/h2&gt;

&lt;p&gt;The single-character test is the test everyone runs, and it is the test that hides the problem. Real strings do not contain one underscore. Usernames, filenames, package versions and search queries contain two, three, five.&lt;/p&gt;

&lt;p&gt;Second pass, same eighteen characters, this time paired: &lt;code&gt;a_b_c&lt;/code&gt;, &lt;code&gt;a*b*c&lt;/code&gt;, &lt;code&gt;a.b.c&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;Fourteen of them behave exactly as they did alone, returning a 400 whether they appear once or twice: &lt;code&gt;[&lt;/code&gt; &lt;code&gt;]&lt;/code&gt; &lt;code&gt;(&lt;/code&gt; &lt;code&gt;)&lt;/code&gt; &lt;code&gt;&amp;gt;&lt;/code&gt; &lt;code&gt;#&lt;/code&gt; &lt;code&gt;+&lt;/code&gt; &lt;code&gt;-&lt;/code&gt; &lt;code&gt;=&lt;/code&gt; &lt;code&gt;|&lt;/code&gt; &lt;code&gt;{&lt;/code&gt; &lt;code&gt;}&lt;/code&gt; &lt;code&gt;.&lt;/code&gt; &lt;code&gt;!&lt;/code&gt;. The other four do not, and they are the finding:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Character&lt;/th&gt;
&lt;th&gt;Paired and unescaped&lt;/th&gt;
&lt;th&gt;What arrives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;_&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- italic, underscores gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- bold, asterisks gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;~&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- strikethrough, tildes gone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;`&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;abc&lt;/code&gt; -- code, backticks gone&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four are not a random subset. They are precisely the delimiters that have a &lt;em&gt;closing&lt;/em&gt; form: italic, bold, strikethrough and inline code all open and close with the same character. Two of them in a row is not a mistake to the parser, it is a complete, well-formed entity. The API has nothing to complain about, so it does not complain -- it does what the markup says, consumes both delimiters, and delivers the remainder.&lt;/p&gt;

&lt;p&gt;Everything else on the list is either structural (brackets and parentheses build links, and an unfinished link is an error) or purely reserved, a character with no entity behind it at all, which can only ever be a mistake, which is why the parser can reject it with confidence.&lt;/p&gt;

&lt;p&gt;So the rule underneath the docs' flat list is this: &lt;strong&gt;a reserved character that can close itself will be obeyed rather than reported.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually bites
&lt;/h2&gt;

&lt;p&gt;Nowhere in your own copy. You control your own strings, and you notice a missing asterisk in a template on the first run.&lt;/p&gt;

&lt;p&gt;It bites at the join between your template and somebody else's data. A few real shapes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;anna_marie_k&lt;/code&gt;, a Telegram username with two underscores. Delivered as &lt;code&gt;annamariek&lt;/code&gt; in italics. Now your bot has told a user their handle wrong, and if it also stored what it displayed, wrong in your database.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;report_2026_final.pdf&lt;/code&gt; -- a filename echoed back in a confirmation. Two underscores, both eaten.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;**important**&lt;/code&gt; -- a user who pasted Markdown into your bot. Renders bold, both pairs gone.&lt;/li&gt;
&lt;li&gt;Search results, product titles, error strings pulled from another service -- anything you did not write yourself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode is asymmetric in the worst direction. The fourteen loud characters are far more common in ordinary text than the four quiet ones (a full stop appears in almost every sentence), so the loud ones are what you hit first in development. You will fix it, conclude that unescaped text throws errors, and ship. The quiet four are rare enough to survive to production and common enough to get there eventually.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration trap: legacy Markdown to MarkdownV2
&lt;/h2&gt;

&lt;p&gt;I ran the same eighteen characters through the deprecated &lt;code&gt;Markdown&lt;/code&gt; parse mode for comparison, and the gap is bigger than "V2 is stricter" suggests.&lt;/p&gt;

&lt;p&gt;Legacy Markdown rejected exactly four: &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;`&lt;/code&gt;. The other fourteen, including the full stop, the hyphen, the exclamation mark and the plus sign, passed straight through and rendered literally.&lt;/p&gt;

&lt;p&gt;That is what makes the upgrade nastier than a version bump. Code that ran clean for years on legacy Markdown has fourteen new fatal characters the moment you change the string &lt;code&gt;Markdown&lt;/code&gt; to &lt;code&gt;MarkdownV2&lt;/code&gt;, and they are the &lt;em&gt;ordinary&lt;/em&gt; ones. The first message with a date in it starts failing. Telegram's own &lt;a href="https://core.telegram.org/bots/api#formatting-options" rel="noopener noreferrer"&gt;formatting options reference&lt;/a&gt; flags V1 as deprecated, but the practical cost of moving is not in the docs: it is a bulk audit of every string you have ever passed to &lt;code&gt;sendMessage&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside code spans and links, the rules genuinely are narrower
&lt;/h2&gt;

&lt;p&gt;The docs claim that inside &lt;code&gt;code&lt;/code&gt; and &lt;code&gt;pre&lt;/code&gt; blocks only the backtick and the backslash need escaping, and that inside a link's &lt;code&gt;(...)&lt;/code&gt; only the closing parenthesis and the backslash do. Both claims held on every character I tested:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code span and pre block.&lt;/strong&gt; Seventeen of the eighteen were accepted and rendered verbatim. Only the backtick failed, which it must, since it terminates the span.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link URL.&lt;/strong&gt; Seventeen accepted, only &lt;code&gt;)&lt;/code&gt; failed. Full stops and hyphens inside a URL need nothing, which is a relief given what URLs look like.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One thing the docs mention that is worth seeing rather than reading: the backslash inside a code span is consumed, not displayed. &lt;code&gt;`a\b`&lt;/code&gt; arrives as &lt;code&gt;ab&lt;/code&gt;. If you are formatting Windows paths or regular expressions inside code spans, that is the fifth character on the silent list.&lt;/p&gt;

&lt;p&gt;A smaller detail, offered because it contradicted my own guess: a lone trailing backslash, &lt;code&gt;ab\&lt;/code&gt;, is not treated as an incomplete escape sequence. It is accepted and rendered literally as &lt;code&gt;ab\&lt;/code&gt;. I had assumed an error, wrote the assumption into the test as the expected value, and the run told me I was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  HTML mode has one fatal character instead of eighteen
&lt;/h2&gt;

&lt;p&gt;Telegram supports three parse modes, and the comparison is not close for untrusted input.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;parse_mode=HTML   "if a &amp;lt; b then"      400  Unsupported start tag ""
parse_mode=HTML   "use &amp;lt;b&amp;gt; for bold"   400  Can't find end tag corresponding to...
parse_mode=HTML   "Tom &amp;amp; Jerry"        ACCEPTED  -&amp;gt;  "Tom &amp;amp; Jerry"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Only the less-than sign is genuinely fatal. The ampersand, which every HTML escaping guide treats as mandatory, was accepted and rendered literally because Telegram's parser only cares when it begins a recognised entity. Escaping it is still correct, since &lt;code&gt;&amp;amp;lt;&lt;/code&gt; in user text would otherwise decode into a literal &lt;code&gt;&amp;lt;&lt;/code&gt;, but the blast radius is one character wide instead of eighteen.&lt;/p&gt;

&lt;p&gt;And HTML mode has the advantage that matters more than the character count: your language already ships the escaper. Python's &lt;code&gt;html.escape&lt;/code&gt;, Go's &lt;code&gt;html.EscapeString&lt;/code&gt;, Java's &lt;code&gt;StringEscapeUtils&lt;/code&gt;. All of them are older than your bot and none of them have a bug in them. For MarkdownV2 you are writing that function yourself, or trusting a helper such as &lt;a href="https://docs.python-telegram-bot.org/en/stable/telegram.helpers.html" rel="noopener noreferrer"&gt;python-telegram-bot's &lt;code&gt;escape_markdown&lt;/code&gt;&lt;/a&gt;, which takes a &lt;code&gt;version=2&lt;/code&gt; argument that is easy to forget and defaults to the legacy list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Three rules, in the order they save time:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never interpolate untrusted text into a parse mode.&lt;/strong&gt; If the string came from a user, an API or a filesystem, it goes out with no &lt;code&gt;parse_mode&lt;/code&gt; at all, or escaped by a function. Never by hand, never "it's just a name".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer HTML for anything with variables in it.&lt;/strong&gt; One fatal character, and a standard-library escaper you did not write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you need MarkdownV2, escape all eighteen, always.&lt;/strong&gt; Not the ones you think will appear. The failure of the selective approach is invisible, which is precisely why it is not worth the saved keystrokes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole escaper is one line, and the test that proves it is one call:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`` RESERVED = r"_*[]()~`&amp;gt;#+-=|{}.!"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def esc(s):&lt;br&gt;
    return "".join("\" + c if c in RESERVED else c for c in s)&lt;/p&gt;

&lt;h1&gt;
  
  
  sent:      Anna_Marie (dev) v2.0-beta! 50% off #1
&lt;/h1&gt;

&lt;h1&gt;
  
  
  raw:       400  Character '(' is reserved and must be escaped
&lt;/h1&gt;

&lt;h1&gt;
  
  
  escaped:   ACCEPTED  -&amp;gt;  Anna_Marie (dev) v2.0-beta! 50% off #1 ``
&lt;/h1&gt;

&lt;p&gt;That test string has five of the loud characters and one of the quiet ones. Raw, it fails on the first parenthesis and never reaches the underscore. Escaped, every character survives. Verify by reading &lt;code&gt;result.text&lt;/code&gt; back from the response rather than by eyeballing the chat. The response is the only witness that tells you what was actually stored, and it is the same discipline that catches the four silent characters in the first place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you run this yourself, use a chat you own.&lt;/strong&gt; Every accepted case is a delivered message, and there were 126 of them. I sent them to my own account and deleted each one after reading the response. Keep the pace down as well: the burst allowance on a single chat is around a hundred operations before the rate limiter pushes back, and deletes spend from the same budget as sends.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production
&lt;/h3&gt;

&lt;p&gt;The measured limits, the failure modes and the boilerplate that survives them: escaping, rate limits, update queues and webhook handling, in one pack.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-markdownv2-escaping-tested" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The probe
&lt;/h2&gt;

&lt;p&gt;The script is stdlib-only Python and takes a bot token and a chat ID. It runs the eighteen characters through seven contexts, then the paired pass, then the backslash cases, and prints the roll-up you see in the screenshot. Point it at a demo bot, not a production one.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;`` python3 tg-markdownv2-escape-probe.py --chat &amp;lt;your own chat id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;UNESCAPED in plain MarkdownV2:&lt;br&gt;
  hard 400 error  : 18   _&lt;em&gt;&lt;a href=""&gt;&lt;/a&gt;~`&amp;gt;#+-=|{}.!&lt;br&gt;
  silently mangled:  0&lt;br&gt;
PAIRED and unescaped in plain MarkdownV2:&lt;br&gt;
  hard 400 error  : 14   &lt;a href=""&gt;&lt;/a&gt;&amp;gt;#+-=|{}.!&lt;br&gt;
  silently eaten  :  4   _&lt;/em&gt;~&lt;code&gt;&lt;/code&gt;`&lt;/p&gt;

&lt;p&gt;Two numbers, one conclusion. Every reserved character will stop a message. Four of them will let it through with holes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Which characters must be escaped in Telegram MarkdownV2?&lt;/p&gt;

&lt;p&gt;All eighteen listed in the &lt;a href="https://core.telegram.org/bots/api#markdownv2-style" rel="noopener noreferrer"&gt;Bot API docs&lt;/a&gt;: &lt;code&gt;_ * [ ] ( ) ~ ` &amp;gt; # + - = | { } . !&lt;/code&gt;. Tested one at a time, every single one returns a 400 when it appears unescaped in plain text. The documentation is accurate here.&lt;/p&gt;

&lt;p&gt;Why does my Telegram message lose characters instead of returning an error?&lt;/p&gt;

&lt;p&gt;Because four of the reserved characters open an entity that a second occurrence closes: underscore, asterisk, tilde and backtick. Two of the same character is valid markup, so the API accepts it and consumes both delimiters as formatting instead of delivering them as text.&lt;/p&gt;

&lt;p&gt;Is HTML parse mode safer than MarkdownV2?&lt;/p&gt;

&lt;p&gt;For untrusted text, yes. HTML mode had one fatal character in testing, the less-than sign, and an unescaped ampersand was accepted and rendered literally. MarkdownV2 has eighteen reserved characters, four of which fail silently. HTML also maps onto the escaper your standard library already ships.&lt;/p&gt;

&lt;p&gt;What breaks when I migrate from Markdown to MarkdownV2?&lt;/p&gt;

&lt;p&gt;Fourteen characters that were harmless become fatal. Legacy Markdown rejected only &lt;code&gt;_&lt;/code&gt;, &lt;code&gt;*&lt;/code&gt;, &lt;code&gt;[&lt;/code&gt; and &lt;code&gt;`&lt;/code&gt; in the same test; the other fourteen passed through. Those fourteen -- full stops, hyphens, exclamation marks -- are what starts returning 400 after the switch.&lt;/p&gt;

&lt;p&gt;Can I test parse errors without a real chat?&lt;/p&gt;

&lt;p&gt;Yes. Parse errors come back before Telegram resolves the chat, so sending to a chat ID that does not exist still returns the real parse error. A valid string sent to the same bogus chat returns "chat not found" instead, which is how you tell the two layers apart.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: where the Bot API validates an inline keyboard, what the 4096-character limit counts, and what happens to an update queue after you switch the bot off.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-markdownv2-escaping-tested/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a paid pack I sell, so I earn from it directly if you buy — nothing you read here is behind that link, and the measurements are reproducible with the script above.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Half of Telegram's Bot API Errors Arrive Before It Checks Your Chat Exists</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 18 Aug 2026 19:57:18 +0000</pubDate>
      <link>https://dev.to/charliemorrison/half-of-telegrams-bot-api-errors-arrive-before-it-checks-your-chat-exists-1l0h</link>
      <guid>https://dev.to/charliemorrison/half-of-telegrams-bot-api-errors-arrive-before-it-checks-your-chat-exists-1l0h</guid>
      <description>&lt;p&gt;I found this by making a mistake. I was probing inline keyboard limits against the Bot API and I did not want to spam a real conversation while doing it, so I sent the first test to &lt;code&gt;chat_id=1&lt;/code&gt;, a chat that does not exist, expecting Telegram to reject everything with the same useless line and force me to find a real chat.&lt;/p&gt;

&lt;p&gt;That is what happened, for most of the cases. But two of them came back with a real answer:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;button with no callback_data  -&amp;gt;  Bad Request: can't parse InlineKeyboardButton:
                                  Text buttons are unallowed in the inline keyboard
text=""                       -&amp;gt;  Bad Request: message text is empty
callback_data 65 bytes        -&amp;gt;  Bad Request: chat not found
1000 buttons in one row       -&amp;gt;  Bad Request: chat not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Telegram had told me my keyboard was malformed without ever resolving the chat I claimed to be sending it to. So there are two validation layers in there, they run in a fixed order, and the boundary between them is worth knowing if you write bots, because one of those layers is testable from a machine with no chat and no test user.&lt;/p&gt;

&lt;p&gt;I spent the evening mapping the boundary. Here is what the API actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;One throwaway bot (&lt;code&gt;@cm_demo_order_bot&lt;/code&gt;, a demo, nothing in production), eight &lt;code&gt;sendMessage&lt;/code&gt; calls, sent twice: once to &lt;code&gt;chat_id=1&lt;/code&gt;, once to a real private chat. Anything that landed in the real chat was deleted straight after, so the comparison is clean. Then a bisection pass to find the exact numbers where the API flips from accept to reject.&lt;/p&gt;

&lt;p&gt;The probe is about ninety lines and does not depend on anything beyond the standard library. Every verdict quoted below came out of one run of it on 17 August 2026.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9579kfz1zbke6iawdnka.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9579kfz1zbke6iawdnka.png" alt="Terminal output showing which Telegram Bot API errors return before the chat is resolved and which return after" width="800" height="603"&gt;&lt;/a&gt; The same eight cases, sent to a nonexistent chat and then to a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer one: parse errors, no chat required
&lt;/h2&gt;

&lt;p&gt;Two of the eight cases answered before Telegram looked for the chat.&lt;/p&gt;

&lt;p&gt;A button carrying neither &lt;code&gt;callback_data&lt;/code&gt; nor &lt;code&gt;url&lt;/code&gt; returns &lt;em&gt;"can't parse InlineKeyboardButton: Text buttons are unallowed in the inline keyboard"&lt;/em&gt;. An empty &lt;code&gt;text&lt;/code&gt; returns &lt;em&gt;"message text is empty"&lt;/em&gt;. Both arrive against a chat ID that has never existed.&lt;/p&gt;

&lt;p&gt;What these two have in common is that neither requires Telegram to know anything about the recipient. The request is structurally wrong on its face. A button with no action is not a button, an empty message is not a message, and the server can say so while parsing the request body.&lt;/p&gt;

&lt;p&gt;That makes them cheap to assert against in a test suite. If your bot builds keyboards from data, and you want to know that the builder never emits a button without an action, you can ask Telegram itself instead of reimplementing its rules in a validator that drifts out of date. You need a bot token. You do not need a chat, a test user, or anyone's consent to receive junk messages.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer two: limits, chat first
&lt;/h2&gt;

&lt;p&gt;The other six cases were invisible from &lt;code&gt;chat_id=1&lt;/code&gt;. Oversized &lt;code&gt;callback_data&lt;/code&gt;, a thousand buttons in one row, an empty button label, a message one character over the documented cap: all six came back as &lt;em&gt;"chat not found"&lt;/em&gt; , which tells you nothing about the payload.&lt;/p&gt;

&lt;p&gt;Send the identical six to a real chat and they separate immediately:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;case&lt;/th&gt;
&lt;th&gt;real chat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;callback_data&lt;/code&gt; 64 bytes&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;callback_data&lt;/code&gt; 65 bytes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1000 buttons in one row&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2000 rows&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;button label &lt;code&gt;""&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;inline_keyboard: []&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;text 4097 characters&lt;/td&gt;
&lt;td&gt;&lt;em&gt;message is too long&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the order is: parse the request, resolve the chat, then check sizes. If you were hoping to validate payload limits without touching a conversation, you cannot. That half needs a real chat, and if you are testing at any volume it needs to be a chat you own, because every accepted case is a message that gets delivered before you delete it.&lt;/p&gt;

&lt;p&gt;There is a second tell in that table worth pointing at. &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt; is shouted in the uppercase constant style of the underlying MTProto layer, while the parse-layer errors are written as English sentences. Two error vocabularies, arriving in a fixed order, from what looks like two different pieces of code. The behaviour I measured is consistent with the shape of the error strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one size limit the server actually enforces
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;callback_data&lt;/code&gt; is documented as 1 to 64 bytes. I bisected it rather than trusting the number: 64 accepted, 65 rejected, and the rejection is that same &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;. The documentation is exactly right, which is worth saying plainly because the next paragraph is about a number that is not in the documentation at all and is repeated everywhere anyway.&lt;/p&gt;

&lt;p&gt;I then went looking for the ceiling on keyboard size and did not find one.&lt;/p&gt;

&lt;p&gt;Thirty buttons in a row: accepted. A hundred: accepted. A thousand buttons in a single row: accepted. A hundred rows: accepted. Five hundred: accepted. Two thousand rows of one button each: accepted. An &lt;code&gt;inline_keyboard&lt;/code&gt; of &lt;code&gt;[]&lt;/code&gt;, a keyboard with no buttons in it at all: accepted, and the message arrives with no markup attached.&lt;/p&gt;

&lt;p&gt;If you have written Telegram bots you have probably read that the limit is eight buttons per row, or ten, or that you must keep the whole keyboard under some count. I have read it too. As an API rule it is not true. The server took every keyboard I built up to two thousand by one thousand without complaint.&lt;/p&gt;

&lt;p&gt;The reason the folklore exists is that those keyboards are unusable, not invalid. Telegram's clients wrap and squeeze buttons to fit the width they have, so a row of twelve renders as a wall of unreadable stubs and a row of a thousand renders as something you scroll past forever. Eight per row is good advice about how phones display things. It has been repeated for long enough to become a rule people believe the API is enforcing, and it is not enforcing it.&lt;/p&gt;

&lt;p&gt;That distinction matters when you are debugging. If your keyboard is not appearing, the API is not silently rejecting it for being too big. Look somewhere else: a parse error you are not logging, a &lt;code&gt;chat not found&lt;/code&gt; you are treating as a network blip, or a client that is rendering exactly what you asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The empty label is the one I would guard against
&lt;/h2&gt;

&lt;p&gt;Of everything in the results, the case I would actually put a check on before shipping is the button with an empty &lt;code&gt;text&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Telegram accepts it. The message goes out. What arrives is a button that renders as a thin strip of nothing, still tappable, still firing its &lt;code&gt;callback_data&lt;/code&gt; at your bot when someone hits it. Nothing in the response tells you anything is wrong, because from the API's point of view nothing is.&lt;/p&gt;

&lt;p&gt;That is the shape of bug that gets past tests. A label built from user data, or a translation lookup that misses, or a truncation that trims to zero, and your keyboard ships with a hole in it that no error message will ever mention. It is exactly the class of thing I wrote about when a bot's update queue kept collecting messages months after I turned the service off: the API doing precisely what it was told, quietly, while the operator assumes silence means nothing happened.&lt;/p&gt;

&lt;p&gt;Same for the empty keyboard. &lt;code&gt;inline_keyboard: []&lt;/code&gt; is accepted and produces a message with no buttons, so a builder that returns an empty list on a bad branch will never raise. It will just ship a message that does nothing, forever, until a human notices.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The five places a bot passes your tests and fails in front of real users: initData validation, poll payloads Telegram silently rewrites, systemd units that start and quietly do nothing, file-size limits, and the rate limit that stalls a round. Dependency-free Python and Node, 55 tests you can run from the zip, plus a 26-point pre-ship checklist.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-validates-before-chat-exists" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do with this
&lt;/h2&gt;

&lt;p&gt;Three practical things came out of the evening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Assert the parse layer in CI, cheaply.&lt;/strong&gt; Structural mistakes in keyboard construction can be checked against the live API with a token and no chat. It is a real network call, so it is not a unit test, but it is a very cheap contract test against the only authority that matters, and it will not drift when Telegram changes its mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never test limits against a chat you do not own.&lt;/strong&gt; Half the failure modes only surface after the chat resolves, and every passing case is a delivered message. Use your own account or a throwaway. Also keep the pace down: I measured the burst allowance on a single chat at roughly a hundred operations before the rate limiter starts pushing back, and edits spend from the same allowance as sends.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate button labels yourself, because Telegram will not.&lt;/strong&gt; An empty label and an empty keyboard are both legal. If either is reachable from your code, the check has to live in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits of this measurement
&lt;/h2&gt;

&lt;p&gt;One bot, one evening, one account, the public Bot API. I did not test a local Bot API server, which is documented to relax several limits and could plausibly differ here too. I did not find the ceiling on keyboard size, I found that it is above two thousand rows and above a thousand buttons per row, which is far past anything a sane interface would build. And behaviour like validation order is an implementation detail, not a documented contract. It could change on any Tuesday, without a changelog entry, precisely because it was never promised.&lt;/p&gt;

&lt;p&gt;That is why the probe is a script and not a paragraph of notes. When something here stops being true, re-running it takes a minute and the answer comes from Telegram rather than from this post.&lt;/p&gt;

&lt;p&gt;The documented parts are documented well, for what it is worth. The &lt;a href="https://core.telegram.org/bots/api#inlinekeyboardbutton" rel="noopener noreferrer"&gt;InlineKeyboardButton reference&lt;/a&gt; states the 64-byte &lt;code&gt;callback_data&lt;/code&gt; bound and the rule that a button must carry exactly one optional action field, and both of those held exactly as written. The &lt;a href="https://core.telegram.org/bots/api#sendmessage" rel="noopener noreferrer"&gt;sendMessage reference&lt;/a&gt; gives the 4096-character text cap, and 4097 characters is where it broke. Everything in this post that surprised me lives in the space the documentation does not describe: the order the checks run in, and how much the server will accept when nobody wrote a number down.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;What is the maximum number of buttons per row in a Telegram inline keyboard?&lt;/p&gt;

&lt;p&gt;The Bot API does not enforce one. A row of 1000 buttons was accepted in testing, as were 2000 rows. The widely repeated limit of 8 buttons per row is a rendering convention for narrow screens, not a server rule.&lt;/p&gt;

&lt;p&gt;What is the callback_data size limit?&lt;/p&gt;

&lt;p&gt;64 bytes, and it is enforced exactly. Bisecting the boundary live, 64 bytes was accepted and 65 bytes was rejected with &lt;code&gt;BUTTON_DATA_INVALID&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Why does Telegram return "chat not found" instead of telling me my keyboard is wrong?&lt;/p&gt;

&lt;p&gt;Because size and length checks run after the chat is resolved. Only structural parse errors, such as a button with no action or an empty message text, are returned before Telegram looks for the chat.&lt;/p&gt;

&lt;p&gt;Can I validate a Telegram inline keyboard without sending a message to anyone?&lt;/p&gt;

&lt;p&gt;Partly. Structural errors can be checked by sending to a chat ID that does not exist, using a bot token and no chat. Size limits cannot, because those checks only run after the chat resolves.&lt;/p&gt;

&lt;p&gt;Does Telegram accept a button with an empty label?&lt;/p&gt;

&lt;p&gt;Yes. An empty button text is accepted and delivered. It renders as a thin strip and still fires its &lt;code&gt;callback_data&lt;/code&gt; when tapped, so this check has to live in your own code.&lt;/p&gt;

&lt;p&gt;More measurements from the same bot: what eighteen game bots actually reply, and what an "anonymous" bot operator receives when you press Start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-bot-api-validates-before-chat-exists/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram's 50 MB Bot Upload Limit: I Measured Every Boundary</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 11 Aug 2026 20:21:54 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegrams-50-mb-bot-upload-limit-i-measured-every-boundary-4266</link>
      <guid>https://dev.to/charliemorrison/telegrams-50-mb-bot-upload-limit-i-measured-every-boundary-4266</guid>
      <description>&lt;p&gt;The first time a Telegram bot of mine hit a file limit, the symptom made no sense. The bot could send a 40 MB video to a user without complaint. When the same user forwarded a 25 MB video back, the bot could see it, log its size, and read its &lt;code&gt;file_id&lt;/code&gt; -- and then could not download it. Not slowly. Not with a retry. At all, permanently.&lt;/p&gt;

&lt;p&gt;That is not a bug in anyone's code. It is the shape of the Bot API: the number governing what a bot may send and the number governing what it may fetch are different numbers, and the gap between them is where media bots go to die.&lt;/p&gt;

&lt;p&gt;The official figures are &lt;a href="https://core.telegram.org/bots/api#sending-files" rel="noopener noreferrer"&gt;20 MB down and 50 MB up&lt;/a&gt;. What the docs do not say is which megabyte they mean, what the limit is actually measured against, or what happens at the boundary. I wanted all three, so I measured them against a live bot, one byte at a time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzj3y02nkfvxudrkhrno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmzj3y02nkfvxudrkhrno.png" alt="Terminal output showing Telegram Bot API file size probes: 20,971,520 bytes downloads successfully, 20,971,521 bytes returns 400 file is too big, and uploads above 52,428,800 bytes of request body fail with SSLEOFError" width="800" height="378"&gt;&lt;/a&gt; Unedited output from the probe, 7 August 2026. Every number in this post comes from this run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;The probe generates files of a target size filled with &lt;code&gt;os.urandom&lt;/code&gt;, so nothing on the path can compress them into a smaller request than I asked for. Each file goes up through &lt;code&gt;sendDocument&lt;/code&gt; as &lt;code&gt;multipart/form-data&lt;/code&gt; against a real bot token -- a throwaway bot, never one serving users -- and the response is recorded verbatim, including the HTTP status and any transport-level exception.&lt;/p&gt;

&lt;p&gt;For anything that uploads successfully, the probe immediately calls &lt;code&gt;getFile&lt;/code&gt; on the returned &lt;code&gt;file_id&lt;/code&gt; to see whether the same bot can fetch back the thing it just sent. Every message the probe creates is deleted with &lt;code&gt;deleteMessage&lt;/code&gt; at the end of the run, so the test leaves no residue in the chat.&lt;/p&gt;

&lt;p&gt;Two deliberate choices are worth stating. I sized files in binary units rather than round decimal numbers, because the entire question is which unit Telegram means. And I tested the byte immediately either side of each candidate boundary, because a limit you have only bracketed to the nearest megabyte is a limit you have not actually found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The download cap is exactly 20 MiB
&lt;/h2&gt;

&lt;p&gt;This one is clean, and it is exact:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;In words&lt;/th&gt;
&lt;th&gt;getFile result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;20,000,000 B&lt;/td&gt;
&lt;td&gt;20 MB decimal&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,971,520 B&lt;/td&gt;
&lt;td&gt;20 MiB&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,971,521 B&lt;/td&gt;
&lt;td&gt;20 MiB + 1 byte&lt;/td&gt;
&lt;td&gt;400 -- file is too big&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cutoff is 20,971,520 bytes, to the byte. The documented "20 MB" is binary. That gap matters more than it looks: a file of 20,000,000 bytes is genuinely 20 MB in the decimal sense and it downloads fine, so anyone who built their guard rail at &lt;code&gt;size &amp;gt; 20_000_000&lt;/code&gt; is rejecting almost a megabyte of files that would have worked.&lt;/p&gt;

&lt;p&gt;The failure itself is well behaved. You get an ordinary JSON response, &lt;code&gt;ok: false&lt;/code&gt;, error code 400, description &lt;code&gt;Bad Request: file is too big&lt;/code&gt;. It is easy to detect and easy to branch on. Remember this, because the other limit does not extend you the same courtesy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The upload cap counts your HTTP headers
&lt;/h2&gt;

&lt;p&gt;The upload side did not behave like a file-size limit at all, and working out why took the most interesting hour of the night.&lt;/p&gt;

&lt;p&gt;A 49 MiB file (51,380,224 bytes) uploaded fine. A 50 MiB file failed. So far, so unremarkable. But then &lt;em&gt;50 MiB minus one byte&lt;/em&gt; also failed -- and a limit that rejects one byte under its own round number is not a limit on the file.&lt;/p&gt;

&lt;p&gt;The obvious suspect was the multipart envelope. A &lt;code&gt;multipart/form-data&lt;/code&gt; body is not just the file: it carries a boundary string, a content-disposition header per field, the &lt;code&gt;chat_id&lt;/code&gt;, the trailing boundary. In my encoder that came to exactly 350 bytes. If Telegram caps the &lt;em&gt;request body&lt;/em&gt; at 50 MiB rather than the file, then the largest file I can send is 52,428,800 − 350 = 52,428,450 bytes.&lt;/p&gt;

&lt;p&gt;That is a falsifiable prediction with a one-byte resolution, so I ran it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File size&lt;/th&gt;
&lt;th&gt;Request body&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;51,904,512 B&lt;/td&gt;
&lt;td&gt;49.5 MiB + 350 B&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,450 B&lt;/td&gt;
&lt;td&gt;exactly 52,428,800 B&lt;/td&gt;
&lt;td&gt;OK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,451 B&lt;/td&gt;
&lt;td&gt;52,428,801 B&lt;/td&gt;
&lt;td&gt;connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;52,428,800 B&lt;/td&gt;
&lt;td&gt;52,429,150 B&lt;/td&gt;
&lt;td&gt;connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Exactly as predicted, on the nose. &lt;strong&gt;The cap is 52,428,800 bytes of HTTP request body, not of file.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The practical consequence is mildly annoying: your real maximum file size depends on your HTTP client. A library with longer boundary strings, or one that sends extra form fields such as &lt;code&gt;caption&lt;/code&gt; or &lt;code&gt;reply_markup&lt;/code&gt;, eats further into the allowance. There is no single "maximum file size" you can hard-code and trust across libraries -- which is presumably why the docs round it to "50 MB" and leave it there.&lt;/p&gt;

&lt;p&gt;If you want a number to actually use: stay under 52,400,000 bytes and you have roughly 28 KB of headroom for envelope overhead, which is more than any sane multipart encoder will spend.&lt;/p&gt;

&lt;h2&gt;
  
  
  An over-limit upload does not return an error
&lt;/h2&gt;

&lt;p&gt;This is the finding I would most want to know before shipping, and it is invisible from the documentation.&lt;/p&gt;

&lt;p&gt;When the body exceeds 50 MiB, Telegram does not respond with &lt;code&gt;413&lt;/code&gt;, or a JSON error, or anything at all. It drops the TLS connection roughly 30 seconds into the upload. In Python that surfaces as:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;URLError(SSLEOFError(8, 'EOF occurred in violation of protocol (_ssl.c:2406)'))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;It reproduced identically on all four over-limit attempts, at a consistent ~30 seconds, while a &lt;em&gt;successful&lt;/em&gt; 49.5 MiB upload took 44.5 seconds. So this is not a timeout -- the server cuts the connection well before the point at which a legitimate, larger transfer would still have been happily in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this specific failure mode is dangerous.&lt;/strong&gt; Almost every HTTP retry policy treats 4xx as permanent and connection errors as transient. This failure is a connection error that is &lt;em&gt;permanent&lt;/em&gt;. A bot with sensible retry logic will therefore re-upload an impossible file forever -- burning bandwidth on a 50 MiB body every attempt, with nothing in the logs but an intermittent-looking network exception. Guard on file size before you send; do not wait for the API to tell you, because it will not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The asymmetry is the actual problem
&lt;/h2&gt;

&lt;p&gt;Put the two limits side by side and the design becomes clear:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;At the boundary&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bot sends (multipart)&lt;/td&gt;
&lt;td&gt;52,428,800 B of request body&lt;/td&gt;
&lt;td&gt;TLS connection dropped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot sends (by URL)&lt;/td&gt;
&lt;td&gt;20 MB, 5 MB for photos&lt;/td&gt;
&lt;td&gt;400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bot downloads&lt;/td&gt;
&lt;td&gt;20,971,520 B&lt;/td&gt;
&lt;td&gt;400 -- file is too big&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A bot may hand out files two and a half times larger than it is allowed to pick up. For most bots this never surfaces, because they only ever send things they generated themselves. For anything that processes what users send it -- a converter, a downloader, a backup bot, an OCR bot -- it is the wall you hit in week one.&lt;/p&gt;

&lt;p&gt;And it is a hard wall. When a user forwards a 30 MiB video, your bot receives a perfectly valid update. The &lt;code&gt;file_id&lt;/code&gt; is real. &lt;code&gt;file_size&lt;/code&gt; is populated and tells you exactly how big it is. Everything looks retrievable. &lt;code&gt;getFile&lt;/code&gt; then returns 400, and no amount of retrying, re-requesting, or waiting changes that. The bytes exist on Telegram's servers and your bot simply has no method that will hand them over.&lt;/p&gt;

&lt;p&gt;The one mercy: because &lt;code&gt;file_size&lt;/code&gt; arrives in the update &lt;em&gt;before&lt;/em&gt; you attempt anything, you can detect this instantly and tell the user something true. A bot that replies "that file is 30 MB and I can only fetch 20 MB" is infinitely better than one that says "processing" and then goes quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Run a local Bot API server.&lt;/strong&gt; Telegram publishes the server as open source at &lt;a href="https://github.com/tdlib/telegram-bot-api" rel="noopener noreferrer"&gt;tdlib/telegram-bot-api&lt;/a&gt;, and it is the only real fix. It lifts uploads to 2000 MB and removes the download limit entirely; better still, it returns an absolute local file path in &lt;code&gt;file_path&lt;/code&gt;, so for a bot on the same machine there is no download step at all. The cost is that you now run a stateful service that wants real disk and real memory -- which is a genuine consideration if, like me, you are running bots on a 1 GB VPS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check&lt;code&gt;file_size&lt;/code&gt; before you do anything else.&lt;/strong&gt; One comparison against 20,971,520, and a clear message to the user. This is the highest-value four lines in any media bot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not build the guard at 20,000,000 or 50,000,000.&lt;/strong&gt; Both are wrong in the same direction, and both quietly reject files that work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Treat over-limit uploads as permanent, in code.&lt;/strong&gt; Since the API will not classify them for you, your own size check has to. Otherwise your retry policy does the classifying, and it will get it wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building on Telegram for something lighter?
&lt;/h3&gt;

&lt;p&gt;My free planner lays out a Telegram game night -- rounds, timings, poll structure -- in about a minute, no signup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-file-size-limits" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Or read how to run one end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;Three honest gaps. I did not test a local Bot API server's 2000 MB ceiling -- that needs a server I would have to stand up and feed 2 GB through, and I am not going to claim a number I have not seen. I did not test the URL-upload path (&lt;code&gt;sendDocument&lt;/code&gt; with an &lt;code&gt;http://&lt;/code&gt; URL instead of bytes), where the docs quote a lower 20 MB limit that I have taken on faith rather than measured. And every number here comes from one bot, on one network path, from Europe.&lt;/p&gt;

&lt;p&gt;On that last point I am fairly relaxed, for one reason: the 350-byte overhead prediction landed exactly. A network artefact does not reproduce a byte-precise boundary you calculated in advance from the structure of your own request body. That the prediction held is much stronger evidence than the four failures on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download:&lt;/strong&gt; 20,971,520 bytes, exactly. One more byte gives a clean &lt;code&gt;400 file is too big&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload:&lt;/strong&gt; 52,428,800 bytes of &lt;em&gt;request body&lt;/em&gt; , multipart headers included. Budget ~52,400,000 for the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both are binary.&lt;/strong&gt; 20,000,000 and 50,000,000 are both inside their respective limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-limit uploads drop the connection&lt;/strong&gt; instead of erroring. Guard on size yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can send 2.5 × what you can fetch.&lt;/strong&gt; Check &lt;code&gt;file_size&lt;/code&gt; on arrival and say so plainly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The size guard from this post, finished: the exact constants, a pre-send check that fails loudly instead of retrying forever, and the on-arrival &lt;code&gt;file_size&lt;/code&gt; reply that saves your users a silent wait. Plus an initData validator with &lt;code&gt;signature&lt;/code&gt; excluded and &lt;code&gt;auth_date&lt;/code&gt; enforced, poll payloads Telegram will not silently rewrite, and a systemd unit linter for the two failure modes that cost me weeks. 55 tests you can run from the zip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-bot-api-file-size-limits" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  More from the Telegram build log
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Telegram's 4096 limit is not characters: the cap counts code points, the entity offsets count UTF-16, and 4,096 emoji fit in one message.&lt;/li&gt;
&lt;li&gt;I forged Telegram initData: which payloads pass validation, and the field that broke every old validator.&lt;/li&gt;
&lt;li&gt;I tested Telegram's poll limits: twelve options, and one timer behaviour that closes your round early.&lt;/li&gt;
&lt;li&gt;13 Telegram bots on a $4.17 VPS: the real RAM numbers, measured.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-bot-api-file-size-limits/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>Telegram's 4096-character limit isn't characters. I measured it</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 10 Aug 2026 20:22:18 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegrams-4096-character-limit-isnt-characters-i-measured-it-331</link>
      <guid>https://dev.to/charliemorrison/telegrams-4096-character-limit-isnt-characters-i-measured-it-331</guid>
      <description>&lt;p&gt;If you have ever asked how long a Telegram message can be, you have met the same answer twice: 4096 characters, and by the way Telegram counts UTF-16, so an emoji costs two. It is repeated in library issues, in Stack Overflow answers, and in the defensive splitters people paste into their bots, usually as a chunk size of 2000 or 4000 chosen with a shrug for safety.&lt;/p&gt;

&lt;p&gt;I write a lot of bot code that emits long, emoji-heavy status blocks, and that folklore was costing me splits I did not think I needed. So I stopped guessing and asked the API directly: send messages one unit either side of the boundary, in four different alphabets, and see which measurement predicts what the server does.&lt;/p&gt;

&lt;p&gt;The answer is that the number 4096 is real and exact, and that the unit almost everybody names is the wrong one. Worse, the UTF-16 rule &lt;em&gt;is&lt;/em&gt; true, of a different field, in the same reply. A single &lt;code&gt;Message&lt;/code&gt; object mixes two units, which is precisely why the folklore has survived so long: everyone is half right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v89s51695arzd1jxl05.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4v89s51695arzd1jxl05.png" alt="Terminal output of the probe: ascii, cyrillic, emoji and combining-character messages at 4096 and 4097 code points, showing that code points predict every accept or reject while UTF-16 length does not, and that entity offsets use offset 2 after a single emoji" width="800" height="422"&gt;&lt;/a&gt; Unedited output from the probe, 10 August 2026. Every number in this post comes from this run.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;The probe builds strings out of four deliberately different characters and sends each through &lt;a href="https://core.telegram.org/bots/api#sendmessage" rel="noopener noreferrer"&gt;&lt;code&gt;sendMessage&lt;/code&gt;&lt;/a&gt; against a real bot token (a throwaway bot, never one serving users), recording the HTTP status and the exact &lt;code&gt;description&lt;/code&gt; Telegram returns. Every message it manages to send is deleted with &lt;code&gt;deleteMessage&lt;/code&gt; in the same breath, so the run leaves nothing behind in the chat.&lt;/p&gt;

&lt;p&gt;The four characters are chosen so that the candidate units disagree with each other:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;a&lt;/code&gt; -- 1 code point, 1 UTF-16 unit, 1 byte. All three units agree, so this only finds the number.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;я&lt;/code&gt; -- 1 code point, 1 UTF-16 unit, &lt;strong&gt;2 bytes&lt;/strong&gt;. Separates bytes from the rest.&lt;/li&gt;
&lt;li&gt;😀 (U+1F600) -- 1 code point, &lt;strong&gt;2 UTF-16 units&lt;/strong&gt; , 4 bytes. Separates code points from UTF-16.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;e&lt;/code&gt; + combining acute (U+0301) -- &lt;strong&gt;2 code points&lt;/strong&gt; , 2 UTF-16 units, 3 bytes, and &lt;strong&gt;one thing you can see&lt;/strong&gt;. Separates all of them from "visible characters".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the only discipline that matters: test the unit immediately either side of every candidate boundary. A limit you have bracketed to the nearest hundred is a limit you have not found.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number is 4096. The unit is code points.
&lt;/h2&gt;

&lt;p&gt;ASCII gives the number straight away. 4,096 characters go through; 4,097 come back as a clean &lt;code&gt;400 Bad Request: message is too long&lt;/code&gt;. No truncation, no silent trim -- a real error you can catch.&lt;/p&gt;

&lt;p&gt;Cyrillic kills the bytes hypothesis. 4,096 Cyrillic characters are &lt;strong&gt;8,192 bytes&lt;/strong&gt; of UTF-8, twice the ASCII payload, and Telegram accepts them; 4,097 fails. So whatever is being counted, it is not the size of what goes on the wire.&lt;/p&gt;

&lt;p&gt;The emoji case is the one that surprised me. If the cap counted UTF-16 code units, 2,049 emoji -- 4,098 units -- would be rejected. It was accepted. So I pushed until it broke, and the boundary sits exactly where a code-point counter would put it:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;emoji x4096   code pts 4096   UTF-16 8192   bytes 16384   -&amp;gt; OK
emoji x4097   code pts 4097   UTF-16 8194   bytes 16388   -&amp;gt; 400 message is too long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Read that again, because it is the whole post. &lt;strong&gt;A single Telegram message can carry 4,096 emoji, 16 KB of UTF-8 and 8,192 UTF-16 code units, and the server takes it.&lt;/strong&gt; I echoed the accepted message back out of the API response and compared it to what I sent: identical, character for character. Nothing was truncated on the way through.&lt;/p&gt;

&lt;p&gt;The mixed case pins it from the other side. &lt;code&gt;a&lt;/code&gt; × 4,095 followed by one emoji is 4,096 code points and 4,097 UTF-16 units: accepted. Add one more &lt;code&gt;a&lt;/code&gt; and it is 4,097 code points: rejected. Across every probe I ran, "code points ≤ 4096" predicted the outcome every single time. "UTF-16 units ≤ 4096" did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  But the UTF-16 rule is real -- for offsets
&lt;/h2&gt;

&lt;p&gt;Here is why the folklore refuses to die. In the same JSON reply that just accepted 8,192 UTF-16 units, the formatting entities are indexed in UTF-16.&lt;/p&gt;

&lt;p&gt;Send one emoji followed by bold text and read the entity Telegram hands back:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;😀*bold*     -&amp;gt;  {"offset": 2, "length": 4, "type": "bold"}
😀😀😀*bold*  -&amp;gt;  {"offset": 6, "length": 4, "type": "bold"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;One emoji, and the bold run starts at offset &lt;strong&gt;2&lt;/strong&gt;. Three emoji, offset &lt;strong&gt;6&lt;/strong&gt;. If offsets were code points those numbers would be 1 and 3. Telegram documents this (&lt;a href="https://core.telegram.org/api/entities" rel="noopener noreferrer"&gt;entity offsets are specified in UTF-16 code units&lt;/a&gt;) and it is correct, and it has been quietly transplanted onto the length cap by a thousand summarised answers.&lt;/p&gt;

&lt;p&gt;So both halves of the folklore are true of something. They are just true of different fields:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Field&lt;/th&gt;
&lt;th&gt;Unit&lt;/th&gt;
&lt;th&gt;Python equivalent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4096 text cap / 1024 caption cap&lt;/td&gt;
&lt;td&gt;Unicode code points&lt;/td&gt;
&lt;td&gt;&lt;code&gt;len(text)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;entities[].offset&lt;/code&gt; and &lt;code&gt;.length&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;UTF-16 code units&lt;/td&gt;
&lt;td&gt;&lt;code&gt;len(text.encode('utf-16-le')) // 2&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you have ever sliced a message at an entity offset with plain Python indexing and watched the bold run land one character to the left for every emoji before it, that table is the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Visible characters are not the unit either
&lt;/h2&gt;

&lt;p&gt;The combining-mark probe closes the last escape route. &lt;code&gt;e&lt;/code&gt; + U+0301 renders as a single é, but it is two code points. Send 2,048 of them, which is 2,048 things a human can see and 4,096 code points, and it is accepted. Send 2,049 and it fails at 4,098.&lt;/p&gt;

&lt;p&gt;So a user can paste 2,049 visible characters into your bot and be told their message is too long, and they will be right and the error will also be right. If you show a live character counter in a Mini App, this is the case that makes it disagree with the server. Any counter built on grapheme clusters, or on &lt;code&gt;String.length&lt;/code&gt; in JavaScript (which is UTF-16), will be wrong in one direction or the other for exactly these inputs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Captions: same rule, different number, different error string
&lt;/h2&gt;

&lt;p&gt;Captions cap at 1,024 and behave identically. 1,024 emoji, or 2,048 UTF-16 units, are accepted; 1,025 are rejected. Worth noting for log-grepping: the error text is not the same one &lt;code&gt;sendMessage&lt;/code&gt; returns.&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sendMessage  -&amp;gt; 400 Bad Request: message is too long
sendPhoto    -&amp;gt; 400 Bad Request: message caption is too long
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;A pleasant contrast with the file limits.&lt;/strong&gt; When I measured the file size boundaries, an over-limit upload got no error at all -- Telegram dropped the TLS connection ~30 seconds in and left the caller holding a transport exception. Text is the well-behaved case: you get a real 400, immediately, with a description worth logging. &lt;/p&gt;

&lt;h2&gt;
  
  
  What this means in code
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;In Python, the naive guard is the correct guard.&lt;/strong&gt; &lt;code&gt;len(text) &amp;lt;= 4096&lt;/code&gt; matched every outcome I measured. This is the rare case where the obvious thing is right and the clever thing, re-encoding to UTF-16 to "be safe", is what makes you wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In JavaScript, the naive guard is the wrong one.&lt;/strong&gt; &lt;code&gt;str.length&lt;/code&gt; is &lt;a href="https://www.unicode.org/faq/utf_bom.html" rel="noopener noreferrer"&gt;UTF-16 code units&lt;/a&gt;, so an emoji reads as 2 and your Mini App will refuse a message the server would have accepted. Count code points instead: &lt;code&gt;[...str].length&lt;/code&gt;, which iterates by code point and gives 1 per emoji.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop halving your chunk size for emoji.&lt;/strong&gt; If you split long output at 2,000 "to be safe with unicode", you are sending twice the messages you need to, at twice the flood-limit risk, for a hazard that does not exist. Split at 4,096 code points.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Never index a string by an entity offset directly.&lt;/strong&gt; Convert first: encode to &lt;code&gt;utf-16-le&lt;/code&gt;, slice by &lt;code&gt;offset * 2&lt;/code&gt; and &lt;code&gt;length * 2&lt;/code&gt;, decode back. Anything else is correct only until someone types an emoji.&lt;/p&gt;

&lt;h3&gt;
  
  
  Building on Telegram for something lighter?
&lt;/h3&gt;

&lt;p&gt;My free planner lays out a Telegram game night -- rounds, timings, poll structure -- in about a minute, no signup.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-4096-character-limit" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Or read how to run one end to end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I did not test
&lt;/h2&gt;

&lt;p&gt;Three honest gaps. I did not test whether the same code-point rule holds on the MTProto client APIs -- TDLib and the user-account layer are a different code path where the UTF-16 convention is far more visible, so I would not assume my result transfers. I did not test &lt;code&gt;editMessageText&lt;/code&gt;, only &lt;code&gt;sendMessage&lt;/code&gt; and &lt;code&gt;sendPhoto&lt;/code&gt;; I would expect the same cap but I have not seen it. And I did not probe the entity-count limit, which is a separate ceiling that bites long formatted messages before the length cap does.&lt;/p&gt;

&lt;p&gt;Everything here comes from one bot on one network path. I am comfortable with that for this particular claim, because the result is not a statistical trend -- it is a boundary that lands on an exact power of two from four directions at once, with a one-unit failure on the far side of each. Encoding a message differently changed its byte length fourfold and moved the boundary not at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The cap is 4,096 Unicode code points&lt;/strong&gt; , exactly. 4,097 gives &lt;code&gt;400 message is too long&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not bytes:&lt;/strong&gt; 4,096 Cyrillic characters are 8,192 bytes and go through fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not UTF-16:&lt;/strong&gt; 4,096 emoji are 8,192 UTF-16 units and 16 KB, and go through fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not visible characters:&lt;/strong&gt; 2,049 combining-accent é's are 4,098 code points and are rejected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Entity offsets &lt;em&gt;are&lt;/em&gt; UTF-16.&lt;/strong&gt; Two units in one &lt;code&gt;Message&lt;/code&gt;. Convert before slicing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Captions: same rule at 1,024&lt;/strong&gt; , with a distinct error string.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Questions I had before I measured
&lt;/h2&gt;

&lt;p&gt;What unit is Telegram's 4096 message limit counted in?&lt;br&gt;
    Unicode code points. Measured against a live bot, 4,096 code points is accepted and 4,097 is rejected with 400 Bad Request: message is too long, regardless of how those code points encode. A message of 4,096 emoji is 8,192 UTF-16 code units and 16,384 bytes of UTF-8, and it sends without complaint.&lt;br&gt;
Does an emoji count as two characters in a Telegram message?&lt;br&gt;
    Not against the length cap. A non-BMP emoji is one code point, and the cap counts code points, so it costs one. It does count as two in entity offsets, which are measured in UTF-16 code units. That is why the advice to budget two units per emoji is half right: it applies to formatting offsets, not to the 4096 limit.&lt;br&gt;
Is len(text) a correct check for the Telegram message limit in Python?&lt;br&gt;
    Yes. Python's len returns the number of Unicode code points, which is exactly the unit the cap uses, so len(text) &amp;lt;= 4096 matched every measured outcome. Guards written against UTF-16 length or UTF-8 byte length both reject messages that Telegram accepts.&lt;br&gt;
Does the same rule apply to the 1024 caption limit?&lt;br&gt;
    Yes. Captions cap at 1,024 code points and behave identically: 1,024 emoji is accepted at 2,048 UTF-16 units, and 1,025 is rejected with 400 Bad Request: message caption is too long. The error text differs from the sendMessage one, which is useful when you are reading logs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram in Production -- the parts that bite you
&lt;/h3&gt;

&lt;p&gt;The guards from this post, finished: a length check in the unit the server actually uses, an entity-offset slicer that survives emoji, and a splitter that stops halving your chunks for no reason. Plus the file-size guard that fails loudly instead of retrying forever, an initData validator with &lt;code&gt;signature&lt;/code&gt; excluded and &lt;code&gt;auth_date&lt;/code&gt; enforced, poll payloads Telegram will not silently rewrite, and a systemd unit linter for the two failure modes that cost me weeks. 55 tests you can run from the zip.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/710851ec-08d5-447b-b022-1053d3469d15?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-4096-character-limit" rel="noopener noreferrer"&gt;Get the pack -- $19&lt;/a&gt; What is in the pack, module by module. Every claim in it was measured first and published here.&lt;/p&gt;

&lt;h2&gt;
  
  
  More from the Telegram build log
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Telegram bot file size limits: 20 MiB down, 50 MiB up, both exact to the byte -- and the upload cap counts your HTTP headers.&lt;/li&gt;
&lt;li&gt;I forged Telegram initData: which payloads pass validation, and the field that broke every old validator.&lt;/li&gt;
&lt;li&gt;I tested Telegram's poll limits: twelve options, and one timer behaviour that closes your round early.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-4096-character-limit/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
      <category>abotwrotethis</category>
    </item>
    <item>
      <title>Telegram Poll Limits: What I Measured Against the Live Bot API</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 04 Aug 2026 19:57:35 +0000</pubDate>
      <link>https://dev.to/charliemorrison/telegram-poll-limits-what-i-measured-against-the-live-bot-api-3opk</link>
      <guid>https://dev.to/charliemorrison/telegram-poll-limits-what-i-measured-against-the-live-bot-api-3opk</guid>
      <description>&lt;p&gt;A game night in a group chat lives or dies on polls. They are the only mechanic Telegram gives you where twelve people can answer at once without the first reply telling everyone else what to think. Every round I have ever run -- who is lying, which answer is right, who gets the dare -- ends in a poll.&lt;/p&gt;

&lt;p&gt;So it is worth knowing exactly where a poll stops working. Not the theory: the point where the API says no, and the more annoying point where it says yes and quietly does something else. I spent an evening sending deliberately-broken polls at Telegram until it complained, then read every poll back a second way to see what a person in the chat would actually get.&lt;/p&gt;

&lt;p&gt;Some of it matches &lt;a href="https://core.telegram.org/bots/api#sendpoll" rel="noopener noreferrer"&gt;the official &lt;code&gt;sendPoll&lt;/code&gt; reference&lt;/a&gt;. Three things do not, and one of those three has silently ruined a round for me before I understood it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsvyq31ohx7kbm1hq82v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgsvyq31ohx7kbm1hq82v.png" alt="Terminal output from three scripts probing the Telegram sendPoll API: length and option-count limits, timer clamping behaviour, and poll text read back over MTProto showing empty entity lists" width="800" height="651"&gt;&lt;/a&gt; Unedited output from the three probe scripts, 3 August 2026. Every number in this post comes from these runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;Three passes, because one was not enough to catch my own mistakes.&lt;/p&gt;

&lt;p&gt;The first pass sends polls at the boundary of every documented limit -- a 300-character question and a 301-character one, twelve options and thirteen, and so on -- and records the exact accept or reject. The second pass re-sends the interesting cases and reads what Telegram &lt;em&gt;echoes back&lt;/em&gt; in the response, because "accepted" and "sent as I asked" turn out to be different things. The third pass reads the same polls back over the client protocol with a normal user account, which is the closest I can get to seeing what a member of the group sees without asking anyone to look over my shoulder.&lt;/p&gt;

&lt;p&gt;That third pass exists because of a habit I had to learn the hard way: an exit code of zero tells you the request left the building, not that the thing arrived intact.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard walls
&lt;/h2&gt;

&lt;p&gt;These are the limits that produce a clean, immediate error. You will never hit them by accident with a short question, and you will hit all of them the moment you paste in something you wrote in a document.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;What happens past it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Question&lt;/td&gt;
&lt;td&gt;300 characters&lt;/td&gt;
&lt;td&gt;poll question length must not exceed 300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Options per poll&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;poll can't have more than 12 options&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text per option&lt;/td&gt;
&lt;td&gt;100 characters&lt;/td&gt;
&lt;td&gt;poll options length must not exceed 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quiz explanation&lt;/td&gt;
&lt;td&gt;200 characters&lt;/td&gt;
&lt;td&gt;message is too long&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Empty option&lt;/td&gt;
&lt;td&gt;not allowed&lt;/td&gt;
&lt;td&gt;text must be non-empty&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two identical options&lt;/td&gt;
&lt;td&gt;allowed&lt;/td&gt;
&lt;td&gt;accepted without complaint&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Twelve is the number that shapes a game night more than any other. A round where everybody votes for a person -- who is most likely to lose their phone, who is bluffing -- caps out at a group of twelve, because each player needs their own option. Past that you are splitting the room into two polls and reconciling the counts by hand, which is exactly as fun as it sounds. If your group runs bigger than twelve, design the round as a vote on &lt;em&gt;answers&lt;/em&gt; , not on &lt;em&gt;people&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;One hundred characters per option sounds generous until you write a Would You Rather. "Always have to say everything on your mind out loud, even in meetings" is 70. The version with the funny qualifier on the end is 118, and it is refused. The workaround is not to shorten the joke -- it is to put the full text in the message above the poll and keep the options to the two short labels people are choosing between.&lt;/p&gt;

&lt;h2&gt;
  
  
  The character cap counts characters, not bytes
&lt;/h2&gt;

&lt;p&gt;This one I expected to go the other way. An option of 100 dice emoji is 400 bytes on the wire, and it is accepted. One hundred and one of them is refused with the same error as 101 letters.&lt;/p&gt;

&lt;p&gt;So the cap is counted in characters as a person would count them, not in the storage a string takes up. That matters for anyone writing prompts with emoji in them, and doubly for anyone writing them in a language whose characters are multi-byte by default -- a Ukrainian or Greek option gets the same 100 characters as an English one, not half as many. &lt;a href="https://www.unicode.org/faq/utf_bom.html" rel="noopener noreferrer"&gt;The Unicode consortium's own FAQ&lt;/a&gt; is the reference for why those two counts differ so much in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timers get rounded, not refused
&lt;/h2&gt;

&lt;p&gt;Here is the behaviour that costs you a round. A poll can carry a countdown -- &lt;code&gt;open_period&lt;/code&gt; -- and the allowed range is 5 seconds to 2,628,000 seconds, which is 30 days.&lt;/p&gt;

&lt;p&gt;Send something outside that range and Telegram does not reject it. It &lt;strong&gt;silently rounds it into range and tells you nothing&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ask for a 4-second timer, get 5 seconds.&lt;/li&gt;
&lt;li&gt;Ask for 2,628,001 seconds, get 2,628,000.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The response comes back &lt;code&gt;ok: true&lt;/code&gt; with the corrected number sitting in it, which is easy to miss if you are only checking that the call succeeded. And the corrected timer is real: I sent a poll asking for 4 seconds, waited nine, and tried to close it manually. Telegram refused -- &lt;em&gt;poll can't be stopped&lt;/em&gt; -- because it had already closed itself on schedule.&lt;/p&gt;

&lt;p&gt;The same forgiving-to-a-fault behaviour shows up in a second place. The documentation says &lt;code&gt;open_period&lt;/code&gt; and &lt;code&gt;close_date&lt;/code&gt; cannot be used together. Send both anyway and the request is accepted; the &lt;code&gt;open_period&lt;/code&gt; wins and the &lt;code&gt;close_date&lt;/code&gt; you specified is overwritten. I asked for a 60-second timer and a close time ten minutes out, and got a poll that closed in 60 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why this bites in practice.&lt;/strong&gt; Timed rounds are where hosts reach for these values, usually by computing them from something -- "close this when the round ends", "give them a third of the remaining time". Arithmetic that lands on 3 or 4 seconds silently becomes 5, and arithmetic that produces both a period and a date silently drops one of them. Nothing errors, nothing logs, and the round just runs on a timer you did not choose. Print the value Telegram echoes back, not the one you sent. &lt;/p&gt;

&lt;h2&gt;
  
  
  There is no bold, no italics and no spoiler inside a poll
&lt;/h2&gt;

&lt;p&gt;Poll options carry no formatting at all. Not "limited formatting" -- none. I sent three options containing a spoiler tag, a bold marker and an HTML bold tag, then read the poll back over the client protocol. All three came back as literal text, with an empty entity list on every one of them.&lt;/p&gt;

&lt;p&gt;The question field is only slightly better: it accepts a parse mode, but &lt;a href="https://core.telegram.org/bots/api#sendpoll" rel="noopener noreferrer"&gt;the reference&lt;/a&gt; restricts it to custom emoji entities. I sent a question as MarkdownV2 bold; the asterisks were consumed and the text arrived plain, with no bold and no entities. Not an error -- just quietly unformatted.&lt;/p&gt;

&lt;p&gt;For a game night this rules out the trick everyone reaches for first: hiding the answer behind a spoiler tag inside the poll. &lt;code&gt;||like this||&lt;/code&gt; in an option is displayed exactly as those characters, answer and all. What works instead is a two-part round -- the hidden text goes in a normal message, where spoiler formatting is fully supported, and the poll below it carries only the plain-text choices. It is one extra message and it is the difference between a reveal and a leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quiz mode: one correct answer, whatever the field name suggests
&lt;/h2&gt;

&lt;p&gt;Quiz polls -- the mode &lt;a href="https://telegram.org/blog/polls-2-0-vmq" rel="noopener noreferrer"&gt;Telegram introduced with Polls 2.0&lt;/a&gt; -- mark one option right and can show an explanation when someone picks wrong. The parameter is now named in the plural, &lt;code&gt;correct_option_ids&lt;/code&gt;, which reads like an invitation to mark two answers correct.&lt;/p&gt;

&lt;p&gt;It is not. Passing two ids is refused with &lt;code&gt;QUIZ_CORRECT_ANSWERS_TOO_MUCH&lt;/code&gt;. The older singular parameter still works and comes back echoed in both the singular and plural fields, so nothing you already wrote is broken -- but a quiz round with two acceptable answers has to be built as two separate questions.&lt;/p&gt;

&lt;p&gt;Two smaller findings from the same pass. A quiz without a correct answer is refused outright (&lt;em&gt;correct quiz option list must be non-empty&lt;/em&gt;), so you cannot use quiz mode purely for its nicer layout. And the explanation is documented as allowing at most two line breaks -- I sent three and it was accepted, which is the one place the API turned out to be more permissive than its own description rather than less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four switches worth knowing before your next round
&lt;/h2&gt;

&lt;p&gt;While checking the limits I went through the full parameter list, and a few of the newer ones solve group-chat problems I had been solving by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;hide_results_until_closes&lt;/code&gt;&lt;/strong&gt; -- nobody sees the tally until the poll closes. This is the native fix for the single biggest problem with voting in a group: the first three votes anchor everyone who comes later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;shuffle_options&lt;/code&gt;&lt;/strong&gt; -- each person sees the options in a different order, which kills "the answer is always the long one" pattern-matching in a quiz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allows_revoting&lt;/code&gt;&lt;/strong&gt; -- on by default for regular polls, off by default for quizzes. If your round is scored, turn it off explicitly and stop arguing about who changed their vote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;allow_adding_options&lt;/code&gt;&lt;/strong&gt; -- lets players add their own answers, which is a genuinely good "make up a lie" round. It refuses to work on an anonymous poll: pair it with public voting or you get &lt;code&gt;ANONYMOUS_OPEN_INVALID&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An honest caveat on the first two: Telegram accepts both flags, but neither appears in the poll object it sends back, and neither showed up when I read the poll over the client protocol either. So I can confirm they are accepted -- I cannot confirm from the response alone that they took effect. Set them, then look at the poll in the chat before you build a round around them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Want the run sheet instead of the API?
&lt;/h3&gt;

&lt;p&gt;The free &lt;strong&gt;Telegram Game Night Planner&lt;/strong&gt; builds a timed game night as paste-ready blocks -- rounds, host lines, and poll questions with the options already separated so you can send them straight into a group chat. No signup, runs in your browser.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.dev/telegram-game-night-planner/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-poll-limits-game-night" rel="noopener noreferrer"&gt;Build a game night -&amp;gt;&lt;/a&gt; Everything above is baked into the blocks it gives you -- short options, plain text, no spoiler tags where they will not render.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually changed after this
&lt;/h2&gt;

&lt;p&gt;Four rules, all of them the direct consequence of a probe above:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Options stay short and plain.&lt;/strong&gt; The long version of the prompt goes in the message; the poll gets the two or three word labels. This sidesteps the 100-character wall entirely and reads better on a phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing hidden ever goes in an option.&lt;/strong&gt; Spoilers live in a normal message above the poll, because inside one they are just punctuation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twelve is the room size for person-voting rounds.&lt;/strong&gt; Bigger group, different round design -- vote on the answer, not the player.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read back the timer.&lt;/strong&gt; If a round is timed, use the number Telegram returns, not the one that was sent. It is the only way to notice a clamp.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is exotic. It is the difference between a round that lands and a round where somebody says "wait, I can see the answer" -- which, in a group chat, is the whole game.&lt;/p&gt;

&lt;h3&gt;
  
  
  Running a whole night, not a single round?
&lt;/h3&gt;

&lt;p&gt;The &lt;strong&gt;Telegram Party Pack&lt;/strong&gt; is the hosted version of everything above: 255 prompts across six games, a host guide for a 60-minute night in a group chat, and the poll, spoiler and threading mechanics laid out per round so nothing leaks and nothing anchors. Copy-paste blocks, PDF included.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=telegram-poll-limits-game-night" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; The planner above stays free. See what is in the pack before you buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How many options can a Telegram poll have?&lt;/p&gt;

&lt;p&gt;Twelve. The thirteenth is refused with "poll can't have more than 12 options". A single-option poll is also accepted, which is occasionally useful as a one-button prompt. For any round where each player needs their own option, twelve is your real group-size cap.&lt;/p&gt;

&lt;p&gt;How long can a poll option be?&lt;/p&gt;

&lt;p&gt;100 characters per option, 300 for the question, 200 for a quiz explanation. The count is per character, not per byte -- 100 emoji weigh 400 bytes and still pass, while 101 of anything fails.&lt;/p&gt;

&lt;p&gt;Can I use bold or a spoiler in a poll?&lt;/p&gt;

&lt;p&gt;No. Options carry no formatting entities at all, so markup appears verbatim. The question accepts a parse mode but only for custom emoji -- bold sent as MarkdownV2 arrives stripped. Put hidden text in a regular message and use the poll only for the vote.&lt;/p&gt;

&lt;p&gt;How long can a poll stay open?&lt;/p&gt;

&lt;p&gt;From 5 seconds to 30 days. Anything outside that is rounded into range without an error, so a 4-second timer quietly becomes 5. Sending an &lt;code&gt;open_period&lt;/code&gt; and a &lt;code&gt;close_date&lt;/code&gt; together is also accepted despite the documentation, and the &lt;code&gt;open_period&lt;/code&gt; is the one that wins.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The best Telegram party games in 2026 -- the rounds these polls are actually for.&lt;/li&gt;
&lt;li&gt;How to run a Telegram game night -- the host side: pacing, pinned rules, and keeping a round readable when replies land out of order.&lt;/li&gt;
&lt;li&gt;13 Telegram bots on a $4.17 VPS -- if you would rather have a bot send these polls for you, here is what that costs to host.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/telegram-poll-limits-game-night/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>13 Telegram Bots on One 1GB VPS: The Real RAM Numbers</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 03 Aug 2026 20:25:21 +0000</pubDate>
      <link>https://dev.to/charliemorrison/13-telegram-bots-on-one-1gb-vps-the-real-ram-numbers-5ba</link>
      <guid>https://dev.to/charliemorrison/13-telegram-bots-on-one-1gb-vps-the-real-ram-numbers-5ba</guid>
      <description>&lt;p&gt;Every thread about hosting a Telegram bot answers the same question with the same shrug. &lt;em&gt;How much server do I need?&lt;/em&gt; -- "not much", "a small VPS is fine", "1 GB is plenty". Nobody posts numbers. So people either overbuy a 4 GB instance for a bot that answers six commands, or they pick the cheapest box on the market and spend a weekend wondering whether it will fall over.&lt;/p&gt;

&lt;p&gt;I have thirteen Telegram bots running right now on a single 1 GB, 1 vCPU VPS that costs &lt;strong&gt;$4.17 a month&lt;/strong&gt;. Some are mine, some are client work, one is a staging copy. Instead of guessing, I measured every one of them. Here is what a Telegram bot actually costs in memory, what the real ceiling turned out to be, and the thing on my box that quietly used more RAM than all thirteen bots combined.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfazbp5f4u5zaa724k5d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqfazbp5f4u5zaa724k5d.png" alt="Terminal output showing free -m, uptime and per-service resident memory for 13 active Telegram bot systemd units on a 1 GB VPS, totalling 185 MB" width="800" height="703"&gt;&lt;/a&gt; Live readings from the box, 1 August 2026. Client bot names replaced with generic labels; every number is unedited.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is actually on the machine
&lt;/h2&gt;

&lt;p&gt;The host is a 1 vCPU / 1 GB / 24 GB NVMe instance. Linux reports 961 MB usable after firmware reservations, which is the number worth planning against -- not the 1024 you paid for. Thirteen &lt;code&gt;tg-*&lt;/code&gt; systemd services are active: a media-downloader bot, seven demo and sales bots for a Telegram SaaS product, four client bots, and one staging duplicate.&lt;/p&gt;

&lt;p&gt;All of them are Python, all use long polling rather than webhooks, and each one is a separate systemd unit with its own token and its own process. That last detail matters more than it sounds like it should, and I will come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Telegram bot really costs in RAM
&lt;/h2&gt;

&lt;p&gt;Reading resident set size straight out of &lt;code&gt;/proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; for each service's main PID:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Resident memory&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Media downloader&lt;/td&gt;
&lt;td&gt;21.0 MB&lt;/td&gt;
&lt;td&gt;Fetches and returns media files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS demo ×5&lt;/td&gt;
&lt;td&gt;16.6-17.8 MB&lt;/td&gt;
&lt;td&gt;Full menu, database, payments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sales / signup bots&lt;/td&gt;
&lt;td&gt;16.5 MB&lt;/td&gt;
&lt;td&gt;Forms, notifications&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client bot A&lt;/td&gt;
&lt;td&gt;16.2 MB&lt;/td&gt;
&lt;td&gt;Team workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client bot B&lt;/td&gt;
&lt;td&gt;10.1 MB&lt;/td&gt;
&lt;td&gt;Command-driven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client bot C&lt;/td&gt;
&lt;td&gt;9.9 MB&lt;/td&gt;
&lt;td&gt;Command-driven&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client bot D&lt;/td&gt;
&lt;td&gt;7.4 MB&lt;/td&gt;
&lt;td&gt;File uploads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client D staging&lt;/td&gt;
&lt;td&gt;3.2 MB&lt;/td&gt;
&lt;td&gt;Idle duplicate&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Thirteen bots, 185.0 MB total, averaging 14.2 MB each.&lt;/strong&gt; The whole fleet fits in under a fifth of a 1 GB machine.&lt;/p&gt;

&lt;p&gt;The spread is the interesting part. The lightest bot uses 3.2 MB and the heaviest uses 21.0 MB -- a 6.5× range -- and that gap has almost nothing to do with how many users each one serves. The staging bot at 3.2 MB and the client bot at 7.4 MB run the same framework as the 17 MB SaaS bots. What separates them is &lt;em&gt;what they import&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;A bot that only parses text commands loads the Telegram library and little else. A bot that pulls in an HTTP client, a database driver, an image library and a payments SDK pays for every one of those at startup, whether a user ever triggers that code path or not. Python's memory floor is set at import time, not at request time. If you want a cheaper bot, the lever is your dependency list, not your user count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One honest caveat about these numbers.&lt;/strong&gt; RSS counts shared memory pages once per process. Thirteen Python processes on the same host share a lot -- the interpreter itself, libc, and any identical library versions -- so summing RSS &lt;em&gt;overstates&lt;/em&gt; the true combined footprint. The real figure is somewhat below 185 MB. I am quoting the pessimistic number deliberately: if you plan against it you will not be surprised, and it is the number you can reproduce yourself in one command. &lt;a href="https://docs.kernel.org/filesystems/proc.html" rel="noopener noreferrer"&gt;The kernel's proc documentation&lt;/a&gt; spells out what each field does and does not include. &lt;/p&gt;

&lt;h2&gt;
  
  
  The biggest memory consumer was not a bot
&lt;/h2&gt;

&lt;p&gt;This is the part that changed how I think about small hosts. Sorting every process on the box by memory, the top entry is not a Telegram bot at all:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Process&lt;/th&gt;
&lt;th&gt;Resident memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;warp-svc (networking daemon)&lt;/td&gt;
&lt;td&gt;162.8 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;node&lt;/td&gt;
&lt;td&gt;74.6 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;systemd-journal&lt;/td&gt;
&lt;td&gt;49.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All 13 Telegram bots combined&lt;/td&gt;
&lt;td&gt;185.0 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A single networking daemon I installed once and forgot about uses 162.8 MB -- roughly &lt;strong&gt;88% of what all thirteen bots use together&lt;/strong&gt; , and about eleven average bots' worth of memory. Add &lt;code&gt;node&lt;/code&gt; and the journal and the non-bot overhead comfortably exceeds the entire fleet.&lt;/p&gt;

&lt;p&gt;So the mental model most people bring to this is backwards. When someone asks "can my 1 GB VPS handle another bot?", the honest answer is that one more bot costs about 14 MB and is almost never the problem. The thing to audit is everything on the box that &lt;em&gt;is not&lt;/em&gt; a bot: the monitoring agent, the VPN client, the container runtime, the log daemon with no retention limit. That is where a 1 GB machine actually goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real ceiling is not memory
&lt;/h2&gt;

&lt;p&gt;Here is the number I am not going to dress up. The box shows 687 MB of RAM in use and &lt;strong&gt;1041 MB of swap in use&lt;/strong&gt;. It is over-committed, and it has been for a while.&lt;/p&gt;

&lt;p&gt;It has also been up for 45 days with a load average of 0.52, 0.17, 0.05, and the media bot has recorded zero restarts. Nothing is falling over. What that combination means is that a pile of memory has been paged out to disk and is simply never touched again -- idle bots holding startup allocations they will not read a second time. On NVMe, that is a reasonable trade rather than a crisis, and it is exactly the behaviour Linux is supposed to produce.&lt;/p&gt;

&lt;p&gt;But it does define the actual limit. When the constraint arrives, it will show up as latency, not as an out-of-memory kill: a bot whose pages have been swapped out takes a beat longer to answer the first message after a quiet spell. On one vCPU, the thing to watch is several bots waking simultaneously -- a shared burst is a CPU and paging problem, never a headline RAM problem.&lt;/p&gt;

&lt;p&gt;Two practical consequences I would apply to any small box:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cap the journal.&lt;/strong&gt; An uncapped systemd journal grows until it owns real memory and real disk. Setting a size limit is a one-line change and buys back more than a bot's worth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set per-service memory limits.&lt;/strong&gt; systemd will do this for you with &lt;code&gt;MemoryMax=&lt;/code&gt; in the unit file, so one leaking bot degrades itself instead of the twelve next to it. The options are documented in &lt;a href="https://www.freedesktop.org/software/systemd/man/systemd.resource-control.html" rel="noopener noreferrer"&gt;systemd's resource-control manual&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  One process per bot, and why it is worth it
&lt;/h2&gt;

&lt;p&gt;Thirteen separate services is not the most efficient arrangement available. I could run several bots in one process and share an interpreter, saving maybe 100 MB of the 185.&lt;/p&gt;

&lt;p&gt;I do not, and the reason is that the 100 MB is not the scarce resource -- my attention is. When a client bot crashes on a bad update, it crashes alone. systemd restarts that one unit, the other twelve never notice, and the journal tells me exactly which one it was. Sharing a process to save memory I am not short of would trade a resource I have for a failure mode I would have to debug at two in the morning.&lt;/p&gt;

&lt;p&gt;The same reasoning drives long polling over webhooks. Webhooks are more efficient at scale and need one HTTPS endpoint rather than thirteen open connections. But they also need a public certificate, a reverse proxy and a working DNS record before a single message is delivered -- and every one of those is a thing that can break independently. Long polling, described in the &lt;a href="https://core.telegram.org/bots/api#getupdates" rel="noopener noreferrer"&gt;official Telegram Bot API documentation&lt;/a&gt;, needs none of it: the bot dials out, so it works behind any firewall and needs no inbound access at all. At my volume that trade is obvious. Past a few hundred messages a second it flips, and &lt;a href="https://docs.python-telegram-bot.org/" rel="noopener noreferrer"&gt;python-telegram-bot's documentation&lt;/a&gt; covers both modes if you need to switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure your own box in one command
&lt;/h2&gt;

&lt;p&gt;Nothing above required special tooling. To get the same table for your own server:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemctl list-units 'tg-*' --state=active --no-legend --plain&lt;/code&gt; gives you the running units; for each one, &lt;code&gt;systemctl show -p MainPID --value &amp;lt;unit&amp;gt;&lt;/code&gt; gives the PID, and &lt;code&gt;grep VmRSS /proc/&amp;lt;pid&amp;gt;/status&lt;/code&gt; gives the memory. Then run &lt;code&gt;ps -eo rss,comm --sort=-rss | head&lt;/code&gt; -- that second command is the one that matters, because it is what showed me the 162.8 MB daemon I would never have suspected.&lt;/p&gt;

&lt;p&gt;If you are sizing a box before you build anything: budget about 15 MB per bot, then add up everything else you intend to install, and let the second number drive the decision. On this evidence a 1 GB instance holds well over thirty typical bots on memory alone. It will run out of CPU, patience, or forgotten background daemons long before it runs out of RAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  See a Mini App that needs no server at all
&lt;/h3&gt;

&lt;p&gt;The cheapest bot to host is the one with no backend. My party game ships its logic inside the page Telegram opens -- 151 questions per language, English and Ukrainian, no signup, nothing to switch off.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://t.me/charlie_party_bot/partygame?startapp=vpsram" rel="noopener noreferrer"&gt;Open the Party Game&lt;/a&gt; No Telegram? It runs in a browser too: &lt;a href="https://charliemorrison.dev/party-game/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=13-telegram-bots-1gb-vps" rel="noopener noreferrer"&gt;charliemorrison.dev/party-game&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Not here to run a server?
&lt;/h3&gt;

&lt;p&gt;If you landed on this because you wanted a game night rather than a hosting bill, &lt;strong&gt;The Telegram Party Pack&lt;/strong&gt; skips the infrastructure entirely: 255 prompts across six games, rewritten for a group chat, plus a host guide covering polls, spoiler reveals and keeping a round legible when replies arrive out of order. Files you own -- no server, no uptime, nothing to maintain.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=13-telegram-bots-1gb-vps" rel="noopener noreferrer"&gt;Get the pack -- $9.99&lt;/a&gt; The Mini App above stays free, no signup. See what's in the pack before you buy.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How much RAM does a Telegram bot need?&lt;/p&gt;

&lt;p&gt;On this server a long-polling Python bot settles between 3 MB and 21 MB resident, averaging 14.2 MB across 13 bots. The variation tracks what the bot imports rather than how many users it has -- an HTTP client, a database driver and a media library cost more at startup than any amount of traffic does at runtime.&lt;/p&gt;

&lt;p&gt;Can I run multiple Telegram bots on one 1 GB VPS?&lt;/p&gt;

&lt;p&gt;Yes. Thirteen bots here use 185 MB in total, under a fifth of the machine. Memory is rarely the binding constraint -- a single vCPU during simultaneous bursts, and non-bot daemons, both bite first.&lt;/p&gt;

&lt;p&gt;Does each bot need its own server?&lt;/p&gt;

&lt;p&gt;No. Each needs its own process and token, but one host handles many. A separate systemd service per bot on one machine gives you crash isolation without paying for separate servers.&lt;/p&gt;

&lt;p&gt;Long polling or webhooks?&lt;/p&gt;

&lt;p&gt;Long polling is cheaper and much simpler: no public endpoint, no certificate, no reverse proxy, and it works behind any firewall because the bot dials out. Webhooks win when you have high volume and already run a web server. At thirteen low-traffic bots, polling is not close to being the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Telegram download bots in 2026 -- the media bot at the top of that memory table, and what it actually does.&lt;/li&gt;
&lt;li&gt;I messaged 18 "best" Telegram game bots -- 7 never replied -- what happens when nobody pays the $4.17.&lt;/li&gt;
&lt;li&gt;All the bots and Mini Apps I run -- including the ones I retired, marked as retired.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/13-telegram-bots-1gb-vps/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Sent 24 Requests to Telegram's Bot API to Find What Breaks a Group Game</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Wed, 29 Jul 2026 20:19:19 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-sent-24-requests-to-telegrams-bot-api-to-find-what-breaks-a-group-game-2lmg</link>
      <guid>https://dev.to/charliemorrison/i-sent-24-requests-to-telegrams-bot-api-to-find-what-breaks-a-group-game-2lmg</guid>
      <description>&lt;p&gt;Every article about Telegram games answers the same question: &lt;em&gt;which&lt;/em&gt; games. Truth or Dare, Never Have I Ever, Would You Rather, Most Likely To. Fine: that part is easy, and I've written one of those lists myself.&lt;/p&gt;

&lt;p&gt;Nobody writes about the part that actually decides whether your game night survives past round three: &lt;strong&gt;the chat mechanics&lt;/strong&gt;. A group chat is not a living room. Nobody takes turns. The person who types fastest answers first and quietly sets the answer everyone else copies. Half the group is reading on a phone under a table. The host, meaning you, is trying to run a game in a medium that has no concept of "whose turn it is".&lt;/p&gt;

&lt;p&gt;Telegram has real tools for this: polls, quiz polls, spoiler text, pinned messages, self-closing rounds. Most of what's written about them is copied from other articles, and a fair amount of it is simply out of date. So rather than trust it, I sent &lt;strong&gt;24 live requests to Telegram's own API&lt;/strong&gt; : real polls, real spoilers, real pins, and I wrote down exactly what came back.&lt;/p&gt;

&lt;p&gt;Seven of the 24 failed. Two of them "succeeded" while doing something other than what I asked, which is worse, because those are the two you ship to your friends without noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I tested it
&lt;/h2&gt;

&lt;p&gt;I run a party game on Telegram, so the questions weren't hypothetical: I wanted to know which of these mechanics I could rely on when a group of eight people is mid-round and impatient.&lt;/p&gt;

&lt;p&gt;The method was deliberately boring. Each mechanic became one request to &lt;a href="https://core.telegram.org/bots/api#sendpoll" rel="noopener noreferrer"&gt;Telegram's Bot API&lt;/a&gt;, using &lt;code&gt;sendPoll&lt;/code&gt;, &lt;code&gt;sendMessage&lt;/code&gt; and &lt;code&gt;pinChatMessage&lt;/code&gt;, with one variable changed at a time: a poll question at 300 characters and then at 301, an option at 100 and then at 101, a quiz with a correct answer and then without one. Whatever the API returned, success or error, went straight into a results file. The screenshot below is generated from that file, not typed by hand.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6y61zdgsirbvl5kqv5t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6y61zdgsirbvl5kqv5t.png" alt="Results table of 24 live requests to the Telegram Bot API, showing which game-night mechanics were accepted and which were rejected, with the API's own error messages" width="800" height="814"&gt;&lt;/a&gt; All 24 requests. Green rows were accepted; red rows carry Telegram's own error text. The two red rows with code 200 are the dangerous ones.&lt;/p&gt;

&lt;p&gt;One thing I could not test, and won't pretend otherwise: the per-group rate limit. My test account is restricted from creating new groups, so the requests went to a one-to-one chat with my bot, where 22 back-to-back messages went through in 6.1 seconds without a single block. That number does not transfer to a group. Telegram's own &lt;a href="https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this" rel="noopener noreferrer"&gt;bot FAQ&lt;/a&gt; puts the guidance at roughly 20 messages per minute in the same group, and everything I say about pacing below follows their figure, not mine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five things that break a round
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The 100-character option cap hits the exact game you most want to poll
&lt;/h3&gt;

&lt;p&gt;A poll question can be 300 characters. Each option can only be 100. I got &lt;code&gt;poll options length must not exceed 100&lt;/code&gt; at 101 characters, and success at exactly 100.&lt;/p&gt;

&lt;p&gt;That sounds generous until you look at what you're actually pasting. I ran all 255 prompts from my own game pack through those two limits. Zero exceeded the question cap. Twenty exceeded the option cap, and &lt;strong&gt;19 of those 20 were Would You Rather&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Which makes sense once you see it. A Would You Rather line is already a question &lt;em&gt;plus&lt;/em&gt; its two answers glued together: "Would you rather have unlimited money but no free time, or unlimited free time but just enough money?" runs to 138 characters. Paste that whole line as an option and Telegram rejects it. Would You Rather is the game that maps most naturally onto a poll, and it's the one whose text most reliably breaks the poll.&lt;/p&gt;

&lt;p&gt;The fix is a split, not an edit. The dilemma goes in the poll &lt;em&gt;question&lt;/em&gt; (300 characters, so it always fits; my longest was 101). The two halves become the two options: "Money, no time" / "Time, no money". When I re-ran the same 30 Would You Rather prompts that way, &lt;strong&gt;all 30 fit&lt;/strong&gt; , without cutting a single word.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Host shortcut:&lt;/strong&gt; if a prompt is too long to be an option, it was never an option. It was a question with the answers written into it. Split it, don't trim it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The option limit is 12, not the 10 you'll read everywhere
&lt;/h3&gt;

&lt;p&gt;Twelve options were accepted and created normally. Thirteen came back &lt;code&gt;poll can't have more than 12 options&lt;/code&gt;. A lot of current advice still says ten, which was true of an earlier version of the API and quietly stopped being true.&lt;/p&gt;

&lt;p&gt;This matters for exactly one thing, and it's the most popular game in the genre: Most Likely To. You want one option per player, so the group can vote for each other. Twelve options means a group of up to twelve fits in a single poll. At thirteen people you either split the vote across two polls, which fractures it, or drop the poll and take answers as replies.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Quiz mode demands a right answer, so it's wrong for opinion games
&lt;/h3&gt;

&lt;p&gt;A quiz poll without a designated correct option is rejected: &lt;code&gt;correct quiz option list must be non-empty&lt;/code&gt;. With one, it works, and you can attach an explanation that pops up after the vote.&lt;/p&gt;

&lt;p&gt;So quiz mode is for trivia, where there genuinely is a correct answer, and the explanation slot is the best feature nobody uses: it lets the poll teach instead of just scoring. For anything with no right answer (Would You Rather, Most Likely To, Never Have I Ever) you want a regular poll. Reach for quiz mode there and Telegram forces you to declare one of your friends' opinions officially correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Polls are anonymous by default, which silently kills Most Likely To
&lt;/h3&gt;

&lt;p&gt;Anonymity is the default, and that default is exactly backwards for half these games.&lt;/p&gt;

&lt;p&gt;Never Have I Ever wants anonymity. People answer honestly precisely because nobody sees who tapped "I have". Most Likely To wants the opposite: the accusation &lt;em&gt;is&lt;/em&gt; the game. "Most likely to reply at 3am: Ann, Bo, Cy" is only funny if you can see that four people voted for Bo. Run it anonymously and you get a bar chart, which nobody laughs at.&lt;/p&gt;

&lt;p&gt;Turning anonymity off is one toggle when you create the poll, and it's the single highest-value decision in the whole session. My rule: &lt;strong&gt;confessions anonymous, accusations public.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Two requests that succeed while doing the wrong thing
&lt;/h3&gt;

&lt;p&gt;These are the two red rows carrying code 200, and they're the reason I ran this test at all. An outright rejection you notice immediately, a silent substitution you ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spoiler formatting is ignored inside a poll question.&lt;/strong&gt; Telegram &lt;a href="https://telegram.org/blog/reactions-spoilers-translations" rel="noopener noreferrer"&gt;introduced spoiler text in 2021&lt;/a&gt;, and it's the mechanic that finally makes hidden-answer games work in a chat: the answer sits there covered until someone taps it. Write a poll question as &lt;code&gt;Guess: ||Bo||&lt;/code&gt; expecting the name to be hidden, and the API returns success, then delivers the question with the pipes visible as ordinary characters. The answer isn't hidden. It's printed, in a slightly uglier font, to everyone.&lt;/p&gt;

&lt;p&gt;Spoilers only work in ordinary messages. So the pattern is two messages, not one: poll first, reveal second, spoiler applied to the reveal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bots cannot schedule anything.&lt;/strong&gt; I sent a message with a scheduling timestamp one hour in the future. The API said success and delivered it instantly. My "scheduled" message arrived 3,599 seconds before its own schedule. There is no scheduling parameter for bots; the API just ignores what it doesn't recognise instead of telling you.&lt;/p&gt;

&lt;p&gt;Scheduling does exist, in the Telegram app, on your side: compose the message, hold the send button, pick a time. That's how you have the rules card land in the group at 20:00 without being awake for it. It is not something you can automate through a bot, and if a tool promises you that, it is doing something else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two mechanics worth adding on purpose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Self-closing rounds.&lt;/strong&gt; A poll can carry an open period. I set 30 seconds and it was created with a 30-second life. This is the closest Telegram gets to a turn timer. It ends the round for you, so the game doesn't die of one person "answering later", and it applies pressure that makes answers funnier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pin the rules, then edit the pin.&lt;/strong&gt; Pinning worked, and so did editing the text of an already-sent message. Together those are a live scoreboard: pin one message at the start, edit it as the night goes, and anyone arriving late reads the current state instead of scrolling. Editing a pin doesn't re-notify the group, so it's cheap to update often.&lt;/p&gt;

&lt;p&gt;One thing the API will not do is protect you from a broken round. A poll with a &lt;em&gt;single&lt;/em&gt; option was accepted without complaint: a poll nobody can meaningfully vote in, delivered with a cheerful success code. Telegram checks lengths and counts. It does not check whether your game makes sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  The host protocol this adds up to
&lt;/h2&gt;

&lt;p&gt;Strip out the API detail and what's left is a short list of things a host does, all of which exist because the medium has no turns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Name one host per session.&lt;/strong&gt; Not a rotation, not a democracy. One person sends the prompts and closes the rounds, because otherwise two people post round four simultaneously and the thread splits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the rules, including the pace.&lt;/strong&gt; "One round every ten minutes, replies by reply-thread." Half the failures aren't disagreement, they're people guessing at the tempo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Poll first, talk second.&lt;/strong&gt; A poll captures everyone's answer before the loudest voice anchors it. In an open-text round the first reply becomes the template for every reply after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two passes each, then the round closes.&lt;/strong&gt; The single rule that keeps quiet people in the game. It caps the extroverts without singling anyone out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Space the sends.&lt;/strong&gt; Around 20 messages per minute to one group, per Telegram's guidance. Twelve prompts fired back to back is also just unreadable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spoiler the reveals.&lt;/strong&gt; Separate message, spoiler applied there, never in the poll question.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is clever. It's just the set of things that turn out to be load-bearing once you stop assuming a group chat behaves like a room.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try the free game first
&lt;/h3&gt;

&lt;p&gt;Truth or Dare, Never Have I Ever and Would You Rather, with 151 questions per language, English and Ukrainian, no signup and no backend that can be switched off. Open it in the chat and start a round.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://t.me/charlie_party_bot/partygame?startapp=hostguide" rel="noopener noreferrer"&gt;Open the Party Game&lt;/a&gt; No Telegram? It runs in a browser too: &lt;a href="https://charliemorrison.dev/party-game/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=how-to-run-a-telegram-game-night" rel="noopener noreferrer"&gt;charliemorrison.dev/party-game&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Want the rounds already built for polls?
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;The Telegram Party Pack&lt;/strong&gt; is the version of this with the work done: 255 prompts across six games, each written to fit inside Telegram's limits, plus poll-ready files where the Would You Rather dilemmas are already split into question and options, the exact split this test says you need. Includes the host guide: pinned rules, pacing, spoiler reveals and keeping a round legible when replies arrive out of order.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/buy/c7bd4341-6eb3-4acc-b8e1-7946e1413b98?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=telegram&amp;amp;utm_content=how-to-run-a-telegram-game-night" rel="noopener noreferrer"&gt;Get the pack — $9.99&lt;/a&gt; The game above stays free, no signup.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How many options can a Telegram poll have?&lt;/p&gt;

&lt;p&gt;Twelve. Tested directly: 12 options were created normally, 13 came back &lt;code&gt;poll can't have more than 12 options&lt;/code&gt;. Plenty of guides still say ten, which was true of an older API version. The question is capped at 300 characters and each option at 100.&lt;/p&gt;

&lt;p&gt;Why can't I see who voted in my Telegram poll?&lt;/p&gt;

&lt;p&gt;Because polls are anonymous unless you say otherwise, and most people never change it. For accusation games like Most Likely To that's fatal, because seeing who picked whom is the game. Turn anonymity off for those, leave it on for confession-style rounds where hiding the vote is what makes people honest.&lt;/p&gt;

&lt;p&gt;Can I schedule game-night messages from a bot?&lt;/p&gt;

&lt;p&gt;No. The Bot API has no scheduling parameter and doesn't complain if you invent one. My message with a timestamp an hour ahead was delivered immediately with a success code. Scheduling lives in the Telegram apps: compose, hold send, pick a time.&lt;/p&gt;

&lt;p&gt;Do spoiler tags work inside a poll question?&lt;/p&gt;

&lt;p&gt;No. Formatting is ignored in poll questions, so &lt;code&gt;||spoiler||&lt;/code&gt; arrives with the pipes visible and nothing hidden. Send the poll, then reveal the answer in a separate message with the spoiler applied there.&lt;/p&gt;

&lt;p&gt;Which games work best as polls, and which as plain messages?&lt;/p&gt;

&lt;p&gt;Polls suit anything with fixed choices: Would You Rather (split into question and options), Most Likely To (one option per player, up to twelve), Never Have I Ever (two options, kept anonymous). Plain messages suit anything open-ended, such as Truth or Dare, Story Chain and Paranoia, where the answer is written, not chosen, and the spoiler reveal does the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Telegram party games and bots in 2026: which games to play, if you're still choosing.&lt;/li&gt;
&lt;li&gt;I messaged 18 "best" Telegram game bots — 7 never replied: why I stopped recommending bots you can't verify.&lt;/li&gt;
&lt;li&gt;The Telegram tech quiz game: quiz mode used the way this test says it should be.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/how-to-run-a-telegram-game-night/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>api</category>
    </item>
    <item>
      <title>I Messaged 18 'Best' Telegram Game Bots. 7 Never Replied</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 27 Jul 2026 22:21:54 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-messaged-18-best-telegram-game-bots-7-never-replied-2a1b</link>
      <guid>https://dev.to/charliemorrison/i-messaged-18-best-telegram-game-bots-7-never-replied-2a1b</guid>
      <description>&lt;p&gt;Search for something like "best Telegram games" and you'll get a dozen confident listicles. Each one hands you a set of &lt;code&gt;@handles&lt;/code&gt;: Hangman, 2048, Werewolf, Poker, Snake. What none of them tell you is which of those bots is still &lt;em&gt;running&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That's not a nitpick. I run a Telegram game myself, and in May 2026 I switched off the chat-command backend behind it. The bot account stayed. Its profile page kept loading. My own article kept telling readers to add it to a group and type &lt;code&gt;/play&lt;/code&gt; — into total silence, for weeks, before I noticed. Nobody complained, because a dead bot doesn't produce a complaint. It produces nothing.&lt;/p&gt;

&lt;p&gt;So I stopped guessing and measured it. I took every bot recommended by three "best Telegram games" articles currently ranking on Google, sent each one a real &lt;code&gt;/start&lt;/code&gt; from a real Telegram account, and waited to see what came back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Eleven answered. Seven said nothing at all.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How the test worked
&lt;/h2&gt;

&lt;p&gt;I wanted this to be boring and reproducible rather than clever, so the method is exactly what a person would do by hand — just automated so all 18 got identical treatment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sample&lt;/strong&gt; is the union of the bots recommended by three articles ranking for "best Telegram game bots" in July 2026: a 16-game roundup on LightXtremeVPN, MakeUseOf's "8 Fun Telegram Game Bots You Should Try", and Membertel's "5 best Telegram Game Bots". Deduplicated, that's 18 distinct handles. I didn't hand-pick them and I didn't drop any.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The probe&lt;/strong&gt; is one message: &lt;code&gt;/start&lt;/code&gt;, the command every Telegram bot is expected to handle, sent from my own account (&lt;a class="mentioned-user" href="https://dev.to/charliemorrison"&gt;@charliemorrison&lt;/a&gt;) rather than a fresh burner, so nothing could be blamed on an unusual account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wait&lt;/strong&gt; is 15 seconds. That is extremely generous — a live bot replies in well under a second, because the reply is a program responding, not a person typing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The verdict&lt;/strong&gt; is based on new inbound messages only. I record the newest message ID in the chat &lt;em&gt;before&lt;/em&gt; sending, then count only replies newer than that. Otherwise an old message from a previous visit would make a dead bot look alive — which, on two of these chats, it would have.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pacing:&lt;/strong&gt; 6–11 seconds of randomised delay between bots, so the run looks like a person poking around rather than a flood.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here is the actual run:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkww0yjxuyoo56armuq20.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkww0yjxuyoo56armuq20.png" alt="Terminal output listing all 18 Telegram game bots with the result of sending /start to each: 11 answered, 7 silent" width="800" height="549"&gt;&lt;/a&gt; The real output. 15 seconds of patience per bot, 18 bots, no cherry-picking.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven that said nothing
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bot&lt;/th&gt;
&lt;th&gt;Listed as&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;@PlayGame2048Bot&lt;/td&gt;
&lt;td&gt;2048 Puzzle&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@Snakeslite_official_bot&lt;/td&gt;
&lt;td&gt;SnakeLite&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@HangBot&lt;/td&gt;
&lt;td&gt;Hangman&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@dreamersbot&lt;/td&gt;
&lt;td&gt;Dreamers&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@ArenaGameTelegramBot&lt;/td&gt;
&lt;td&gt;Arena Game RPG&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@chessybot&lt;/td&gt;
&lt;td&gt;Chessy&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;@LumberjackBot&lt;/td&gt;
&lt;td&gt;Lumberjack&lt;/td&gt;
&lt;td&gt;Silent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of those handles resolves. Open any of them in Telegram and you get a normal-looking bot profile with a name and a Start button. Press Start and the chat stays empty. There is no error, no "this bot is no longer available", no hint that you're talking to a switched-off machine. The most common reaction to that is to assume &lt;em&gt;you&lt;/em&gt; did something wrong.&lt;/p&gt;

&lt;p&gt;Worth flagging: &lt;strong&gt;Lumberjack is a special case that shows exactly how these lists rot.&lt;/strong&gt; The standalone @LumberjackBot is silent — but LumberJack the game is still perfectly playable, because it lives inside @gamebot, which answered immediately. One article recommends the working route, another recommends a handle that leads nowhere, and both describe the same game. If you followed the second one you'd conclude the game is gone. It isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eleven that answered
&lt;/h2&gt;

&lt;p&gt;These replied within the window, most of them instantly: @gamebot, @ChessBot, @xoBot, @unobot, @QuizariumBot, @wordibot, @RatherGameBot, @werewolfbot, @chat_against_humanity_bot, @PokerBot and @Gamee.&lt;/p&gt;

&lt;p&gt;Two details are worth having before you pick one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Several are group-only, and that surprises people.&lt;/strong&gt; Quizarium's reply to a private &lt;code&gt;/start&lt;/code&gt; is: &lt;em&gt;"Yes, it's my command, but you can use it only within a group chat."&lt;/em&gt; Chat Against Humanity says the same in different words — you need a group and a couple of friends before anything happens. Not broken, just not a solo experience, which most roundups fail to mention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;@unobot's own first message points somewhere else:&lt;/strong&gt; &lt;em&gt;"Also check out @UnoDemoBot, a newer version of this bot with exclusive modes and features."&lt;/em&gt; The bot itself is telling you the recommendation is a version behind.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caveat I'd rather state than hide: this is a snapshot taken on 28 July 2026. A bot that answered today can go quiet next month — that is the entire point of the exercise. Treat the list as a demonstration of the method, not as a permanent verdict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bots die quietly
&lt;/h2&gt;

&lt;p&gt;The reason is structural, and once you see it you can predict which recommendations will rot.&lt;/p&gt;

&lt;p&gt;A Telegram bot is not a self-contained thing living on Telegram's servers. It's the Telegram-facing half of somebody's software. The platform's own documentation is blunt about the other half: &lt;a href="https://core.telegram.org/bots/faq" rel="noopener noreferrer"&gt;"In order for a bot to work, set up a bot account with @BotFather, then connect it to your backend server via our API."&lt;/a&gt; That backend is a machine someone rents, maintains and pays for every month.&lt;/p&gt;

&lt;p&gt;When the developer moves on — new job, lost interest, hosting bill no longer worth it — they stop paying for the server. They almost never delete the bot account, because deleting it takes deliberate effort and produces no benefit. So the account outlives the product. Telegram keeps rendering the profile from its own records, and your Start button keeps sending commands into a machine that is no longer there.&lt;/p&gt;

&lt;p&gt;This isn't unique to Telegram, it's just unusually invisible there. Pew Research Center's 2024 study on digital decay found that &lt;a href="https://www.pewresearch.org/data-labs/2024/05/17/when-online-content-disappears/" rel="noopener noreferrer"&gt;"a quarter of all webpages that existed at one point between 2013 and 2023 are no longer accessible"&lt;/a&gt;, and that 38% of pages from 2013 are gone. On the web, decay at least announces itself with a 404. A dead bot has no 404. It has an empty chat.&lt;/p&gt;

&lt;p&gt;Which means &lt;strong&gt;39% silent in this sample isn't a scandal — it's roughly what a decade of link rot looks like when it's applied to software that nobody can see rotting.&lt;/strong&gt; The scandal is that lists keep recommending them without ever pressing Start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug in my own test that nearly produced a fake result
&lt;/h2&gt;

&lt;p&gt;I'll include this because it's the most useful thing I learned, and because the first version of this article would have been wrong.&lt;/p&gt;

&lt;p&gt;My first run died four bots in. Everything after @ChessBot came back with the same error — &lt;code&gt;TypeNotFoundError: Could not find a matching Constructor ID&lt;/code&gt; — and the naive reading was that 12 bots were unreachable. That would have been a much more dramatic headline and completely false.&lt;/p&gt;

&lt;p&gt;What actually happened: my client library (Telethon 1.43.2) was receiving live account updates in the background, including an unrelated spam broadcast that used a newer Telegram data type than the library knows about. One unparseable object killed the connection's receive loop, and every later call inherited the corpse. The bots were fine. My tooling was lying to me.&lt;/p&gt;

&lt;p&gt;The fix was one argument — &lt;code&gt;receive_updates=False&lt;/code&gt;, so the server never pushes those updates to this client at all — and re-running gave clean results for 15 of 18. The last three still errored, so I re-probed those with a fresh connection each. All three came back conclusive: @Gamee alive, @chessybot and @LumberjackBot genuinely silent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transferable lesson:&lt;/strong&gt; when a tool reports a wall of identical failures, suspect the tool before you believe the finding. A real-world result is usually messy — some pass, some fail. Perfectly uniform failure, starting at an arbitrary point in the run, is the signature of something breaking in &lt;em&gt;your&lt;/em&gt; code, not of the world suddenly agreeing to be broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 15-second check you can do yourself
&lt;/h2&gt;

&lt;p&gt;You don't need any of my scripts. Before you trust a bot from any list, including mine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the bot and press &lt;strong&gt;Start&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Count to fifteen.&lt;/li&gt;
&lt;li&gt;If nothing arrived, the backend is off. Move on — there is nothing to fix on your end.&lt;/li&gt;
&lt;li&gt;If it's a group game, add it to a group and run its start command there before inviting people. Several of these only wake up in groups.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The one thing that proves nothing is the bot's profile loading. Every dead bot in this test has a perfectly healthy profile page. Telegram serves that from its own database; it has no idea whether the developer's server is still switched on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changed about the game I run
&lt;/h2&gt;

&lt;p&gt;Watching my own bot go silent, then measuring how normal that is, is why my party game no longer depends on a backend at all. It's a Mini App: the questions and the game logic ship inside the page Telegram opens, so there is no server left to switch off. It also opens in a normal browser, which means it survives even if I lose interest entirely — the failure mode that killed seven bots above simply doesn't exist for it.&lt;/p&gt;

&lt;p&gt;That's not a claim you should take on faith after an article about not taking claims on faith. Press Start and count to fifteen.&lt;/p&gt;

&lt;h3&gt;
  
  
  Test it yourself — it should answer instantly
&lt;/h3&gt;

&lt;p&gt;Truth or Dare, Never Have I Ever and Would You Rather. 151 questions per language, English and Ukrainian, no signup, no backend to die.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://t.me/charlie_party_bot/partygame?startapp=liveness" rel="noopener noreferrer"&gt;Open the Party Game&lt;/a&gt; No Telegram? It runs in a browser too: charliemorrison.dev/party-game&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Why does a Telegram bot stop replying?&lt;/p&gt;

&lt;p&gt;Because a bot is only the Telegram-facing half of the product. Telegram's documentation tells developers to connect a bot account to their backend server. When that server is switched off, unpaid for or abandoned, the bot account still exists and its profile still loads — but nothing answers your commands.&lt;/p&gt;

&lt;p&gt;How do I tell if a bot is dead before trusting a recommendation?&lt;/p&gt;

&lt;p&gt;Open it, press Start, wait about 15 seconds. A working bot answers almost instantly because the reply is automated. If nothing arrives, the backend isn't running. A profile page that loads normally proves nothing.&lt;/p&gt;

&lt;p&gt;Do dead Telegram bots show an error message?&lt;/p&gt;

&lt;p&gt;No, and that's what makes them hard to spot. No error, no warning, no retirement notice — you press Start and the chat stays empty, which most people read as their own mistake.&lt;/p&gt;

&lt;p&gt;Which recommended game bots still work?&lt;/p&gt;

&lt;p&gt;In this test, 11 of 18: GameBot, ChessBot, xoBot, unobot, QuizariumBot, wordibot, RatherGameBot, werewolfbot, chat_against_humanity_bot, PokerBot and Gamee. The seven silent ones were PlayGame2048Bot, Snakeslite_official_bot, HangBot, dreamersbot, ArenaGameTelegramBot, chessybot and LumberjackBot. Snapshot from 28 July 2026 — re-check before you rely on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Best Telegram party games and bots in 2026 — the roundup this test came out of, including why chat bots and Mini Apps fail differently.&lt;/li&gt;
&lt;li&gt;The Telegram tech quiz game — the same no-backend approach applied to a quiz.&lt;/li&gt;
&lt;li&gt;All the bots and Mini Apps I run — including the two I retired, marked as retired instead of quietly left up.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/i-messaged-18-telegram-game-bots/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance. It links to a free tool I built and run myself; there is no paid tier and no affiliate link in this post.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>telegram</category>
      <category>python</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I Generated a Month of Social Posts With One Free Tool — Here's the Mix That Held Up</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Tue, 21 Jul 2026 19:59:38 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-generated-a-month-of-social-posts-with-one-free-tool-heres-the-mix-that-held-up-2nc</link>
      <guid>https://dev.to/charliemorrison/i-generated-a-month-of-social-posts-with-one-free-tool-heres-the-mix-that-held-up-2nc</guid>
      <description>&lt;p&gt;Here is how most small accounts actually plan their social media: they don't. They open the app in the morning, remember they have a thing to sell, and post about the thing. The next day they feel guilty for being salesy, so they post nothing. Three days later a competitor does something and they fire off a reactive post. That is not a strategy, it is a mood, and the algorithm can smell the difference. The accounts that grow are boring in exactly one way: they show up with a &lt;em&gt;plan&lt;/em&gt; , and the plan is mostly not about them.&lt;/p&gt;

&lt;p&gt;I wanted to see what that plan looks like when you take the human indecision out of it. So instead of writing about content-calendar theory, I ran a real niche through a &lt;a href="https://charliemorrison.dev/content-calendar-generator?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=social&amp;amp;utm_content=i-generated-a-month-of-social-posts" rel="noopener noreferrer"&gt;free content calendar generator&lt;/a&gt; I put on this site, read every post it planned, and checked whether the balance it produced matches what the people who study this for a living recommend. This is that experiment: one frozen input, the raw output, and an honest account of what a machine gets right and wrong about a month of posting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The input I froze
&lt;/h2&gt;

&lt;p&gt;I gave it a deliberately unglamorous, real-world case -- the kind of account that actually struggles with this: a small &lt;strong&gt;fitness coaching&lt;/strong&gt; business on &lt;strong&gt;Instagram&lt;/strong&gt; , posting &lt;strong&gt;5 times a week&lt;/strong&gt; for &lt;strong&gt;4 weeks&lt;/strong&gt;. Twenty posts. No brand voice tuning, no cherry-picking; I took whatever the first generation handed me and read it top to bottom.&lt;/p&gt;

&lt;p&gt;The first thing I noticed was what it refused to do. I never told it "go easy on the selling." It decided that on its own. Out of twenty planned posts, only three were straight promotion. The other seventeen were teaching, asking, showing behind-the-scenes, or proving results -- the stuff that earns the right to sell in the first place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgykw7zexzpni45rpjgyl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fgykw7zexzpni45rpjgyl.png" alt="Screenshot of the content calendar generator output showing a balanced mix of educate, engage, behind-the-scenes, inspire and promote posts for a fitness niche on Instagram" width="800" height="1244"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The generated calendar for a fitness coach on Instagram -- each card carries a pillar badge, a hook, a CTA, and a best-time suggestion. Note how few of them are "Promote."&lt;/p&gt;

&lt;h2&gt;
  
  
  The mix it enforced (and why it's right)
&lt;/h2&gt;

&lt;p&gt;When I tallied the pillar badges across the twenty posts, the shape was clear. Educational content was the backbone, engagement posts came second, and promotion was deliberately a minority slice. Roughly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pillar&lt;/th&gt;
&lt;th&gt;Share of the month&lt;/th&gt;
&lt;th&gt;What it looked like&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Educate&lt;/td&gt;
&lt;td&gt;~35%&lt;/td&gt;
&lt;td&gt;"3 mobility drills before you squat," "why your scale lies to you"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engage&lt;/td&gt;
&lt;td&gt;~20%&lt;/td&gt;
&lt;td&gt;"What's the one exercise you avoid? Be honest." polls, this-or-that&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behind-the-scenes&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;td&gt;"My actual 6am prep," a client session setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inspire / proof&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;td&gt;a transformation, a client win, a mindset reframe&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promote&lt;/td&gt;
&lt;td&gt;~15%&lt;/td&gt;
&lt;td&gt;the coaching offer, a spot opening, a free consult&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If that ratio feels familiar, it should. It is a near-exact match for the frameworks marketers have converged on for years. The classic &lt;a href="https://www.orbitmedia.com/blog/social-media-rule-of-thirds/" rel="noopener noreferrer"&gt;social media rule of thirds&lt;/a&gt; splits your feed into roughly one-third promotion, one-third curated industry value, and one-third personal, relationship-building content. The even more common &lt;a href="https://ansira.com/blog/the-80-20-rule/" rel="noopener noreferrer"&gt;80/20 rule&lt;/a&gt; is blunter: 80% of your posts should inform, educate, or entertain, and only 20% should sell. The generator landed at about 15% promotion without being told either rule -- it just bakes the principle in so you can't drift back to selling every day.&lt;/p&gt;

&lt;p&gt;That drift is the entire problem the tool solves. Nobody plans to be a walking advertisement. It happens because promotion is the only post type that feels "productive" in the moment, so when you improvise, you over-index on it. A calendar quietly removes the improvisation. You are no longer deciding &lt;em&gt;whether&lt;/em&gt; today is a sales day; the plan already decided, and it said no most of the time.&lt;/p&gt;

&lt;p&gt;The cost of getting this wrong is invisible until it isn't. An account that sells in half its posts doesn't get a warning; it just slowly trains its audience that opening a notification means being pitched. People mute before they unfollow, so you never see the moment they check out -- you only see reach quietly sliding down over months while you post the same amount. The value-first mix is not a feel-good nicety. It is the thing that keeps the audience willing to see the 15% of posts where you actually ask for the sale. You earn the promote posts with the seventeen that came before them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the generated posts actually contained
&lt;/h2&gt;

&lt;p&gt;A pillar label is worthless if the post underneath it is filler, so I read the contents, not just the badges. Each card came with four things I would otherwise have to invent from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A specific idea&lt;/strong&gt; , not a category. Not "post something educational" but "the three warm-up mistakes I see every new client make." The difference between a prompt and a blank page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hook&lt;/strong&gt; written to survive the half-second thumb-scroll -- a question, a mild contradiction, or a number. Hooks are where most amateur posts die, and it is the one line I am worst at writing under time pressure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A platform-tuned CTA.&lt;/strong&gt; Instagram got "save this for your next session" and "DM me the word START," not the LinkedIn-flavored "thoughts?" that reads as tone-deaf on IG.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A best-time-to-post nudge and format hint&lt;/strong&gt; (reel vs carousel vs single image), so the calendar is a shooting schedule, not just a topic list.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Were they perfect, finished captions? No. They are strong first drafts -- the skeleton and the hook, with room for your real voice and your real client stories. But going from "I have no idea what to post for four weeks" to "I have twenty specific, balanced ideas with hooks" in about ten seconds is the entire value. The blank page is the tax; the tool pays it for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it can't do
&lt;/h2&gt;

&lt;p&gt;I would be lying if I sold this as a full content team. Three honest limits:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't know your voice or your stories.&lt;/strong&gt; The ideas are sound and the structure is right, but the thing that makes a fitness post &lt;em&gt;yours&lt;/em&gt; is the client who cried after their first pull-up, and the tool has never met her. You still have to pour the specifics in. It gives you the mold; you supply the metal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't watch what works.&lt;/strong&gt; A real calendar is a loop: post, read the analytics, do more of what landed. A generator gives you a strong month one. It cannot tell you that your carousels crush and your talking-head reels flop, because it never sees your numbers. After the first cycle, you are the one who has to prune and double down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doesn't replace consistency.&lt;/strong&gt; This is the quiet punchline of every content-mix framework, and the marketers who study it say it plainly: the ratio only works if you actually apply it week after week. A perfect calendar you post from for nine days and then abandon loses to a mediocre one you keep for six months. The tool removes the planning excuse. It cannot remove the showing-up part.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest verdict
&lt;/h2&gt;

&lt;p&gt;For anyone staring at an empty content calendar -- a solo founder, a freelancer, a small brand with no social hire -- generating a balanced month in one click is genuinely useful. It solves the blank-page problem, it enforces a value-first mix you would not hold to on your own, and every post lands with a hook and a CTA already attached. What it can't do is know your voice, read your analytics, or post for you. Treat it as the frame of the month, then fill it with real stories and adjust from your own numbers. The generator gets you a plan; you turn the plan into a following.&lt;/p&gt;

&lt;h3&gt;
  
  
  Skip the blank calendar entirely
&lt;/h3&gt;

&lt;p&gt;The 2026 Social Media Content Calendar bundles a full year of balanced post ideas plus 110 caption templates -- the same value-first mix, pre-written, so you spend your time filming instead of staring at a grid.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/33a1ec8f-6b1f-49fd-83b6-e8f69577814b?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=social&amp;amp;utm_content=i-generated-a-month-of-social-posts&amp;amp;checkout%5Bcustom%5D%5Bsrc%5D=blog_content_calendar" rel="noopener noreferrer"&gt;Get the 2026 Content Calendar -- $17&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;How much of my social content should actually be promotional?&lt;/p&gt;

&lt;p&gt;The two most-cited frameworks agree it's a minority. The 80/20 rule caps direct promotion at about 20% of posts; the rule of thirds puts it around one-third, with the rest split between industry value and relationship-building. In practice, aiming for 15-20% promotion and putting the rest into teaching, engaging, and proof keeps you from training your audience to scroll past you.&lt;/p&gt;

&lt;p&gt;Is a generated content calendar just generic filler?&lt;/p&gt;

&lt;p&gt;The topics are a starting frame, not a finished caption. A good generator gives you a specific idea, a hook, and a CTA per post -- which solves the blank-page problem -- but you still add your own voice, client stories, and real examples. Think first draft from a sharp assistant, not a done-for-you feed.&lt;/p&gt;

&lt;p&gt;How often should a small account post?&lt;/p&gt;

&lt;p&gt;Consistency beats volume. Three to five well-planned posts a week that you can sustain for months will outperform daily posting that burns you out in three weeks. Pick a cadence you can actually keep, plan it in advance so you're not deciding daily, and protect that schedule.&lt;/p&gt;

&lt;p&gt;Related reading: I also ran the numbers on the AI content-creation tools worth using in 2026, and the free social media AI helper here drafts captions and hooks once you know what you're posting. All of it follows the same rule as this post: plan the mix first, sell second.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/i-generated-a-month-of-social-posts/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>socialmedia</category>
      <category>contentmarketing</category>
      <category>productivity</category>
    </item>
    <item>
      <title>I Ran 4 Salary Negotiation Tones Through an AI Detector</title>
      <dc:creator>charlie-morrison</dc:creator>
      <pubDate>Mon, 20 Jul 2026 20:04:51 +0000</pubDate>
      <link>https://dev.to/charliemorrison/i-ran-4-salary-negotiation-tones-through-an-ai-detector-3b1j</link>
      <guid>https://dev.to/charliemorrison/i-ran-4-salary-negotiation-tones-through-an-ai-detector-3b1j</guid>
      <description>&lt;p&gt;Half the salary advice online now ends the same way: paste your role into some generator, pick a tone, copy the script it spits out, send it. I have nothing against that. A script beats freezing up when a recruiter asks what number you had in mind. But there is a quieter problem nobody mentions. Hiring managers read a lot of email, and in 2026 a growing share of them can smell a machine-written message from the subject line. If your carefully generated counter-offer reads like it came out of a chatbot, the number is not the thing that sinks you. The &lt;em&gt;voice&lt;/em&gt; is.&lt;/p&gt;

&lt;p&gt;So I stopped guessing and measured it. I took one free &lt;a href="https://charliemorrison.dev/salary-negotiation?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=career&amp;amp;utm_content=i-ran-4-salary-tones-through-ai-detector" rel="noopener noreferrer"&gt;salary negotiation script tool&lt;/a&gt;, gave it a single scenario (countering a lowball offer) and generated the exact same negotiation in all four of its tones. Then I fed each result into an AI-text detector and wrote down the score. Same facts, same numbers, same person. Only the tone changed. The spread surprised me, and the tone I expected to sound the most robotic came out the most human.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test: one offer, four tones, one detector
&lt;/h2&gt;

&lt;p&gt;I kept every input frozen so the only moving part was tone. The scenario was "countering a lowball offer." The role was Senior Backend Engineer in tech, eight years of experience, a current offer of $120,000, and a target of $150,000. For the three achievement bullets I used real, specific wins: cut cloud spend 34% by re-architecting the billing pipeline, shipped a fraud-detection service now screening four million events a day, mentored three juniors with two promoted inside a year. Concrete numbers, because vague bullets are their own separate problem.&lt;/p&gt;

&lt;p&gt;Then I generated the script four times, switching only the tone selector: &lt;strong&gt;Confident &amp;amp; Direct&lt;/strong&gt;, &lt;strong&gt;Collaborative &amp;amp; Warm&lt;/strong&gt;, &lt;strong&gt;Data-Driven &amp;amp; Analytical&lt;/strong&gt;, and &lt;strong&gt;Humble but Firm&lt;/strong&gt;. Each run produced two script versions plus a short tips block. I ran the raw script text of each tone through the same AI-writing detector I use on my own drafts before anything ships. It returns a 0-to-100 score where higher means more machine-like. Here is the data-driven run mid-generation, so you can see the tool and the inputs I am describing:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fue4i0t3rz8wvw665nwls.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fue4i0t3rz8wvw665nwls.png" alt="Salary negotiation script tool generating a data-driven counter-offer for a Senior Backend Engineer, showing the filled inputs and generated script" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The free salary script tool generating the "Data-Driven" counter-offer, using the same frozen inputs, one of four tone runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scores
&lt;/h2&gt;

&lt;p&gt;Lower is better here. A low score means the text reads like a person wrote it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tone&lt;/th&gt;
&lt;th&gt;AI-likeness (0-100)&lt;/th&gt;
&lt;th&gt;Reads as…&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Data-Driven &amp;amp; Analytical&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clearly human&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Humble but Firm&lt;/td&gt;
&lt;td&gt;23&lt;/td&gt;
&lt;td&gt;Mostly human&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collaborative &amp;amp; Warm&lt;/td&gt;
&lt;td&gt;28&lt;/td&gt;
&lt;td&gt;Borderline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Confident &amp;amp; Direct&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Most machine-like&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I had it backwards going in. If you had asked me to guess, I would have said the "Data-Driven" tone, with its percentiles, market bands, and the word &lt;em&gt;benchmarked&lt;/em&gt; , would read the most like a spreadsheet wrote it. It read the most human of the four, by a wide margin. And "Confident &amp;amp; Direct," the tone that is supposed to sound like a real person putting their foot down, scored the highest on the robot scale. The reason turns out to be simple once you read the two side by side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Confident" read like a bot
&lt;/h2&gt;

&lt;p&gt;Here is the confident version, lightly trimmed:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Thank you for the offer of $120,000. I want to be straightforward: that's significantly below what I'm seeing for Senior Backend Engineer positions in tech… I need to be at $150,000 to accept. I'm not trying to negotiate for the sake of it -- that number reflects my market value and the results I'll deliver. Is there flexibility on your end?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Read it out loud and the tells stack up. &lt;em&gt;" I want to be straightforward." "I have to be honest." "I'm not trying to negotiate for the sake of it."&lt;/em&gt; These are announcements of a posture rather than the posture itself, the verbal equivalent of a header that says &lt;code&gt;TONE: CONFIDENT&lt;/code&gt;. Real confident people rarely narrate that they are being direct; they are just direct. Then it closes on a soft rhetorical question, &lt;em&gt;" Is there flexibility on your end?"&lt;/em&gt;, which is the exact hedge-after-a-bold-claim rhythm that AI detectors are tuned to notice. The message is trying to &lt;em&gt;perform&lt;/em&gt; confidence, and performance is what reads as synthetic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "Data-Driven" read human
&lt;/h2&gt;

&lt;p&gt;Now the same offer in the data tone:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I'd like to discuss the offer of $120,000. Here's my analysis: market rate for Senior Backend Engineer in tech sits in the $150,000 range… The offer sits below the 25th percentile for someone with eight years of experience… My counter: $150,000, which is in line with the median. I'd rather we both start with a number grounded in data. Does this align with your internal bands?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is almost no throat-clearing. It states a fact, attaches a number to it, and moves on to &lt;em&gt;" below the 25th percentile," "in line with the median," "your internal bands."&lt;/em&gt; Concrete nouns crowd out the filler phrases, and the closing question is a genuine ask about the company's pay bands, not a rhetorical softener. Detectors key on generic connective tissue, the "I want to be honest with you" padding, and this version simply has less of it. The lesson is not "always pick the data tone." It is that &lt;strong&gt;specificity is what makes writing sound human&lt;/strong&gt; , and the data tone happened to force the most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually means for your next email
&lt;/h2&gt;

&lt;p&gt;You do not need to abandon script tools. You need to edit their output like a human would before it leaves your outbox. Three moves cover most of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Delete the posture announcements.&lt;/strong&gt; Cut every "I want to be honest," "let me be straightforward," "I'm not trying to be difficult." If the sentence describes your tone instead of doing the work, it goes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace one vague clause with a number.&lt;/strong&gt; "Below market" becomes "below the 25th percentile for eight years' experience." Specificity reads as human and, separately, it is more persuasive. Harvard Business Review's long-standing negotiation guidance is that you justify the ask with evidence, not adjectives (&lt;a href="https://hbr.org/2014/04/15-rules-for-negotiating-a-job-offer" rel="noopener noreferrer"&gt;HBR's 15 rules for negotiating an offer&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill the rhetorical closer.&lt;/strong&gt; "Is there flexibility on your end?" is filler. "Can you match $150,000, or should we talk about the equity side?" is a real question that forces a real answer. Indeed's script guidance makes the same point: state the counter and the reason clearly and stop (&lt;a href="https://www.indeed.com/career-advice/pay-salary/salary-negotiation-script" rel="noopener noreferrer"&gt;Indeed's salary negotiation scripts&lt;/a&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Run whichever tone fits the relationship, then spend two minutes stripping it of the four or five phrases that scream "generated." That two minutes is the difference between a message a hiring manager reads as a candidate who did their homework and one they read as a candidate who pasted a template.&lt;/p&gt;

&lt;h3&gt;
  
  
  Generate the script, then make it sound like you.
&lt;/h3&gt;

&lt;p&gt;The Job Search AI Toolkit is 100+ prompts for the whole offer stage: counter-offer language that avoids the robotic tells, the follow-up note after you send it, and the "what's your range" answer that comes before any of it. Built for editing, not blind pasting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://charliemorrison.lemonsqueezy.com/checkout/buy/0c3f51d6-9089-466e-ada4-58a0b22036e0?utm_source=site&amp;amp;utm_medium=blog&amp;amp;utm_campaign=career&amp;amp;utm_content=i-ran-4-salary-tones-through-ai-detector&amp;amp;checkout%5Bcustom%5D%5Bsrc%5D=blog_salary_tone" rel="noopener noreferrer"&gt;Get the Job Search AI Toolkit -- $12&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  One caveat about the number itself
&lt;/h2&gt;

&lt;p&gt;An AI detector measures how machine-like the prose is, not whether the negotiation is any good. A perfectly human-sounding email that asks for a number with no justification behind it will still lose to a slightly stiff one backed by real market data. The tone test is about clearing a threshold, so you avoid tripping the "this is a bot" reflex. It is not about winning the negotiation on style. Get the message past the human filter first, then let the evidence do the arguing. If the underlying number is wrong, the smoothest wording in the world will not save it, which is a separate exercise in answering the salary-expectation question before you ever get to the counter.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;Do salary negotiation scripts really sound like AI to hiring managers?&lt;/p&gt;

&lt;p&gt;Some tones do. In my test, the "Confident &amp;amp; Direct" script scored 35 out of 100 on an AI detector while the "Data-Driven" version scored 2, from the exact same inputs. The difference was filler phrases like "I want to be straightforward" and a rhetorical closing question, both patterns detectors flag. A hiring manager who reads a lot of email notices the same rhythm.&lt;/p&gt;

&lt;p&gt;Which tone should I use for a counter-offer?&lt;/p&gt;

&lt;p&gt;Match the relationship, but bias toward specificity. The data-driven framing read the most human in my test because it swapped vague adjectives for concrete numbers: percentiles, medians, your role's pay band. You can use any tone and get the same effect by editing out the posture announcements and adding one hard number to justify the ask.&lt;/p&gt;

&lt;p&gt;How do I make a generated negotiation email sound less robotic?&lt;/p&gt;

&lt;p&gt;Delete the phrases that describe your tone instead of showing it ("let me be honest," "I want to be direct"), replace one vague clause with a specific number, and turn any rhetorical closing question into a real one that forces a decision. Those three edits removed most of the AI-likeness in the scripts I tested.&lt;/p&gt;

&lt;p&gt;Is it a bad idea to use a salary script tool at all?&lt;/p&gt;

&lt;p&gt;No. A script is a good starting point, especially if negotiating makes you freeze. The mistake is pasting the output unedited. Generate the structure, then spend two minutes stripping the four or five generic phrases that make it read as machine-written before you send it.&lt;/p&gt;

&lt;p&gt;Does an AI detector score tell me if my negotiation will succeed?&lt;/p&gt;

&lt;p&gt;No. It only measures how human the prose reads, not whether the ask is justified. A human-sounding email with no evidence behind the number still loses to a stiff one backed by market data. Use the tone check to clear the "this is a bot" filter, then let concrete numbers carry the actual argument.&lt;/p&gt;

&lt;p&gt;Related reading: I generated 30 salary negotiation scripts to see which openings recruiters actually respond to, and what happened across 38 counter-offer negotiations. Both use the same "test the advice, don't repeat it" approach as this post.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://charliemorrison.dev/blog/i-ran-4-salary-tones-through-ai-detector/" rel="noopener noreferrer"&gt;charliemorrison.dev&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This post was written with AI assistance and links to a free tool I built; the tool has an optional paid upgrade, so I may earn a small commission if you choose it — at no extra cost to you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>abotwrotethis</category>
      <category>career</category>
      <category>jobsearch</category>
      <category>writing</category>
    </item>
  </channel>
</rss>
