Skip to main content

Telegram Business & Mini Apps

Two ways to meet your users where they already are: reply to Telegram Business DMs as the account owner, and back a Mini App (Web App) with a trusted, stateful bot.

Telegram Business — reply as the account​

Connect your bot to a Telegram Business account (Settings → Business → Chatbots) and every customer DM is delivered to your bot. Inside a business flow, send.* automatically replies as the connected account — the customer never sees a bot.

Three triggers drive it:

  • on business_connection — fires when an account connects or disconnects the bot. Read business.* (connection_id, can_reply, is_enabled, user). Register or clear the connection here.
  • on business_message — fires on a customer DM to the connected account. Read business.* (text, from, connection_id). Any send.* in this flow replies as the owner.
  • on edited_business_message / on deleted_business_messages — fire when the customer edits or deletes messages.

Connection lifecycle​

flow connected on business_connection {
when business.is_enabled {
set {
shared.connections = array.reject(shared.connections, "cid", business.connection_id)
shared.connections << [{ cid: business.connection_id }]
}
send.text(`✅ Connected. I'll answer DMs as you. Reply rights: ${business.can_reply}`)
} else {
set { shared.connections = array.reject(shared.connections, "cid", business.connection_id) }
send.text("â„šī¸ Connection disabled — I've stopped answering on this account.")
}
}

Escalation menu​

A typed menu lets the owner take over a conversation with one tap. The customer's id travels as a param — no string parsing.

menu escalateMenu(customer_id: Number, customer_name: String) {
text: `Complaint from ${customer_name}. Take over?`
text "🙋 I'll take it" -> doTakeover(cid: customer_id)
text "🤖 Keep AI" -> keepAi()
}

subflow doTakeover(cid: Number) {
set {
shared.escalated = array.reject(shared.escalated, "uid", cid)
shared.escalated << [{ uid: cid }]
}
edit.text(message: callback.message, text: `✅ You're handling ${cid}. AI is silent for them now.`)
}

subflow keepAi() {
edit.text(message: callback.message, text: "OK — AI will continue.")
}

Reply keyboard command bar (Business / group bots)​

A reply keyboard beneath the input field gives the account owner quick access to common commands without typing. Declare it with the reply keyword:

menu ownerBar reply {
resize
button "⏸ Pause AI"
button "â–ļ Resume AI"
row
button "📋 Open CRM"
}

flow connected on business_connection {
when business.is_enabled {
send.text("✅ Connected.", reply_markup: ownerBar)
}
}

AI concierge pattern​

A production concierge routes each DM by intent, drafts a reply in the owner's persona, and escalates the hard cases. Combine the ai library (ai.classify + ai.chat) with on business_message:

import ai from "ai"

flow concierge on business_message {
// Owner kill-switch + per-customer escalation (set flow.skip true to stay silent).
when shared.auto_reply == false {
set { flow.skip = true }
} else {
when !types.isNil(array.find(shared.escalated, "uid", business.from.id)) {
set { flow.skip = true }
} else {
set { flow.skip = false }
}
}

when flow.skip {
debug.log("concierge: skipped (paused or escalated)")
} else {
// 1) Route the intent (defensive default).
set { flow.intent = "other" }
let { label } = await ai.classify(
api_key: global.openai_key,
base_url: global.ai_base_url,
model: global.ai_model,
input: business.text,
labels: ["faq", "pricing", "booking", "complaint", "lead", "other"]
) on error { set { flow.intent = "other" } }
set { flow.intent = str.lower(str.trim(label)) }

// 2) Draft the reply in the owner's persona (safe fallback if AI fails).
set { flow.reply = "Thanks for your message — I'll get back to you shortly!" }
let { text } = await ai.chat(
api_key: global.openai_key,
base_url: global.ai_base_url,
model: global.ai_model,
system: `You are ${global.persona}. The detected intent is "${flow.intent}". Reply briefly as the owner. Never reveal you are an AI.`,
prompt: business.text
) on error { set { flow.reply = "Thanks for your message — I'll get back to you shortly!" } }
set { flow.reply = text }

// 3) Reply AS the account owner (the runtime stamps the connection id).
send.text(flow.reply)

// 4) Escalate complaints to the owner with a typed takeover button.
when flow.intent == "complaint" && global.owner_id != 0 {
send.text(
`âš ī¸ Complaint from ${business.from.first_name}. You may want to take over.`,
chat_id: global.owner_id,
reply_markup: escalateMenu(business.from.id, business.from.first_name)
)
}
}
}
tip

A complaint takeover toggles a per-customer escalation list, so the AI stays quiet for that customer until the owner hands it back. See the business-concierge blueprint for the full pattern: a Notion CRM for lead capture, office-hours awareness, a rolling transcript, and an owner kill-switch (/pause / /resume).

Mini Apps — a trusted backend for a Web App​

A Mini App is a web page opened from a url menu button pointing to your Web App URL, or from a reply keyboard web_app button. The page can send data back to your bot, which becomes its stateful, tamper-proof backend.

Use a menu to mix the Mini App button with in-chat fallback buttons:

global webapp_url: String = ""

menu loyaltyMenu {
text: "🏅 Loyalty Club"
url "🚀 Open App" global.webapp_url
text "📅 Check in" -> doCheckin()
}

flow start on command "/start" {
send.text("Welcome!", reply_markup: loyaltyMenu)
}

When the page calls Telegram.WebApp.sendData(payload), your bot receives on web_app_data with the raw string in web_app.data. Parse it, authenticate it, then act:

flow on_webapp on web_app_data {
// The page sends a JSON string: { action, reward?, initData }.
set { flow.payload = json.parse(web_app.data) }
set { flow.init = util.coalesce(flow.payload.initData, "") }

// Authenticate the Web App user before any state changes.
set { flow.authentic = crypto.verifyInitData(flow.init, global.bot_token) }
when !flow.authentic {
send.text("⛔ Couldn't verify that request. Please reopen the app and try again.")
stop
}

set { flow.action = util.coalesce(flow.payload.action, "") }
when flow.action == "checkin" {
let { msg } = await do_checkin()
send.text(msg, parse_mode: "Markdown")
} else {
send.text("🤔 Unknown app action.")
}
}

Authenticating with crypto.verifyInitData​

Anyone can POST a fake payload, so never trust Web App data on its own. Have your page include Telegram.WebApp.initData in what it sends, and verify it with crypto.verifyInitData(initData, bot_token) — a spec-correct HMAC check against your bot token. Only act once it returns true.

global bot_token: String = "" // set in the Variables panel; keep secret

set { flow.authentic = crypto.verifyInitData(flow.init, global.bot_token) }
when !flow.authentic { stop }
danger

The bot token used for verifyInitData is a secret — store it in a global.* variable set in the Variables panel, never inline.

A good Mini App backend also ships in-chat equivalents (a /checkin command, callback buttons) so it works even without the Web App. See the miniapp-rewards blueprint (Loyalty Club) for a complete check-in/streak/rewards economy authenticated on every action.

See also​