Tài liệu nhà phát triển
Tiếng Việt

Phát triển bot

Webhook

Cách để máy chủ gửi thẳng tin mới đến địa chỉ HTTPS của bot thay vì dùng getUpdates — thiết lập, xác minh, gửi lại và trả lời ngay trong phản hồi.

getUpdates là cách bot liên tục hỏi máy chủ. Webhook thì ngược lại: khi có tin mới, máy chủ Pabal gửi ngay đến địa chỉ HTTPS của bot. Nếu bot chạy trên một máy chủ luôn bật (đám mây, máy chủ nội bộ công ty), webhook là lựa chọn phù hợp hơn.

getUpdates (long polling)Webhook
Ai bắt đầuBot hỏi máy chủMáy chủ gửi cho bot
Bot cần gìChỉ cần kết nối Internet chiều đi raMột địa chỉ HTTPS công khai
Phù hợp vớiMáy tính cá nhân, lúc đang phát triển, sau tường lửaMáy chủ luôn bật, serverless, nhiều bot
Dùng đồng thờiKhông được — khi đã thiết lập webhook, getUpdates sẽ trả về 409 Conflict

Webhook trao đổi như thế nào

Máy chủ Pabal Webhook bot (HTTPS) ① POST update 5 · X-Telegram-Bot-Api-Secret-Token ② 200 OK → chốt 5, sang update sau ③ POST update 6 ④ 500 · lỗi kết nối · quá 30s → ghi last_error Cách 1 giây → 2 → 4 … tối đa 60 giây, gửi lại chính 6 (7 chờ sau 6) ⑤ POST update 6 (gửi lại) ⑥ 200 + {"method": "sendMessage", "chat_id": …, "text": …} Thay bot chạy phương thức trong phản hồi → câu trả lời đến app của người dùng ⑦ POST update 7 …
Update của một bot được gửi lần lượt từng cái một, theo đúng thứ tự

Giải thích sơ đồ

  • Hai trục: bên trái là máy chủ Pabal, bên phải là địa chỉ webhook do bạn vận hành. Nét liền màu xanh là request máy chủ gửi đi, màu xám là phản hồi bình thường của webhook, nét đứt màu đỏ là thất bại.
  • Phản hồi 2xx nghĩa là "đã nhận" (②). Trước đó, update vẫn nằm lại trên máy chủ. Phần thân phản hồi có thể để trống.
  • Nếu thất bại, cùng update đó được gửi lại (④ → ⑤): phản hồi không phải 2xx, lỗi kết nối hay không trả lời trong 30 giây đều tính là thất bại; khoảng cách bắt đầu từ 1 giây và tăng gấp đôi, tối đa 60 giây. Lý do và thời điểm thất bại được ghi lại trong getWebhookInfo.
  • Thứ tự được giữ nguyên: update của một bot được gửi từng cái một, nên 7 phải chờ cho đến khi 6 thành công. Vì vậy nếu webhook thất bại lâu, các update phía sau sẽ dồn lại (pending_update_count).
  • Có thể trả lời ngay trong phản hồi (⑥): nếu phần thân phản hồi 200 chứa method và các tham số, máy chủ sẽ thay bot thực thi phương thức đó. Không cần gửi thêm một request nữa nên nhanh hơn.

Điều kiện của địa chỉ webhook

  • Phải là địa chỉ https://. Chứng chỉ phải do tổ chức cấp chứng chỉ công khai cấp (như Let's Encrypt); không hỗ trợ tải lên chứng chỉ tự ký (certificate).
  • Phải là địa chỉ công khai truy cập được từ Internet. Máy chủ không gửi đến các địa chỉ nội bộ như loopback (127.0.0.1, ::1), mạng riêng (10., 172.16–31., 192.168.), link-local (169.254., metadata đám mây), CGNAT (100.64/10). Tên miền trỏ đến những địa chỉ như vậy cũng bị từ chối, và việc kiểm tra được thực hiện lại mỗi lần gửi.
  • Không giới hạn cổng (không nhất thiết là 443). Không được đưa tên đăng nhập và mật khẩu vào địa chỉ (https://user:pw@…).
  • Chuyển hướng (3xx) không được đi theo mà bị tính là thất bại. Hãy dùng địa chỉ cuối cùng.
  • Phải phản hồi trong vòng 30 giây. Với việc tốn thời gian, hãy trả lời 200 trước rồi xử lý ở phía sau.

Thiết lập

  1. Chạy chương trình nhận webhook. Giả sử bạn chạy một trong các ví dụ bên dưới trên máy chủ bot tại 127.0.0.1:8081.
  2. Đặt HTTPS phía trước. Ví dụ với Caddy, chỉ hai dòng này là có luôn cả chứng chỉ tự động.
    bot.example.com {
        reverse_proxy 127.0.0.1:8081
    }
  3. Tạo một token bí mật. Dài 1–256 ký tự gồm chữ Latin, chữ số, _-. Máy chủ gửi giá trị này trong header của mỗi request, nhờ đó bạn kiểm tra được request có thật sự đến từ máy chủ Pabal hay không.
    export WEBHOOK_SECRET=$(openssl rand -hex 32)
  4. Gọi 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. Kiểm tra trạng thái. Gửi cho bot một tin nhắn từ ứng dụng rồi xem 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"]
      }
    }

    Nếu pending_update_count là 0 thì webhook đang nhận tốt. last_error_datelast_error_messagebản ghi của lần thất bại gần nhất, nên vẫn còn đó kể cả sau khi đã hồi phục — hãy xem thời điểm để đánh giá.

Tham số setWebhook

Tham sốKiểuBắt buộcMô tả
urlStringĐịa chỉ webhook. Chuỗi rỗng sẽ xóa webhook (giống deleteWebhook).
secret_tokenStringTùy chọn1–256 ký tự, A-Z a-z 0-9 _ -. Được gửi trong header X-Telegram-Bot-Api-Secret-Token của mỗi request.
allowed_updatesArray of StringTùy chọnCác loại cần nhận: message, edited_message, callback_query. Để trống là nhận tất cả. Loại không có trong danh sách sẽ không được gửi mà bị bỏ đi.
drop_pending_updatesBooleanTùy chọnNếu true, bỏ hết các update chưa được chuyển rồi mới bắt đầu.
max_connectionsIntegerTùy chọn1–100, mặc định 40. Được nhận và lưu lại, nhưng để giữ thứ tự, Pabal gửi từng cái một cho mỗi bot.
certificateInputFileKhông hỗ trợKhông nhận chứng chỉ tự ký (400). Hãy dùng chứng chỉ công khai.
ip_addressStringBỏ quaĐược nhận nhưng không dùng. Máy chủ phân giải tên miền mỗi lần gửi.

Thiết lập webhook được lưu trên máy chủ, nên vẫn giữ nguyên khi máy chủ khởi động lại, và việc gửi được tiếp tục ngay khi máy chủ chạy. Tuy nhiên, các update chưa kịp chuyển nằm trong bộ nhớ nên sẽ mất khi khởi động lại.

Request mà máy chủ gửi

POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(giá trị đã truyền cho setWebhook)

{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"Mai"},"chat":{"id":100001,"first_name":"Mai","type":"private"},"date":1789805661,"text":"Xin chào"}}
  • Phần thân là một đối tượng Update, giống hệt một phần tử trong kết quả getUpdates.
  • Hãy luôn kiểm tra token bí mật. Nếu header không có hoặc không khớp thì từ chối bằng 401. So sánh bằng hàm có thời gian không đổi (hmac.compare_digest, crypto.timingSafeEqual).
  • Cùng một update có thể đến hai lần (khi kết nối bị ngắt trước lúc phản hồi đến được máy chủ). Bỏ qua những update đã xử lý dựa trên update_id là an toàn.

Trả lời ngay trong phản hồi

Nếu phần thân của phản hồi 200 chứa một lời gọi Bot API, máy chủ sẽ thay bot thực thi lời gọi đó. Đặt tên phương thức vào method và đưa các tham số còn lại vào cùng.

{"method": "sendMessage", "chat_id": 100001, "text": "Chào bạn!"}
  • Định dạng phần thân có thể là JSON, form hoặc multipart/form-data (aiogram gửi bằng multipart). Kích thước tối đa 1MB.
  • Kết quả hay lỗi của lời gọi này không được trả về cho bot (chỉ ghi vào log máy chủ). Nếu cần kết quả, hãy gửi request riêng như bình thường.
  • Nếu không có gì để trả lời, chỉ cần trả 200 không có phần thân.

Ví dụ

Cả bốn ví dụ đều kiểm tra token bí mật, và khi nhận được tin nhắn văn bản thì đưa sendMessage vào phản hồi để lặp lại lời người dùng. Giả định rằng HTTPS (ví dụ Caddy) được đặt phía trước và chương trình nhận tại 127.0.0.1:8081.

# webhook.py — chỉ dùng thư viện chuẩn
# Chạy: 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 trở lên, không cần thư viện
// Chạy: 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
# Chương trình này tự thiết lập webhook (setWebhook) luôn
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: gửi kèm trong phản hồi webhook


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]"
# Chương trình này tự thiết lập webhook (setWebhook) luôn
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"])

Xem trạng thái — getWebhookInfo

TrườngÝ nghĩa
urlĐịa chỉ đã thiết lập. Chuỗi rỗng nếu chưa có webhook
pending_update_countSố update đang chờ vì chưa chuyển được
ip_addressIP của địa chỉ đã gửi đến lần gần nhất
last_error_date, last_error_messageThời điểm (giây Unix) và lý do của lần thất bại gần nhất. Không có nếu chưa từng thất bại. Vẫn còn sau khi đã hồi phục
max_connections, allowed_updatesGiá trị đã truyền cho setWebhook
has_custom_certificateLuôn là false

Các thông báo trong last_error_message

Thông báoNguyên nhân
Connection refusedKhông có gì đang lắng nghe tại địa chỉ và cổng đó. Chương trình webhook hoặc proxy đang tắt
Connection timed outTường lửa chặn hoặc không truy cập được địa chỉ
Read timeout expiredKhông phản hồi trong vòng 30 giây
Failed to resolve host: Name or service not knownKhông tìm thấy tên miền (DNS)
SSL error {…}Vấn đề chứng chỉ — hết hạn, tự ký, sai tên
Wrong response from the webhook: 502 Bad GatewayPhản hồi không phải 2xx. Con số là mã trạng thái mà webhook trả về
IP address 10.0.0.5 is reservedTên miền trỏ đến địa chỉ nội bộ (không gửi)

Quay lại getUpdates

curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# Để bỏ luôn các update đang chờ: deleteWebhook?drop_pending_updates=true

Những update mà webhook đã nhận (đã trả lời 2xx) sẽ không đến lại qua getUpdates. Chỉ những update chưa được chuyển mới tiếp tục được nhận qua getUpdates.

Thử nghiệm trong lúc phát triển

  • Tunnel: dùng công cụ gắn địa chỉ HTTPS công khai vào 127.0.0.1:8081 trên máy của bạn (cloudflared, ngrok…) thì có thể thử cả với máy chủ Pabal đang vận hành.
  • Máy chủ Pabal của bạn: nếu bạn tự chạy một máy chủ Pabal để phát triển, hãy bật TELEGRAM_WEBHOOK_ALLOW_LOCAL=true. Khi đó máy chủ sẽ gửi cả đến địa chỉ cục bộ như http://127.0.0.1:8081/…. Tuyệt đối không bật trên máy chủ vận hành thật — một con bot bất kỳ sẽ có thể gửi request vào mạng nội bộ của máy chủ (cơ sở dữ liệu, metadata đám mây).

Danh sách kiểm tra khi vận hành

  • Đã đặt token bí mật và kiểm tra nó ở mọi request.
  • Trả lời trong vòng 30 giây — việc tốn thời gian thì chuyển vào hàng đợi và trả 200 ngay.
  • Lọc update trùng bằng update_id.
  • Nếu webhook ngừng lâu, các update phía sau sẽ dồn lại và mất khi máy chủ khởi động lại — hãy giám sát chương trình webhook (pending_update_count, last_error_date của getWebhookInfo).
  • Nếu token bị lộ, dùng /revoke rồi gọi lại setWebhook với token mới. Đổi luôn cả token bí mật.