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. Readbusiness.*(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. Readbusiness.*(text,from,connection_id). Anysend.*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)
)
}
}
}
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 }
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â
- Dynamic keyboards â
web_appbuttons and inline mode - Standard Library â Integrations â the
aiandnotionlibraries - Security & validation