External Handler
The external handler is a bridge between your code and Telegram. When the visual builder's logic is not enough, you hand off an individual reaction to your own server: it receives events and responds with commands in Telegram Bot API format, while GetMyBot handles transport, delivery, and everything Telegram.
What it is
Reactions in GetMyBot are built visually: trigger → conditions → actions. Complex or non-standard logic — your own database, calculations, calls to third-party systems, ML, branching scenarios — is hard to express in the builder. The external handler removes this ceiling: you connect your own backend and delegate entire reactions to it.
Your service receives events from GetMyBot (a trigger fired, the user replied with text, pressed a button) and in response instructs the bot what to send. GetMyBot executes the commands using its bot token — rate limiting, retries, deduplication, and the dialog log all operate on top.
How it works
- GetMyBot is the WebSocket server. Your service connects to it and does not need a public address or a webhook endpoint. GetMyBot never makes outbound connections to your service, so there is no SSRF surface.
- Authentication via an integration token. The service presents a token on connection; GetMyBot verifies it against a hash and keeps the connection open.
- Events and commands in Telegram Bot API format. The service receives an event and replies with an array of calls in the form "method + parameters", exactly as when working with a regular bot.
- Commands are executed by GetMyBot using its own token. The service never communicates with Telegram directly: commands go through validation and into GetMyBot's internal outbox, from where they are sent using the bot token with rate limiting, retries, deduplication, and a dialog log.
A single connection serves all dialogs for one integration — events from different users are multiplexed and distinguished by the session_id field.
Setting up the integration
- Open the Integrations section in the left menu and click Add integration.
- Select the External Handler service and give it a name.
- After creation, the card will show:
- WebSocket address — in the form
wss://api.mybot.app/ext/ws/<integrationID>, where<integrationID>is the UUID of this integration. Your service connects to this address. Without an id in the path the connection will not be established. - Connection token — shown once at creation time. Copy it and store it somewhere safe; you cannot view the token again (it is stored only as a hash).
- Rotate token — the button generates a new token and immediately invalidates the old one. After rotating, update the token in your service.
- Online indicator — shows whether your service currently holds an active connection.
- WebSocket address — in the form
Additional integration parameters that affect the protocol:
session_ttl_seconds— session lifetime in seconds (default3600).on_unavailable_reaction_id— a fallback reaction that is triggered if the service is offline when the trigger fires (see Reliability).
Binding to a reaction
The external handler is connected not as a separate trigger but as an action. In the reaction editor, add the Send to external handler action and select the desired integration.
Any existing GetMyBot trigger can open a dialog — a command, text, button press, parameter from a link, incoming web request, or schedule. When such a trigger fires and reaches the action, GetMyBot opens a proxy session and sends the service a session.open event.
If the service is not connected at that moment, GetMyBot will trigger the fallback reaction from the integration setting (on_unavailable_reaction_id). If no fallback is set, nothing happens (silently, with no error shown to the user).
Connection and authentication
Endpoint: GET /ext/ws/{integrationID} over WebSocket (wss). integrationID is the UUID of the External Handler integration. Frame size limit — 256 KiB.
You can authenticate in one of two ways.
1. Header during handshake (preferred). Pass the token in the Authorization header:
Authorization: Bearer <token>
2. Auth frame. If no Bearer header is provided, send the following as the first frame within 10 seconds of establishing the connection:
{ "type": "auth", "token": "<token>" }
If the auth frame is not received within 10 seconds, the connection is closed with code 4401. An invalid token in either method also results in close 4401.
The token is generated when the integration is created, shown once, and stored only as a hash (sha256); verification is constant-time. Token rotation:
POST /api/bots/{botID}/integrations/{integrationID}/rotate-token
The endpoint returns a new plaintext token; the old one stops working immediately.
Events: bot → service
Each event is a JSON frame (EventEnvelope). The type field is one of: session.open, message, callback, session.cancel, session.expired.
Full set of envelope fields:
{
"type": "session.open",
"session_id": "string",
"bot_id": "string",
"user": {
"tg_user_id": 123456789,
"first_name": "Ivan",
"username": "ivan",
"params": { "utm": "promo" }
},
"chat": { "id": 123456789, "type": "private" },
"update": { "...": "raw Telegram Update (raw JSON)" },
"event": { "...": "normalized GetMyBot event (raw JSON)" }
}
session_id— session (dialog) identifier.bot_id— bot identifier.user— user data:tg_user_id(int64), optionalfirst_name,username, andparams— user parameters (including those from a link), a key→value map.chat— chat:id(int64) andtype(e.g."private").update— raw Telegram Update (raw JSON). Present forsession.open,message,callback.event— normalized GetMyBot event (raw JSON).
The content varies by type:
session.open— a trigger fired, a new dialog was opened. Containsupdate(raw Update) andevent(full normalized event),user.paramsare populated.message— delivered when the service is "holding an expectation" (expect=text/any) and the user sent a message. Content is the same as forsession.open.callback— the user pressed a button that the service had previously sent.updateis raw;usercontains onlytg_user_id;eventcontains only the original callback_data set by the service in the button:
{ "callback_data": "<original value set by the service in the button>" }
session.cancel/session.expired— minimal envelope:session_id,bot_id,user.tg_user_id,chat.id(forcancelalsochat.type). Noupdate/eventfields. These events are delivered only if the service is online — they are not placed in the offline queue.
Commands: service → bot
A command is a JSON frame (CommandEnvelope). The type field is one of: execute, session.close, auth, ping, pong.
{
"type": "execute",
"session_id": "string",
"methods": [
{ "method": "sendMessage", "params": { "text": "Hello" } }
],
"expect": "none"
}
session_id— the session this command belongs to.token— only fortype: "auth"(see Connection and authentication).methods— array of objects{ "method": "<Telegram Bot API method name>", "params": { ... } }. The method name is exactly as in the Telegram Bot API (e.g.sendMessage),paramsis the parameter object for that method.expect— whether the service is waiting for a user reply:"none","text", or"any"(an empty value is treated asnone).session.close— explicitly close the dialog.
Commands do not go to Telegram directly: GetMyBot validates them and places them in its outbox, from where they are sent using the bot token (with rate limiting, retries, deduplication, and a dialog log).
How execute is processed
An execute command is dropped entirely if: session_id is empty; the session is not found or is not in open status; the session belongs to a different integration; the session has expired.
Then:
- Rate limit: up to 60
executeper minute per session (sliding window). Excess is dropped. - Up to 30 methods in a single
execute; extras are silently discarded. - Each method is checked against the whitelist (see Allowed methods) — a method not on the list is rejected.
- Chat guard: if
paramscontainschat_idorfrom_chat_idwith a value other than0and other than the session'schat_id, the entire method is rejected (you cannot send to a foreign chat).chat_idmay be omitted entirely: GetMyBot will forcibly set the session's chat. - Inline buttons with
callback_dataare automatically tokenized (internal formx:<token>); button lifetime = until session expiry. Buttons of typeurl/webapp/switch_inlineare left unchanged.
Allowed methods
The service commands the bot, so the set of methods is limited to a whitelist — only sending and working with messages in the current dialog. Methods not on the list are silently ignored (not an error).
- sending:
sendMessage,sendPhoto,sendDocument,sendVideo,sendAudio,sendMediaGroup,sendAnimation,sendVoice,sendLocation,sendChatAction; - editing and deleting:
editMessageText,editMessageCaption,editMessageReplyMarkup,deleteMessage; - acknowledging a button press:
answerCallbackQuery; - forwarding and copying:
forwardMessage,copyMessage; - pinning:
pinChatMessage,unpinChatMessage.
Account-level methods (setWebhook, getUpdates, logOut, close, setMyCommands, etc.) are not allowed.
Expectation and session lifecycle
After each execute, the fate of the session depends on expect and whether the command contained inline buttons with callback_data:
expect=textorany— the user's next message will be forwarded to the service as amessageevent.expect=noneAND the command had NO inline buttons with callback — the session closes automatically. This is a "terminal" message.expect=none, BUT there are callback buttons — the session stays open: button presses are captured as long as the button tokens are alive (until TTL).
Important: expect does not affect button press reception. Presses on tokenized buttons are captured regardless of the expect value — as long as the session and the button token are alive. expect controls only whether the service is waiting for a text/arbitrary reply.
Dialog boundaries
The dialog context lives exactly as long as the session is open:
- Text reply while an expectation is active (
expect=text/any) — forwarded to the service as amessageevent, not triggering the bot's normal reactions. - A tokenized button press — forwarded to the service as a
callbackevent with the originalcallback_data. GetMyBot always acknowledges the press itself (answerCallbackQuery); ownership is verified (bot + user + session chat). A button press does not close the session. /cancel(also/cancel@botand "cancel") while an external session is active — the session is closed,session.cancelis sent to the service, and "Cancelled." is sent to the user.- One dialog per user. Opening a new session evicts the user's previous open session — it receives
session.cancel. - TTL expiry. After the timer fires, the session is marked
expiredand (if the service is online)session.expiredis sent to the service.
Limits and timeouts
- Frame size: 256 KiB.
- Session TTL: 3600 seconds by default (configurable via the
session_ttl_secondsintegration parameter). - Command rate: 60
executeper minute per session (sliding window). - Methods per
execute: up to 30 (extras are discarded). - Open sessions per integration: up to 1000.
- Offline queue: up to 100 events per session.
- Heartbeat:
pingevery 30 seconds; inactivity timeout — 60 seconds. - Auth frame timeout: 10 seconds.
Reliability (heartbeat, offline, queue, fallback)
- Heartbeat. The server sends a
{"type":"ping"}frame every 30 seconds. If there is no activity from the client for more than 60 seconds, the connection is closed. The client may send its ownping/pongframes to keep the connection alive. A dropped connection does not kill dialogs: sessions live in the database until their TTL or until reconnection. - Reconnection is the service's responsibility. If the connection drops, your service must reconnect. Open sessions in the database survive restarts until their TTL expires.
- Fallback when offline at trigger time. If the service is not connected when a reaction fires, the fallback reaction from the integration setting (
on_unavailable_reaction_id) is triggered. If none is set, nothing happens (silently). - Offline queue.
message/callbackevents not delivered due to the service being offline are placed in an offline queue (up to 100 events, FIFO, stored until session expiry) and delivered upon reconnection.session.open/session.cancel/session.expiredevents are not queued. - Delivery guarantee — at-most-once. When the queue overflows or expires, events are dropped. Design your logic so that a missed event does not break the scenario.
Online indicator
You can check whether the service holds an active connection via the endpoint:
GET /api/bots/{botID}/integrations/{integrationID}/status
Response:
{ "online": true }
The same indicator is available on the integration card in the dashboard. The MCP equivalent is the get_integration_status tool.
Security
GetMyBot executes commands from a third-party service using its own bot token, so protection is strict.
- No SSRF surface. GetMyBot acts as the WebSocket server and never makes outbound connections to the service — it is the service that connects to GetMyBot. There is no externally controlled URL that the platform fetches.
- Token stored as a hash (sha256), constant-time verification, rotation supported.
- Tenant isolation. A command is tightly bound to its integration; a session is bound to a bot + user + chat. Events from other bots never reach your socket.
- Method whitelist + forced
chat_idprevent the bot from being used as a broadcaster to arbitrary chats. - Bot token is never exposed externally — the service never communicates with Telegram directly.
- Secret masking. The integration token and sensitive values are hidden in the activity log and system logs.
Examples
Minimal handler: on dialog open, sends a message and closes the session (expect: "none"). Substitute your integration id and token. The address must include <integrationID> in the path.
Node.js
import WebSocket from "ws";
const URL = `wss://api.mybot.app/ext/ws/${process.env.MYBOT_INTEGRATION_ID}`;
const ws = new WebSocket(URL, {
headers: { Authorization: `Bearer ${process.env.MYBOT_TOKEN}` },
});
ws.on("message", (raw) => {
const ev = JSON.parse(raw);
if (ev.type === "session.open") {
ws.send(JSON.stringify({
type: "execute",
session_id: ev.session_id,
expect: "none",
methods: [{ method: "sendMessage", params: { text: "Hello from the external handler!" } }],
}));
}
});
Python
import json, os, asyncio, websockets
async def main():
url = f"wss://api.mybot.app/ext/ws/{os.environ['MYBOT_INTEGRATION_ID']}"
headers = {"Authorization": f"Bearer {os.environ['MYBOT_TOKEN']}"}
async with websockets.connect(url, additional_headers=headers) as ws:
async for raw in ws:
ev = json.loads(raw)
if ev["type"] == "session.open":
await ws.send(json.dumps({
"type": "execute",
"session_id": ev["session_id"],
"expect": "none",
"methods": [
{"method": "sendMessage", "params": {"text": "Hello from the external handler!"}}
],
}))
asyncio.run(main())
Inline button and callback handling
To catch a button press, send a button with callback_data (GetMyBot tokenizes it automatically). With expect: "none" and a button present, the session stays open while the button token is alive — the press arrives as a callback event in which event.callback_data equals the original value. You do not need to acknowledge the press (answerCallbackQuery) — GetMyBot does it automatically.
if (ev.type === "session.open") {
ws.send(JSON.stringify({
type: "execute",
session_id: ev.session_id,
expect: "none",
methods: [{
method: "sendMessage",
params: {
text: "Press the button",
reply_markup: { inline_keyboard: [[{ text: "Let's go", callback_data: "go" }]] },
},
}],
}));
} else if (ev.type === "callback" && ev.event.callback_data === "go") {
ws.send(JSON.stringify({
type: "execute",
session_id: ev.session_id,
expect: "none",
methods: [{ method: "sendMessage", params: { text: "Button pressed!" } }],
}));
}
What's next
- Integrations — overview of connectable services.
- Reactions — how triggers, conditions, and actions are assembled.
- Web requests and webhooks — a simpler way to call an external URL without a dialog.