Bot development
Webhooks
How to have the server send new updates straight to your bot's HTTPS address instead of using getUpdates — setup, verification, retries and replying in the response.
getUpdates is the approach where the bot keeps asking the server. A webhook works the other way round: whenever there's something new, the Pabal server sends it straight to your bot's HTTPS address. If your bot runs on a server that's always on (in the cloud or in-house), a webhook is the better fit.
| getUpdates (long polling) | Webhook | |
|---|---|---|
| Who goes first | The bot asks the server | The server sends to the bot |
| What the bot needs | Only outbound internet access | A public HTTPS address |
| Good for | Your own computer, development, behind a firewall | Always-on servers, serverless, many bots |
| Using both at once | Not possible — while a webhook is set, getUpdates returns 409 Conflict | |
How a webhook exchange works
About this diagram
- Two lanes: on the left is the Pabal server, on the right the webhook address you run. Solid blue lines are requests the server sends, gray lines are normal responses from the webhook, and red dashed lines are failures.
- A 2xx response means "received" (②). Until then, the update stays on the server. The body can be empty.
- On failure, the same update is sent again (④ → ⑤): a non-2xx response, a failed connection and no answer within 30 seconds all count as failures, and the interval starts at 1 second and doubles each time, up to 60 seconds. The reason for the failure and when it happened are recorded in
getWebhookInfo. - Order is preserved: a bot's updates are sent one at a time, so 7 waits until 6 succeeds. That's why, if the webhook keeps failing for a long time, later updates pile up (
pending_update_count). - You can reply in the response (⑥): put
methodand its parameters in the body of the 200 response, and the server runs that method on the bot's behalf. It's faster because you don't have to send another request.
Requirements for the webhook address
- It must be an https:// address. The certificate must come from a public certificate authority (Let's Encrypt, for example); uploading a self-signed certificate (
certificate) isn't supported. - It must be a public address reachable from the internet. The server won't send to internal addresses such as loopback (
127.0.0.1,::1), private networks (10.,172.16–31.,192.168.), link-local (169.254., cloud metadata) or CGNAT (100.64/10). It also refuses domains that point to such addresses, and checks again every time it sends. - There's no restriction on the port (it doesn't have to be 443). You can't put a user name and password in the address (
https://user:pw@…). - Redirects (3xx) are not followed and count as failures. Use the final address.
- You must respond within 30 seconds. For work that takes longer, answer with 200 first and do the work afterwards.
Setting it up
- Start the program that will receive the webhook. Let's say you run one of the examples below on your bot server at
127.0.0.1:8081. - Put HTTPS in front of it. With Caddy, for example, these lines are all it takes, certificate included.
bot.example.com { reverse_proxy 127.0.0.1:8081 } - Create a secret token. 1–256 characters of letters, digits,
_and-. The server puts this value in a header on every request, so you can check that a request really came from the Pabal server.export WEBHOOK_SECRET=$(openssl rand -hex 32) - Call setWebhook.
curl -s "https://pabal.me/bot$BOT_TOKEN/setWebhook" -H 'Content-Type: application/json' -d "{ \"url\": \"https://bot.example.com/pabal-webhook\", \"secret_token\": \"$WEBHOOK_SECRET\", \"allowed_updates\": [\"message\", \"callback_query\"], \"drop_pending_updates\": true }" # {"ok":true,"result":true} - Check the status. Send the bot a message from the app and look at
getWebhookInfo.curl -s "https://pabal.me/bot$BOT_TOKEN/getWebhookInfo"{ "ok": true, "result": { "url": "https://bot.example.com/pabal-webhook", "has_custom_certificate": false, "pending_update_count": 0, "ip_address": "198.51.100.7", "max_connections": 40, "allowed_updates": ["message", "callback_query"] } }If
pending_update_countis 0, updates are being received fine.last_error_dateandlast_error_messageare a record of the last failure, so they stay there even after things recover — go by the time.
setWebhook parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
url | String | Yes | The webhook address. An empty string removes the webhook (same as deleteWebhook). |
secret_token | String | Optional | 1–256 characters, A-Z a-z 0-9 _ -. Sent with every request in the X-Telegram-Bot-Api-Secret-Token header. |
allowed_updates | Array of String | Optional | Kinds to receive: message, edited_message, callback_query. Empty means all of them. Kinds not listed aren't sent and are discarded. |
drop_pending_updates | Boolean | Optional | If true, all updates not yet delivered are discarded before starting. |
max_connections | Integer | Optional | 1–100, default 40. Accepted and stored, but to preserve order Pabal sends one at a time per bot. |
certificate | InputFile | Not supported | Self-signed certificates aren't accepted (400). Use a certificate from a public authority. |
ip_address | String | Ignored | Accepted but not used. The server looks up the domain each time. |
The webhook setting is stored on the server, so it survives a server restart, and delivery resumes as soon as the server is back up. Updates not yet delivered, however, are held in memory and are lost on restart.
The request the server sends
POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(the value you gave setWebhook)
{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"Hana"},"chat":{"id":100001,"first_name":"Hana","type":"private"},"date":1789805661,"text":"Hi"}}
- The body is an Update object, exactly like one element of a
getUpdatesresult. The secret token header carries the value you gavesetWebhook. - Always check the secret token. If the header is missing or different, reject the request with 401. Compare using a constant-time function (
hmac.compare_digest,crypto.timingSafeEqual). - The same update may arrive twice (when the connection drops before your response reaches the server). Skipping anything you've already handled, by
update_id, keeps you safe.
Replying in the response
Put one Bot API call in the body of the 200 response, and the server makes that call on the bot's behalf. Put the method name in method, together with the rest of the parameters.
{"method": "sendMessage", "chat_id": 100001, "text": "Hello!"}
- The body can be JSON, a form or
multipart/form-data(aiogram sends multipart). It can be up to 1 MB. - The result or error of this call isn't returned to the bot (it only goes to the server log). If you need the result, make a separate request as usual.
- If there's nothing to reply, just return 200 with no body.
Examples
All four examples check the secret token and, when they receive a text message, echo it back by putting sendMessage in the response. They assume HTTPS in front (Caddy, for example) and receive on 127.0.0.1:8081.
# webhook.py — standard library only
# Run: WEBHOOK_SECRET='…' python3 webhook.py
import hmac
import json
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
SECRET = os.environ["WEBHOOK_SECRET"]
class Webhook(BaseHTTPRequestHandler):
def do_POST(self):
got = self.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if not hmac.compare_digest(got, SECRET):
self.send_response(401)
self.end_headers()
return
update = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
message = update.get("message")
answer = b""
if message and "text" in message:
answer = json.dumps({"method": "sendMessage",
"chat_id": message["chat"]["id"],
"text": message["text"]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(answer)))
self.end_headers()
self.wfile.write(answer)
HTTPServer(("127.0.0.1", 8081), Webhook).serve_forever()// webhook.mjs — Node.js 18 or later, no libraries
// Run: WEBHOOK_SECRET='…' node webhook.mjs
import http from 'node:http';
import { timingSafeEqual } from 'node:crypto';
const SECRET = Buffer.from(process.env.WEBHOOK_SECRET);
http.createServer(async (req, res) => {
const got = Buffer.from(req.headers['x-telegram-bot-api-secret-token'] ?? '');
if (req.method !== 'POST' || got.length !== SECRET.length || !timingSafeEqual(got, SECRET)) {
res.writeHead(401).end();
return;
}
let body = '';
for await (const chunk of req) body += chunk;
const message = JSON.parse(body).message;
if (message?.text) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ method: 'sendMessage', chat_id: message.chat.id, text: message.text }));
} else {
res.writeHead(200).end();
}
}).listen(8081, '127.0.0.1');# webhook_aiogram.py — pip install aiogram
# This program sets the webhook (setWebhook) itself as well
import os
from aiogram import Bot, Dispatcher
from aiogram.client.session.aiohttp import AiohttpSession
from aiogram.client.telegram import TelegramAPIServer
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
from aiohttp import web
URL = "https://bot.example.com/pabal-webhook"
SECRET = os.environ["WEBHOOK_SECRET"]
dp = Dispatcher()
@dp.message()
async def echo(message):
if message.text:
return message.answer(message.text) # return: sent back inside the webhook response
async def on_startup(bot: Bot):
await bot.set_webhook(URL, secret_token=SECRET)
def main():
session = AiohttpSession(api=TelegramAPIServer.from_base("https://pabal.me"))
bot = Bot(os.environ["BOT_TOKEN"], session=session)
dp.startup.register(on_startup)
app = web.Application()
SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=SECRET).register(app, path="/pabal-webhook")
setup_application(app, dp, bot=bot)
web.run_app(app, host="127.0.0.1", port=8081)
main()# webhook_ptb.py — pip install "python-telegram-bot[webhooks]"
# This program sets the webhook (setWebhook) itself as well
import os
from telegram import Update
from telegram.ext import Application, ContextTypes, MessageHandler, filters
SERVER = "https://pabal.me"
async def echo(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text(update.message.text)
app = (Application.builder().token(os.environ["BOT_TOKEN"])
.base_url(f"{SERVER}/bot").base_file_url(f"{SERVER}/file/bot").build())
app.add_handler(MessageHandler(filters.TEXT, echo))
app.run_webhook(listen="127.0.0.1", port=8081, url_path="pabal-webhook",
webhook_url="https://bot.example.com/pabal-webhook",
secret_token=os.environ["WEBHOOK_SECRET"])Checking the status — getWebhookInfo
| Field | Meaning |
|---|---|
url | The address that's set. An empty string if there's no webhook |
pending_update_count | The number of updates waiting because they couldn't be delivered yet |
ip_address | The IP of the address it last sent to |
last_error_date, last_error_message | The time (Unix seconds) and reason of the last failure. Absent if it has never failed. Kept even after recovery |
max_connections, allowed_updates | The values given to setWebhook |
has_custom_certificate | Always false |
What last_error_message can say
| Message | Cause |
|---|---|
Connection refused | Nothing is listening at that address and port. The webhook program or the proxy is down |
Connection timed out | A firewall is blocking it, or the address can't be reached |
Read timeout expired | No response within 30 seconds |
Failed to resolve host: Name or service not known | The domain name can't be found (DNS) |
SSL error {…} | A certificate problem — expired, self-signed or name mismatch |
Wrong response from the webhook: 502 Bad Gateway | A response other than 2xx. The number is the status code the webhook returned |
IP address 10.0.0.5 is reserved | The domain points to an internal address (nothing is sent) |
Going back to getUpdates
curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# To also discard the waiting updates: deleteWebhook?drop_pending_updates=true
Updates the webhook has already taken (answered with 2xx) won't come again through getUpdates. Only the ones not yet delivered continue through getUpdates.
Testing during development
- Tunnels: with a tool that gives
127.0.0.1:8081on your computer a public HTTPS address (cloudflared, ngrok and so on), you can test even against a production Pabal server. - Your own Pabal server: if you're running a Pabal server for development yourself, turn on
TELEGRAM_WEBHOOK_ALLOW_LOCAL=true. That server will then also send to local addresses such ashttp://127.0.0.1:8081/…. Never turn this on for a production server — it would let any single bot send requests into the server's internal network (the database, cloud metadata).
Production checklist
- You've chosen a secret token and you check it on every request.
- You answer within 30 seconds — hand long-running work to a queue and return 200 right away.
- You filter out duplicates by
update_id. - If the webhook stays down for a long time, later updates pile up and are lost when the server restarts — monitor your webhook program (
pending_update_countandlast_error_dateingetWebhookInfo). - If your token leaks,
/revokeit and callsetWebhookagain with the new token. Change the secret token at the same time.