Developer docs
English

Bot development

Build a bot

Create a bot with @BotFather, receive and answer messages, then add buttons, commands, photos and groups โ€” a tutorial to follow from start to finish.

By the end of this tutorial you'll have a bot that replies to messages, shows buttons, handles a command menu and photos, and works in groups as well. All you need is the Pabal app, a computer to run the bot program on, and either Python 3.10 or later or Node.js 18 or later.

How bots work

A Pabal bot is an account controlled by a program. Messages sent to the bot pile up in the bot's message box; the bot program asks the server "anything new?" (getUpdates), picks them up, and sends its replies (sendMessage). The bot program runs outside the server, on your own computer.

User (app) Pabal server Bot program โ‘  getUpdates?offset=0&timeout=30 Waits up to 30 s for something new โ‘ก sends "Hi" โ‘ข [{update_id: 1, message: "Hi"}] โ‘ฃ sendMessage(chat_id, "Hello!") โ‘ค Reply reaches the app (real time) โ‘ฅ getUpdates?offset=2 โ€” got #1, next one
The basic cycle: receive with getUpdates, reply with sendMessage

About this diagram

  • Three lanes: on the left is the person using the app, in the middle the Pabal server, and on the right your bot program. The vertical dotted lines show time flowing downwards.
  • Gray arrows are requests the bot makes first, and blue arrows are the messages that travel as a result. The bot program only sends requests; the server never contacts the bot first (unless you use a webhook).
  • The wait in โ‘  (long polling) is the key: with timeout=30, when there's nothing new the server doesn't answer with an empty result right away but holds the request for up to 30 seconds, and returns it (โ‘ข) the moment a message arrives (โ‘ก). That makes the bot quick to respond while keeping the number of requests low.
  • The offset in โ‘ฅ means "received": send the last update_id you handled plus 1, and everything up to it is removed from the server. If you don't raise the offset, you keep getting the same updates.
  • โ‘ค follows the same path as a message sent by a person: the bot's reply is pushed to all of the person's devices and shows up in their chat list too.

1. Create a bot with @BotFather

You create a bot by chatting with @BotFather in the Pabal app. BotFather is a bot built into the Pabal server.

  1. Type BotFather into the app's search box and open BotFather. Click Start and you'll get a list of commands.
  2. Send /newbot.
  3. Send the bot's name. This is the name shown in chat lists, so non-Latin characters such as Korean are fine too.
  4. Send the bot's username. It is 5โ€“32 characters of letters, digits and underscores, starts with a letter, and must end in bot.
  5. When the reply with the token arrives, you're done. Copy the token and keep it.
/newbot
BotFather์ƒˆ ๋ด‡์„ ๋งŒ๋“ญ๋‹ˆ๋‹ค. ๋ด‡์˜ ์ด๋ฆ„์„ ์•Œ๋ ค ์ฃผ์„ธ์š”. (๋Œ€ํ™” ๋ชฉ๋ก์— ๋ณด์ด๋Š” ์ด๋ฆ„์ด์—์š”)
Hello Bot
BotFather์ข‹์•„์š”. ์ด์ œ ๋ด‡์˜ ์‚ฌ์šฉ์ž๋ช…์„ ์ •ํ•ด ์ฃผ์„ธ์š”. โ€ฆ
hello_test_bot
BotFather์™„๋ฃŒ! ์ƒˆ ๋ด‡ @hello_test_bot ๋ฅผ ๋งŒ๋“ค์—ˆ์–ด์š”. ๊ฒ€์ƒ‰ํ•ด์„œ ๋Œ€ํ™”๋ฅผ ์‹œ์ž‘ํ•  ์ˆ˜ ์žˆ์–ด์š”. ๋ด‡ ํ† ํฐ: 100003:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789 ํ† ํฐ์€ ๋น„๋ฐ€๋ฒˆํ˜ธ์ฒ˜๋Ÿผ ์•ˆ์ „ํ•˜๊ฒŒ ๋ณด๊ด€ํ•˜์„ธ์š”. โ€ฆ

BotFather currently replies in Korean. In the conversation above it first asks for the bot's name (the name shown in chat lists), then asks you to choose the bot's username, and finally confirms that the new bot @hello_test_bot has been created, hands you its bot token (100003:โ€ฆ) and reminds you to keep the token as safe as a password.

BotFather commandWhat it does
/newbotCreate a new bot (name โ†’ username โ†’ token)
/mybotsList the bots you've created
/tokenShow a bot's token again
/revokeIssue a new token โ€” the old token stops working immediately, and connections made with it are cut off
/setcommandsSet the command menu (one command - description per line)
/deletebotDelete a bot โ€” confirm by sending ๋„ค, ์‚ญ์ œํ•ฉ๋‹ˆ๋‹ค ("Yes, delete it"). The username is freed so it can be used again
/cancelCancel the operation in progress

If you add the username, as in /token @hello_test_bot, BotFather skips the "Which bot?" step.

2. Handling the token

A token has the form <bot ID>:<secret>. The number at the front is the bot's user ID, and the rest is the secret. A token alone gives full control of the bot, so treat it like a password.

  • Don't write it into your code; keep it in an environment variable (BOT_TOKEN) or a secret store. Never push it to a public repository.
  • If it leaks, send /revoke to BotFather. The old token is rejected immediately (401 Unauthorized), and any bot sessions connected over MTProto with the old token are cut off too.
  • The token is part of the address (URL), so make sure your bot program doesn't write request URLs to its logs. The Pabal server doesn't log Bot API addresses either.

3. Your first request โ€” getMe

Every request goes to https://pabal.me/bot<token>/<method>. Let's check that the token is right with getMe.

export BOT_TOKEN='100003:AbCdEfโ€ฆ'
curl -s "https://pabal.me/bot$BOT_TOKEN/getMe"
# pip install requests
import os
import requests

r = requests.get(f"https://pabal.me/bot{os.environ['BOT_TOKEN']}/getMe", timeout=10)
print(r.json())
// Node.js 18 or later โ€” fetch is built in
const res = await fetch(`https://pabal.me/bot${process.env.BOT_TOKEN}/getMe`);
console.log(await res.json());

If it works, you get this back. Every response is JSON containing ok and result (on success) or error_code and description (on failure).

{
  "ok": true,
  "result": {
    "id": 100003,
    "is_bot": true,
    "first_name": "Hello Bot",
    "username": "hello_test_bot",
    "can_join_groups": true,
    "can_read_all_group_messages": true,
    "supports_inline_queries": false,
    "can_connect_to_business": false,
    "has_main_web_app": false
  }
}

If the token is wrong, you get HTTP 401 with {"ok": false, "error_code": 401, "description": "Unauthorized"}.

4. Receiving messages โ€” getUpdates

Open the bot in the app and click Start or send it anything, then fetch the new updates.

curl -s "https://pabal.me/bot$BOT_TOKEN/getUpdates?timeout=30"
{
  "ok": true,
  "result": [
    {
      "update_id": 1,
      "message": {
        "message_id": 1,
        "from": { "id": 100001, "is_bot": false, "first_name": "Hana" },
        "chat": { "id": 100001, "first_name": "Hana", "type": "private" },
        "date": 1789805661,
        "text": "/start",
        "entities": [ { "type": "bot_command", "offset": 0, "length": 6 } ]
      }
    }
  ]
}
  • update_id: a number that grows by 1 with each update. Once you've handled an update, pass offset=update_id+1 in your next request and everything up to it is removed as "received".
  • timeout: how many seconds to wait when there's nothing new (0โ€“50). With 0 you get an empty list right away. We recommend 25โ€“30.
  • chat.id: where to send your reply. In a one-to-one chat it's the person's ID (positive); in a group it's negative.
  • There are three kinds of updates you can receive: message (a new message), edited_message (an edited message) and callback_query (a button press).
The queue lives in server memory

Updates that haven't been fetched pile up in server memory, up to the most recent 1,000 per bot. When the server restarts, anything not yet fetched is lost (the messages themselves stay in the chat). Don't leave your bot switched off for long.

5. Sending a reply โ€” sendMessage

curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" \
  -H 'Content-Type: application/json' \
  -d '{"chat_id": 100001, "text": "Hello!"}'
requests.post(f"https://pabal.me/bot{os.environ['BOT_TOKEN']}/sendMessage",
              json={"chat_id": 100001, "text": "Hello!"}, timeout=10)
await fetch(`https://pabal.me/bot${process.env.BOT_TOKEN}/sendMessage`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ chat_id: 100001, text: 'Hello!' }),
});

You can send parameters however is most convenient: as a JSON body, as a form (application/x-www-form-urlencoded), as multipart/form-data when uploading files, or as a query string after the address. The result is the message that was sent (Message).

Bots can't start a conversation

A bot can only message a person after that person has sent the bot at least one message. Otherwise you get 403 Forbidden: bot can't initiate conversation with a user. It's the same rule as on Telegram.

6. A complete echo bot

Receive and send in a loop, and you have a bot. Here is the full code, without any library.

# echo.py โ€” pip install requests
# Run: BOT_TOKEN='100003:โ€ฆ' python3 echo.py
import os
import requests

API = f"https://pabal.me/bot{os.environ['BOT_TOKEN']}"


def call(method, **params):
    r = requests.post(f"{API}/{method}", json=params, timeout=60)
    data = r.json()
    if not data["ok"]:
        raise RuntimeError(f"{method}: {data['description']}")
    return data["result"]


offset = 0
print("The bot is running. Press Ctrl+C to stop")
while True:
    for update in call("getUpdates", offset=offset, timeout=30):
        offset = update["update_id"] + 1          # mark as received
        message = update.get("message")
        if message and "text" in message:
            call("sendMessage", chat_id=message["chat"]["id"], text=message["text"])
// echo.mjs โ€” Node.js 18 or later, no libraries
// Run: BOT_TOKEN='100003:โ€ฆ' node echo.mjs
const API = `https://pabal.me/bot${process.env.BOT_TOKEN}`;

async function call(method, params = {}) {
  const res = await fetch(`${API}/${method}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(params),
  });
  const data = await res.json();
  if (!data.ok) throw new Error(`${method}: ${data.description}`);
  return data.result;
}

let offset = 0;
console.log('The bot is running. Press Ctrl+C to stop');
for (;;) {
  const updates = await call('getUpdates', { offset, timeout: 30 });
  for (const update of updates) {
    offset = update.update_id + 1;              // mark as received
    const message = update.message;
    if (message?.text) {
      await call('sendMessage', { chat_id: message.chat.id, text: message.text });
    }
  }
}

7. Building with a library

Telegram bot libraries have a setting for changing the server address. With that one setting they run on Pabal as they are. When moving a bot built for Telegram, change just this, and get a new token from Pabal's BotFather.

LibrarySetting to changeVersion tested
python-telegram-bot.base_url("https://pabal.me/bot"), .base_file_url("https://pabal.me/file/bot")22.8
aiogramAiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))3.31
No library (HTTP)The start of the address, https://api.telegram.org โ†’ https://pabal.meโ€”
# hello_bot.py โ€” pip install python-telegram-bot
# Run: BOT_TOKEN='100003:โ€ฆ' python3 hello_bot.py
import os

from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import (Application, CallbackQueryHandler, CommandHandler, ContextTypes,
                          MessageHandler, filters)

SERVER = "https://pabal.me"


def buttons():
    return InlineKeyboardMarkup([[InlineKeyboardButton("๐Ÿ‘ Like", callback_data="like"),
                                  InlineKeyboardButton("๐Ÿ”ข Count up", callback_data="count")]])


async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Hello! Try pressing a button.", reply_markup=buttons())


async def button(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    if query.data == "like":
        await query.answer("Thanks!")                  # text that pops up briefly for whoever pressed
    else:
        n = context.chat_data.get("n", 0) + 1
        context.chat_data["n"] = n
        await query.answer()                             # answer first,
        await query.edit_message_text(f"Count: {n}", reply_markup=buttons())   # then edit the message


async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(f"You said '{update.message.text}'.")


def main():
    app = (Application.builder().token(os.environ["BOT_TOKEN"])
           .base_url(f"{SERVER}/bot")                   # Pabal instead of api.telegram.org
           .base_file_url(f"{SERVER}/file/bot")
           .build())
    app.add_handler(CommandHandler("start", start))
    app.add_handler(CallbackQueryHandler(button))
    app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, echo))
    print("The bot is running. Press Ctrl+C to stop")
    app.run_polling()


if __name__ == "__main__":
    main()
# echo_aiogram.py โ€” pip install aiogram
# Run: BOT_TOKEN='100003:โ€ฆ' python3 echo_aiogram.py
import asyncio
import os

from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.filters import CommandStart

dp = Dispatcher()


@dp.message(CommandStart())
async def start(message):
    await message.answer("Hello! Send me anything.")


@dp.message()
async def echo(message):
    if message.text:
        await message.answer(message.text)


async def main():
    session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))   # Pabal
    bot = Bot(os.environ["BOT_TOKEN"], session=session)
    print("The bot is running. Press Ctrl+C to stop")
    await dp.start_polling(bot)


asyncio.run(main())

8. Buttons and callbacks

To attach inline buttons to a message, pass inline_keyboard (an array of rows, where each row is an array of buttons) in reply_markup. There are two kinds of buttons: callback_data buttons, which notify the bot when pressed, and url buttons, which open a link.

curl -s "https://pabal.me/bot$BOT_TOKEN/sendMessage" -H 'Content-Type: application/json' -d '{
  "chat_id": 100001,
  "text": "Please choose",
  "reply_markup": {
    "inline_keyboard": [
      [ {"text": "๐Ÿ‘ Like", "callback_data": "like"}, {"text": "๐Ÿ‘Ž Not really", "callback_data": "dislike"} ],
      [ {"text": "Open the Pabal docs", "url": "https://pabal.me/docs/"} ]
    ]
  }
}'

When someone presses a callback_data button, the bot receives a callback_query update.

{
  "update_id": 7,
  "callback_query": {
    "id": "5812039457730125441",
    "from": { "id": 100001, "is_bot": false, "first_name": "Hana" },
    "message": { "message_id": 4, "chat": { "id": 100001, "type": "private", "first_name": "Hana" }, "text": "Please choose", โ€ฆ },
    "chat_instance": "8413962145072395171",
    "data": "like"
  }
}

The bot must answer with answerCallbackQuery within 10 seconds. Meanwhile, the app shows a clock on the button and waits.

# Text that pops up briefly on screen (with show_alert: true, a dialog box)
curl -s "https://pabal.me/bot$BOT_TOKEN/answerCallbackQuery" -H 'Content-Type: application/json' \
  -d '{"callback_query_id": "5812039457730125441", "text": "Thanks!"}'

# Change the text and buttons of the message that was pressed
curl -s "https://pabal.me/bot$BOT_TOKEN/editMessageText" -H 'Content-Type: application/json' \
  -d '{"chat_id": 100001, "message_id": 4, "text": "You pressed Like ๐Ÿ‘"}'
  • callback_data is 1โ€“64 bytes. A message can have up to 100 buttons.
  • If you don't answer a callback, the app stops waiting after 10 seconds. If the bot is off, the server ends it right away.
  • Only the first answer counts. Some libraries send an empty answer first when they edit a message, so send the answer with text first.
  • Editing a message to the same text and the same buttons gives 400 Bad Request: message is not modified (same as Telegram).

Keyboard below the input field

Pass keyboard and a panel of buttons appears in place of the input field; pressing one sends its text as a message. Hide it with remove_keyboard, and turn on reply mode with force_reply.

{
  "chat_id": 100001,
  "text": "Which one?",
  "reply_markup": {
    "keyboard": [ [ {"text": "Yes"}, {"text": "No"} ], [ {"text": "Send my location", "request_location": true} ] ],
    "resize_keyboard": true,
    "one_time_keyboard": true
  }
}

9. Command menu

This is the list that appears when you type / in the chat or press the Menu button. Set it from code, or with BotFather's /setcommands.

curl -s "https://pabal.me/bot$BOT_TOKEN/setMyCommands" -H 'Content-Type: application/json' -d '{
  "commands": [
    {"command": "start", "description": "Get started"},
    {"command": "help",  "description": "Help"}
  ]
}'

Commands are 1โ€“32 characters of lowercase letters, digits and underscores, descriptions are 1โ€“256 characters, and you can have up to 100 commands. If you pass language_code, a separate list is stored for that language, but the app currently shows only the default list set without a language code. Commands a person sends, such as /start, arrive marked as bot_command in the message's entities.

10. Sending and receiving photos

Sending

Upload the file as multipart/form-data, or reuse the file_id of a photo you received earlier. Photos can be up to 10 MB, and captions (caption) up to 1,024 characters. Sending by URL isn't supported yet.

curl -s "https://pabal.me/bot$BOT_TOKEN/sendPhoto" \
  -F chat_id=100001 -F caption='Photo of the day' -F photo=@sunset.jpg
with open("sunset.jpg", "rb") as f:
    requests.post(f"{API}/sendPhoto", data={"chat_id": 100001, "caption": "Photo of the day"},
                  files={"photo": f}, timeout=60)

Receiving

A photo someone sends arrives in the message's photo (a list by size; Pabal has just the original). Get its path with getFile and download it.

# 1) file_id โ†’ file_path
curl -s "https://pabal.me/bot$BOT_TOKEN/getFile?file_id=AQAAAAAAAAB7โ€ฆ"
# {"ok":true,"result":{"file_id":"AQAAโ€ฆ","file_unique_id":"AQAAโ€ฆ","file_size":48213,"file_path":"photos/AQAAโ€ฆ.jpg"}}

# 2) Download โ€” note the /file/ in the address
curl -s -o photo.jpg "https://pabal.me/file/bot$BOT_TOKEN/photos/AQAAโ€ฆ.jpg"

11. In groups

  • When you create a group in the app, or under group info โ†’ Add Members, search for the bot's username and add it.
  • A bot in a group receives every message in the group (the same as Telegram's "privacy mode off"). chat.type is "group" and chat.id is negative.
  • Call sendMessage with that chat.id and the message goes to the group. Buttons, photos and edits work exactly as in one-to-one chats.
  • Once the bot leaves the group, sending to that group gives 403 Forbidden: bot is not a member of the group chat.

12. Switching to a webhook

If your bot runs on a server with a public HTTPS address, instead of asking with getUpdates you can have the Pabal server send new updates to that address.

curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' \
  -d '{"url": "https://bot.example.com/pabal-webhook", "secret_token": "long-random-string"}'

Setup, verification, retries and replying in the response are all covered in the Webhooks page.

Bots over MTProto (Telethon)

Bots can also connect over MTProto, just like the app. That's handy if you already use tools built for user accounts (such as Telethon). It's the same bot account, so you can mix it with HTTP.

# pip install telethon==1.42.0   โ† use 1.42 (explained below)
# Server public key: download https://pabal.me/docs/server-key.pem into the same folder
import asyncio
import os

from telethon import TelegramClient, events
from telethon.crypto import rsa
from telethon.sessions import StringSession

rsa.add_key(open("server-key.pem").read(), old=False)        # the Pabal server's public key
client = TelegramClient(StringSession(), api_id=1, api_hash="0" * 32)
client.session.set_dc(2, "122.34.175.215", 8443)


@client.on(events.NewMessage(incoming=True))
async def echo(event):
    await event.reply(event.raw_text)


async def main():
    await client.start(bot_token=os.environ["BOT_TOKEN"])      # auth.importBotAuthorization
    print("The bot is running. Press Ctrl+C to stop")
    await client.run_until_disconnected()


asyncio.run(main())
  • Use Telethon 1.42. Pabal speaks layer 216, and newer versions of Telethon try to read responses with a higher layer, so they fail as early as sign-in (TypeNotFoundError).
  • Pabal doesn't check api_id and api_hash, so any values will do.
  • MTProto bots get new messages pushed to them by the server in real time (no need for getUpdates or a webhook). For callback answers, call event.answer("โ€ฆ") before event.edit(โ€ฆ).

Rules and limits

ItemValue
Message length / photo caption4,096 characters / 1,024 characters
Photo size (sendPhoto upload)10 MB
Request body size12 MB
getUpdates timeout ยท limit0โ€“50 seconds ยท 1โ€“100 updates
Keeping unfetched updatesThe most recent 1,000 per bot, in server memory (lost on restart)
Wait for a callback answer10 seconds
callback_data ยท number of buttons1โ€“64 bytes ยท 100 per message
Commands1โ€“32 characters of lowercase letters, digits and underscores, descriptions of 1โ€“256 characters, up to 100 commands
Starting a conversationNot allowed โ€” the person has to message the bot first

Troubleshooting

SymptomCause and fix
401 UnauthorizedThe token is wrong or was changed with /revoke. Send /token to BotFather to check it.
404 Not Found: method not foundPabal doesn't support this method yet. Check the list of methods.
403 Forbidden: bot can't initiate conversation with a userThat person hasn't messaged the bot yet. Ask them to open the bot in the app and click Start.
409 Conflict: can't use getUpdates method while webhook is activeA webhook is set. Call deleteWebhook, or receive through the webhook.
Pressing a button does nothingThe bot is off, or it didn't call answerCallbackQuery.
The same message keeps coming backYou didn't raise the offset. Pass the handled update_id + 1 in the next request.
Bold and link formatting doesn't workparse_mode isn't supported yet, so text is sent as is. Commands, @mentions, URLs and #tags are made clickable automatically.
The bot is silent in a groupThe bot isn't a member of the group. Add it under group info โ†’ Add Members.
ยฉ 2026 Pabal.me ยท Based on the Pabal server as of 2026-09-19 Docs home ยท Pabal.me