개발자 문서
한국어

봇 개발

웹훅

getUpdates 대신 서버가 새 소식을 봇의 HTTPS 주소로 바로 보내게 하는 법 — 설정, 검증, 재시도, 응답으로 답하기.

getUpdates 는 봇이 서버에 계속 묻는 방식입니다. 웹훅은 반대로, 새 소식이 생기면 파발 서버가 봇의 HTTPS 주소로 바로 보내 주는 방식입니다. 봇이 늘 켜져 있는 서버(클라우드·사내 서버)에서 돈다면 웹훅이 더 알맞습니다.

getUpdates (롱 폴링)웹훅
누가 먼저봇이 서버에 묻는다서버가 봇에게 보낸다
봇에게 필요한 것밖으로 나가는 인터넷만공개된 HTTPS 주소
알맞은 곳내 컴퓨터, 개발 중, 방화벽 안늘 켜진 서버, 서버리스, 여러 봇
동시에 쓰기안 됨 — 웹훅이 설정돼 있으면 getUpdates 는 409 Conflict

웹훅은 이렇게 오갑니다

파발 서버 봇의 웹훅 (HTTPS) ① POST 업데이트 5 · X-Telegram-Bot-Api-Secret-Token ② 200 OK → 5 는 확정, 다음으로 ③ POST 업데이트 6 ④ 500 · 연결 실패 · 30초 넘김 → last_error 기록 1초 → 2초 → 4초 … 최대 60초 간격으로 같은 6 을 다시 (7 은 6 뒤에서 기다림) ⑤ POST 업데이트 6 (다시) ⑥ 200 + {"method": "sendMessage", "chat_id": …, "text": …} 응답에 담긴 메서드를 봇 대신 실행 → 답장이 사람의 앱으로 ⑦ POST 업데이트 7 …
한 봇의 업데이트는 한 번에 하나씩, 순서대로

다이어그램 설명

  • 두 줄기: 왼쪽이 파발 서버, 오른쪽이 여러분이 운영하는 웹훅 주소입니다. 파란 실선은 서버가 보내는 요청, 회색은 웹훅의 정상 응답, 빨간 점선은 실패입니다.
  • 2xx 응답이 "받았음"입니다(②). 그 전까지 업데이트는 서버에 남아 있습니다. 본문은 비워도 됩니다.
  • 실패하면 같은 업데이트를 다시 보냅니다(④ → ⑤): 2xx 가 아닌 응답, 연결 실패, 30초 안에 답이 없는 경우 모두 실패이고, 간격은 1초에서 두 배씩 늘어 최대 60초입니다. 실패 이유와 시각은 getWebhookInfo 에 남습니다.
  • 순서가 지켜집니다: 한 봇의 업데이트는 한 번에 하나씩 보내므로, 6 이 성공할 때까지 7 은 기다립니다. 그래서 웹훅이 오래 실패하면 뒤의 업데이트가 쌓입니다(pending_update_count).
  • 응답으로 답할 수 있습니다(⑥): 200 응답 본문에 method 와 파라미터를 담으면 서버가 그 메서드를 봇 대신 실행합니다. 요청을 한 번 더 보내지 않아도 되어 빠릅니다.

웹훅 주소의 조건

  • https:// 주소여야 합니다. 인증서는 공인 인증 기관(Let's Encrypt 등)에서 받은 것이어야 하고, 자체 서명 인증서 올리기(certificate)는 지원하지 않습니다.
  • 인터넷에서 닿는 공인 주소여야 합니다. 서버는 루프백(127.0.0.1, ::1), 사설망(10., 172.16–31., 192.168.), 링크 로컬(169.254., 클라우드 메타데이터), CGNAT(100.64/10) 같은 내부 주소로는 보내지 않습니다. 도메인이 그런 주소를 가리켜도 거절하며, 보낼 때마다 다시 확인합니다.
  • 포트는 제한이 없습니다(443 이 아니어도 됨). 주소에 사용자 이름·비밀번호(https://user:pw@…)를 넣을 수 없습니다.
  • 리다이렉트(3xx)는 따라가지 않고 실패로 봅니다. 최종 주소를 넣으세요.
  • 30초 안에 응답해야 합니다. 오래 걸리는 일은 먼저 200 으로 답하고 뒤에서 처리하세요.

설정하기

  1. 웹훅을 받을 프로그램을 띄웁니다. 아래 예제 중 하나를 봇 서버에서 127.0.0.1:8081 로 띄운다고 합시다.
  2. HTTPS 를 앞에 둡니다. 예를 들어 Caddy 라면 이 두 줄로 인증서까지 자동입니다.
    bot.example.com {
        reverse_proxy 127.0.0.1:8081
    }
  3. 비밀 토큰을 하나 만듭니다. 영문자·숫자·_·- 로 1~256자. 서버가 요청마다 이 값을 헤더에 넣어 보내므로, 요청이 진짜 파발 서버에서 왔는지 확인할 수 있습니다.
    export WEBHOOK_SECRET=$(openssl rand -hex 32)
  4. 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. 상태를 확인합니다. 앱에서 봇에게 메시지를 보내고 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"]
      }
    }

    pending_update_count 가 0 이면 잘 받고 있는 것입니다. last_error_date·last_error_message마지막 실패의 기록이라 회복한 뒤에도 남아 있습니다 — 시각을 보고 판단하세요.

setWebhook 파라미터

파라미터타입필수설명
urlString웹훅 주소. 빈 문자열이면 웹훅을 지웁니다(deleteWebhook 과 같음).
secret_tokenString선택1~256자, A-Z a-z 0-9 _ -. 요청마다 X-Telegram-Bot-Api-Secret-Token 헤더로 보냅니다.
allowed_updatesArray of String선택받을 종류: message, edited_message, callback_query. 비우면 전부. 빠진 종류는 보내지 않고 버립니다.
drop_pending_updatesBoolean선택true 면 아직 전달하지 않은 업데이트를 모두 버리고 시작합니다.
max_connectionsInteger선택1~100, 기본 40. 받아서 보관하지만, 파발은 순서를 지키려고 봇마다 한 번에 하나씩 보냅니다.
certificateInputFile안 됨자체 서명 인증서는 받지 않습니다(400). 공인 인증서를 쓰세요.
ip_addressString무시받지만 쓰지 않습니다. 서버는 도메인을 그때그때 조회합니다.

웹훅 설정은 서버에 저장되어 서버를 다시 켜도 유지되고, 켜지자마자 다시 보내기 시작합니다. 다만 아직 전달하지 못한 업데이트는 메모리에 있어서 재시작 때 사라집니다.

서버가 보내는 요청

POST /pabal-webhook HTTP/1.1
Host: bot.example.com
Content-Type: application/json
X-Telegram-Bot-Api-Secret-Token: 3f1c…(setWebhook 에 준 값)

{"update_id":12,"message":{"message_id":3,"from":{"id":100001,"is_bot":false,"first_name":"하나"},"chat":{"id":100001,"first_name":"하나","type":"private"},"date":1789805661,"text":"안녕"}}
  • 본문은 getUpdates 결과의 원소 하나와 똑같은 Update 객체입니다.
  • 비밀 토큰을 꼭 확인하세요. 헤더가 없거나 다르면 401 로 거절합니다. 비교는 시간이 일정한 함수(hmac.compare_digest, crypto.timingSafeEqual)로 합니다.
  • 같은 업데이트가 두 번 올 수 있습니다(응답이 서버에 닿기 전에 연결이 끊긴 경우). update_id 로 이미 처리한 것은 건너뛰면 안전합니다.

응답으로 답하기

200 응답의 본문에 Bot API 호출 하나를 담으면, 서버가 그 호출을 봇 대신 실행합니다. method 에 메서드 이름을 넣고 나머지 파라미터를 함께 넣습니다.

{"method": "sendMessage", "chat_id": 100001, "text": "안녕하세요!"}
  • 본문 형식은 JSON, 폼, multipart/form-data 모두 됩니다(aiogram 은 multipart 로 보냅니다). 크기는 1MB 까지입니다.
  • 이 호출의 결과나 오류는 봇에게 돌아가지 않습니다(서버 로그에만 남음). 결과가 필요하면 평소처럼 따로 요청하세요.
  • 답할 것이 없으면 본문 없이 200 만 주면 됩니다.

예제

네 예제 모두 비밀 토큰을 확인하고, 글 메시지를 받으면 응답에 sendMessage 를 담아 따라 말합니다. 앞에 HTTPS(예: Caddy)를 두고 127.0.0.1:8081 에서 받는다고 가정합니다.

# webhook.py — 표준 라이브러리만
# 실행: 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 이상, 라이브러리 없이
// 실행: 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
# 웹훅 설정(setWebhook)까지 이 프로그램이 합니다
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: 웹훅 응답에 담아 보냄


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]"
# 웹훅 설정(setWebhook)까지 이 프로그램이 합니다
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"])

상태 보기 — getWebhookInfo

필드
url설정된 주소. 웹훅이 없으면 빈 문자열
pending_update_count아직 전달하지 못하고 기다리는 업데이트 수
ip_address마지막으로 보낸 주소의 IP
last_error_date, last_error_message마지막 실패의 시각(유닉스 초)과 이유. 실패한 적이 없으면 없음. 회복한 뒤에도 남음
max_connections, allowed_updatessetWebhook 에 준 값
has_custom_certificate항상 false

last_error_message 에 뜨는 말

메시지원인
Connection refused그 주소·포트에서 아무것도 듣고 있지 않음. 웹훅 프로그램이나 프록시가 꺼져 있음
Connection timed out방화벽이 막거나 주소가 닿지 않음
Read timeout expired30초 안에 응답하지 않음
Failed to resolve host: Name or service not known도메인 이름을 찾을 수 없음 (DNS)
SSL error {…}인증서 문제 — 만료, 자체 서명, 이름 불일치
Wrong response from the webhook: 502 Bad Gateway2xx 가 아닌 응답. 숫자가 웹훅이 준 상태 코드
IP address 10.0.0.5 is reserved도메인이 내부 주소를 가리킴 (보내지 않음)

getUpdates 로 돌아가기

curl -s "https://pabal.me/bot$BOT_TOKEN/deleteWebhook"
# 기다리던 업데이트까지 버리려면: deleteWebhook?drop_pending_updates=true

웹훅이 이미 받아 간(2xx 로 답한) 업데이트는 getUpdates 로 다시 오지 않습니다. 아직 전달하지 못한 것만 getUpdates 로 이어서 받습니다.

개발 중에 시험하기

  • 터널: 내 컴퓨터의 127.0.0.1:8081 에 공개 HTTPS 주소를 붙여 주는 도구(cloudflared, ngrok 등)를 쓰면 운영 파발 서버로도 시험할 수 있습니다.
  • 내 파발 서버: 개발용 파발 서버를 직접 띄웠다면 TELEGRAM_WEBHOOK_ALLOW_LOCAL=true 로 켜세요. 그 서버는 http://127.0.0.1:8081/… 같은 로컬 주소로도 보냅니다. 운영 서버에서는 절대 켜지 마세요 — 봇 하나가 서버 내부망(데이터베이스, 클라우드 메타데이터)에 요청을 보낼 수 있게 됩니다.

운영 점검표

  • 비밀 토큰을 정했고, 모든 요청에서 확인한다.
  • 30초 안에 답한다 — 오래 걸리는 일은 큐로 넘기고 바로 200.
  • update_id 로 중복을 걸러 낸다.
  • 웹훅이 오래 멈추면 뒤의 업데이트가 쌓이고, 서버 재시작 때 사라진다 — 웹훅 프로그램을 감시한다(getWebhookInfopending_update_count, last_error_date).
  • 토큰이 새면 /revoke 후 새 토큰으로 setWebhook 을 다시 부른다. 비밀 토큰도 함께 바꾼다.