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

  1. Open the Integrations section in the left menu and click Add integration.
  2. Select the External Handler service and give it a name.
  3. 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.

Additional integration parameters that affect the protocol:

  • session_ttl_seconds — session lifetime in seconds (default 3600).
  • 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), optional first_name, username, and params — user parameters (including those from a link), a key→value map.
  • chat — chat: id (int64) and type (e.g. "private").
  • update — raw Telegram Update (raw JSON). Present for session.open, message, callback.
  • event — normalized GetMyBot event (raw JSON).

The content varies by type:

  • session.open — a trigger fired, a new dialog was opened. Contains update (raw Update) and event (full normalized event), user.params are populated.
  • message — delivered when the service is "holding an expectation" (expect = text/any) and the user sent a message. Content is the same as for session.open.
  • callback — the user pressed a button that the service had previously sent. update is raw; user contains only tg_user_id; event contains 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 (for cancel also chat.type). No update/event fields. 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 for type: "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), params is 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 as none).
  • 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 execute per 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 params contains chat_id or from_chat_id with a value other than 0 and other than the session's chat_id, the entire method is rejected (you cannot send to a foreign chat). chat_id may be omitted entirely: GetMyBot will forcibly set the session's chat.
  • Inline buttons with callback_data are automatically tokenized (internal form x:<token>); button lifetime = until session expiry. Buttons of type url/webapp/switch_inline are 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 = text or any — the user's next message will be forwarded to the service as a message event.
  • expect = none AND 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 a message event, not triggering the bot's normal reactions.
  • A tokenized button press — forwarded to the service as a callback event with the original callback_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@bot and "cancel") while an external session is active — the session is closed, session.cancel is 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 expired and (if the service is online) session.expired is sent to the service.

Limits and timeouts

  • Frame size: 256 KiB.
  • Session TTL: 3600 seconds by default (configurable via the session_ttl_seconds integration parameter).
  • Command rate: 60 execute per 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: ping every 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 own ping/pong frames 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/callback events 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.expired events 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_id prevent 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