← All posts
Tutorial

Telegram Formatting Errors: Send Dynamic Text Safely With HTML

If a Telegram message works with fixed text but fails when you insert a customer name or generated answer, inspect the formatting mode before retrying. For an unformatted message, omit parse_mode and send the original text. For HTML, escape dynamic text before inserting it into trusted markup. JSON encoding alone does not protect a string from Telegram’s formatting parser.

Key takeaways

  • Treat plain text, HTML, and MarkdownV2 as different rendering choices—not interchangeable labels.
  • Keep original content separate from its formatted output; escape once at the rendering boundary.
  • Diagnose a confirmed formatting rejection separately from a rate limit or an unknown send outcome.
  • In UnifyPort, the documented extension is provider_data.parse_mode, not a top-level Bot API field.

First isolate the formatting layer

Telegram’s Bot API reference documents parse_mode for parsing text entities and supports HTML formatting with a specified set of tags. It is not a general browser HTML renderer. The reference also distinguishes legacy Markdown from MarkdownV2; changing the mode without changing the renderer is not a repair.

Start with one controlled destination and a short, non-sensitive message:

  1. Inspect the actual serialized request produced by your SDK or automation tool. A default setting may add a parsing mode even when your own code does not.
  2. Send short original text without formatting options. Do not reuse a string already escaped for HTML or Markdown.
  3. Add a small trusted HTML template with static content.
  4. Insert dynamic values only after escaping them for HTML text content.
  5. Inspect the returned API result and the displayed message separately.

If plain text also fails, investigate the actual error, destination, authorization, and length rather than assuming a parser problem. If you currently embed sends in a webhook response, the separate sendMessage request comparison explains why that response mechanism cannot give you an inspectable send result.

Render dynamic values, not arbitrary markup

For HTML mode, Telegram requires literal <, >, and & characters outside tags or entities to be represented by their HTML entities. The following illustrative JavaScript renderer handles text inserted between tags. It is not a general HTML sanitizer, a Markdown converter, or a URL-attribute validator.

function escapeTelegramHtmlText(value) {
  if (typeof value !== 'string') {
    throw new TypeError('Expected text');
  }
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

function renderSupportAnswer(customerName, answer) {
  return `<b>${escapeTelegramHtmlText(customerName)}</b>\n` +
    escapeTelegramHtmlText(answer);
}

const rendered = renderSupportAnswer(
  'R&D <Team>',
  'Use x < 5 & keep the original value.'
);

The ampersand replacement comes first so the entities created by later replacements are not escaped again. The expected rendered source is:

<b>R&amp;D &lt;Team&gt;</b>
Use x &lt; 5 &amp; keep the original value.

The recipient should see the original punctuation, with the name bold. Keep the tag structure application-owned. A customer name or AI-generated answer containing markup should remain content, not become a new formatting instruction.

Do not escape the complete assembled template: that turns the trusted tags into visible text. Conversely, do not skip escaping just because JSON.stringify is used later. JSON serialization and Telegram HTML parsing are different layers.

For links, use a separate reviewed builder with destination validation and attribute-safe handling. This helper deliberately does not interpolate dynamic attributes. If you need more formatting, expand a tested renderer using Telegram’s documented tag set rather than accepting arbitrary HTML.

Map the renderer to the correct API

IntegrationText fieldFormatting option
Official Telegram Bot API sendMessagetextparse_mode
UnifyPort normalized text sendmessage.textprovider_data.parse_mode

UnifyPort’s unofficial interface uses a connected messaging account and a different request envelope. Its text-message reference documents POST /v1/messages, non-empty message.text, and provider-specific parse_mode. The message-support matrix scopes that extension to Telegram; it is not a cross-channel HTML guarantee.

This request builder uses the renderer above. Supply the actual connected account ID and the selected conversation’s ID and type. It constructs JSON only; it does not send, retry, or claim a completed integration test.

function buildTelegramReply({ accountId, conversation, name, answer }) {
  return {
    account_id: accountId,
    to: { id: conversation.id, type: conversation.type },
    message: {
      type: 'text',
      text: renderSupportAnswer(name, answer)
    },
    provider_data: { parse_mode: 'HTML' }
  };
}

Send the resulting body through the documented endpoint with server-side X-Api-Key authentication. For plain text, omit provider_data.parse_mode and use the original answer instead of the HTML-rendered string. Do not copy Bot API chat_id, entities, or other parameters into the normalized body without a documented mapping.

A shared Telegram, LINE, or WhatsApp worker should select a renderer per provider. Shared routing does not imply shared formatting syntax.

Test content boundaries before adding retries

Use synthetic fixtures, not customer transcripts:

FixtureWhat to verify
A name containing & and angle bracketsOriginal characters display; only trusted tags format text
A literal &amp; in the original answerIt remains literal content rather than being silently decoded
Emoji, multilingual text, and newlinesContent survives serialization and rendering
Empty input or a long generated answerYour validation and length policy run before sending
The same source rendered twiceBoth renders start from the original, not the previous output

For longer content, split the source into meaningful sections and render each section independently. Do not cut a serialized HTML string at an arbitrary index: that can cut a tag or entity. Check the selected method’s current length rules; this helper does not implement a length validator or rich-message support.

A confirmed formatting rejection needs a content correction, not a cooldown loop. For flood-control responses, follow the separate Telegram 429 send-queue guide. For a timeout without a response, retain an unknown outcome; do not automatically send a plain-text replacement that could duplicate an already delivered reply.

FAQ

Does valid JSON mean the formatted message is valid?

No. JSON decoding can succeed while the resulting text violates the selected formatting rules.

Should I switch every failure to MarkdownV2?

No. MarkdownV2 has its own escaping rules. Choose a mode deliberately and use a renderer written for it.

Why are HTML entities visible in a plain-text message?

Check whether HTML-escaped output was reused after removing the parsing mode. Plain-text rendering should start from the original content.

Can I apply this helper to an entire HTML document?

No. It only escapes dynamic text between trusted tags. It does not validate arbitrary markup, attributes, links, or message length.

Next step and sources

Implement separate plain-text and HTML renderers, then test a controlled Telegram conversation using the UnifyPort text-send contract if you use a connected account.

References checked on 2026-10-21:

UnifyPort API

Turn messaging integration into a stable product pipeline.

Start by sending through one API, then bring every inbound message back into your business system with standard events.