Developer docs
English

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 firstThe bot asks the serverThe server sends to the bot
What the bot needsOnly outbound internet accessA public HTTPS address
Good forYour own computer, development, behind a firewallAlways-on servers, serverless, many bots
Using both at onceNot possible — while a webhook is set, getUpdates returns 409 Conflict

How a webhook exchange works

Pabal server Bot webhook (HTTPS) ① POST update 5 · X-Telegram-Bot-Api-Secret-Token ② 200 OK → 5 confirmed, next ③ POST update 6 ④ 500 · can't connect · over 30 s → last_error Waits 1 s → 2 s → 4 s … up to 60 s and resends 6 (7 waits behind 6) ⑤ POST update 6 (again) ⑥ 200 + {"method": "sendMessage", "chat_id": …, "text": …} Runs the method in the response → the reply reaches the user's app ⑦ POST update 7 …
A bot's updates go out one at a time, in order

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 method and 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

  1. 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.
  2. 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
    }
  3. 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)
  4. 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}
  5. 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_count is 0, updates are being received fine. last_error_date and last_error_message are a record of the last failure, so they stay there even after things recover — go by the time.

setWebhook parameters

ParameterTypeRequiredDescription
urlStringYesThe webhook address. An empty string removes the webhook (same as deleteWebhook).
secret_tokenStringOptional1–256 characters, A-Z a-z 0-9 _ -. Sent with every request in the X-Telegram-Bot-Api-Secret-Token header.
allowed_updatesArray of StringOptionalKinds to receive: message, edited_message, callback_query. Empty means all of them. Kinds not listed aren't sent and are discarded.
drop_pending_updatesBooleanOptionalIf true, all updates not yet delivered are discarded before starting.
max_connectionsIntegerOptional1–100, default 40. Accepted and stored, but to preserve order Pabal sends one at a time per bot.
certificateInputFileNot supportedSelf-signed certificates aren't accepted (400). Use a certificate from a public authority.
ip_addressStringIgnoredAccepted 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 getUpdates result. The secret token header carries the value you gave setWebhook.
  • 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

FieldMeaning
urlThe address that's set. An empty string if there's no webhook
pending_update_countThe number of updates waiting because they couldn't be delivered yet
ip_addressThe IP of the address it last sent to
last_error_date, last_error_messageThe time (Unix seconds) and reason of the last failure. Absent if it has never failed. Kept even after recovery
max_connections, allowed_updatesThe values given to setWebhook
has_custom_certificateAlways false

What last_error_message can say

MessageCause
Connection refusedNothing is listening at that address and port. The webhook program or the proxy is down
Connection timed outA firewall is blocking it, or the address can't be reached
Read timeout expiredNo response within 30 seconds
Failed to resolve host: Name or service not knownThe 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 GatewayA response other than 2xx. The number is the status code the webhook returned
IP address 10.0.0.5 is reservedThe 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:8081 on 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 as http://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_count and last_error_date in getWebhookInfo).
  • If your token leaks, /revoke it and call setWebhook again with the new token. Change the secret token at the same time.
© 2026 Pabal.me · Based on the Pabal server as of 2026-09-19 Docs home · Pabal.me