Skip to content

Webhook

Polling needs a process that runs forever and adds a round trip of latency. The alternative is to let Telegram post each update to a URL you serve.

Both are supported the same way, and one setting says which one this deployment uses:

TELEGRAM_BOT_DEFAULTS = {'MODE': 'webhook'}  # or 'polling', the default

Scalars can come from the environment, so the choice can be made at startup without touching code — set it where the process is started, which for the compose recipe on Deployment is the service's own environment::

  telegram_bot:
    command: python manage.py start_tgbot
    environment:
      DJANGO_AIOGRAM_MODE: webhook

In a shell it needs exporting: a bare assignment sets a variable in that shell and does not pass it to anything it starts, so the command below would read the setting from your settings module and not from the line above it:

export DJANGO_AIOGRAM_MODE=webhook
python manage.py start_tgbot

start_tgbot prints which mode it is running in, honours --mode for a single run, and the view refuses to serve while the mode is polling — two sources of updates at once means no way to tell which one handled what.

Polling is the default because it needs nothing but an outbound connection. Reach for a webhook when the delay matters, or when an inbound HTTP endpoint is easier to run than a process that calls Telegram in a loop.

It does not remove the need for a long-running process. Only inbound polling goes away: a send from a web or worker process still goes onto the broker, and something has to consume it — which is start_tgbot, the same process that would have polled. (Inside that process bot.send() calls Telegram directly and queues nothing.) Webhook mode is not a route to serverless.

Webhooks are also not always possible — a public HTTPS endpoint with a valid certificate is a hard requirement, and plenty of deployments cannot offer one. Nothing here pushes you towards them.

Past a hundred bots or so this stops being one of two options: polling holds a connection per bot against a session whose connector limit is 100, and a webhook holds none. Scaling has the numbers and what to do between twenty and a hundred.

What changes

Polling Webhook
Who receives updates the start_tgbot container whichever process serves the URL, normally web
Where handlers run that container in a request thread of your web server
Public HTTPS needed no yes, Telegram refuses plain HTTP
Outbound queue consumed by the same container still needs a consumer somewhere

The last row is the one that catches people. Inbound updates and outbound bot.send() calls are unrelated: the queue still needs a worker, and in webhook mode that worker no longer polls.

Setting it up

1. Configure it.

# settings.py
import os

TELEGRAM_BOT_DEFAULTS = {
    'TOKEN': os.environ['TELEGRAM_BOT_TOKEN'],
    'REDIS_URL': os.environ['REDIS_URL'],
    'MODE': 'webhook',
    'WEBHOOK_URL': 'https://example.com/tg/9c1f2b7a/',
    'WEBHOOK_SECRET': os.environ['TELEGRAM_WEBHOOK_SECRET'],
}

MODE set to webhook without a URL is check error E027: half-configured, the bot would receive nothing and say nothing about it.

The secret is not optional: the view refuses to run without one, and manage.py check reports E027. Telegram echoes it back in the X-Telegram-Bot-Api-Secret-Token header, and the view compares it with hmac.compare_digest. Give the path an unguessable segment too — that is one less thing scanning the internet will find.

2. Serve the view.

# urls.py
from django.urls import path

from django_aiogram.consumer.webhook import telegram_webhook

urlpatterns = [
    path('tg/9c1f2b7a/', telegram_webhook),
]

It is CSRF-exempt and accepts POST only. The view is a coroutine: it hands the update to the loop the bot already runs on — the one its HTTP session is bound to, under ASGI and WSGI alike — and awaits the handlers, so the response still says what they did. Under WSGI Django drives it on a loop of its own per request, and no aiogram object is ever built on that loop -- the bot, its session and its dispatcher belong to the bot's own.

Awaiting rather than blocking is what makes it safe under ASGI. A synchronous view runs on the thread asgiref lends the request, and that thread is also the thread-sensitive executor a handler gets when it reaches the ORM through afirst, aget or sync_to_async: blocking it means the update waits for a thread that is waiting for the update. Telegram does not wait for ever: it times out the delivery and redelivers, so every retry strands one more thread — and the database connection it opened — until the process runs out of connections. Measured on an affected deployment, the timeout fell at about a minute and the redelivery a minute after that; Telegram documents neither, so do not build anything on those numbers.

Serving it from the bot container instead

The steps above put the webhook in the web tier, which is where most deployments want it: the route is already there, and the server is already running. What it costs is that an update is handled beside the requests people are waiting on, competing for that process's threads and its database connections.

A deployment that would rather keep them apart serves the webhook where the bot already is:

pip install "django-aiogram[webhook]"
python manage.py start_tgbot --serve --serve-port 8080

The container then answers the webhook itself, on a server whose only routes are the two the view needs — one bot's and the process's own. Nothing is registered in your urls.py, and the path is not configured twice: it is read from WEBHOOK_URL, the URL manage.py tgbot_webhook set gave Telegram. Point your proxy at the container on that port and the web tier serves no updates at all.

--serve is refused where it would bind a port nothing posts to — a polling deployment, or one that also passed --no-updates — and it refuses to start without the webhook extra, naming every package of that group that is missing and the line that installs them.

3. Register it with Telegram.

python manage.py tgbot_webhook set
python manage.py tgbot_webhook info

Telegram remembers the URL, not your settings file. info prints the pending count and the last delivery error, which is the first thing to look at when updates stop arriving.

Several bots

One URL for every bot would leave the update's own contents as the only clue about which bot it is for, and one shared secret would let a leak from one client's bot post as every other. So the path carries the identity and each bot has its own secret — the identity being the number in its token, which Multiple bots explains. A token that carries none has no path to be reached at: E052 reports it, the providers leave it out, and such a bot can only queue sends:

# urls.py
urlpatterns = [
    # the unguessable segment first, the identity last: `WEBHOOK_URL` is the prefix and
    # `tgbot_webhook` appends the identity to it, so the order here is the order it registers
    path('tg/9c1f2b7a/<int:bot_id>/', telegram_webhook),
    path('tg/9c1f2b7a/', telegram_webhook),  # the process's own bot, if it has one
]
TELEGRAM_BOT_DEFAULTS = {
    'WEBHOOK_URL': 'https://example.test/tg/9c1f2b7a',  # no identity: the command adds it
}

manage.py tgbot_webhook reconcile then registers https://example.test/tg/9c1f2b7a/123456/ for the bot whose identity is 123456, which is the route above. Put the identity anywhere else in the path and Telegram will be posting somewhere Django does not route.

The secret goes in that bot's own settings — a section under TELEGRAM_BOTS, or the overrides on its TelegramBot row:

TELEGRAM_BOTS = {
    'support': {'TOKEN': '...', 'WEBHOOK_SECRET': '...'},
}

Generate them; do not type them. django_aiogram.consumer.webhook.new_secret() is what the package uses. A bot with no secret of its own is refused rather than served under the process's, because served that way every bot would accept the same one again.

An update whose path names a bot this deployment does not serve gets a 404, answered from a cached set of identities: a webhook that read the database per request would be a way for a stranger to load it by posting nonsense. The cache is held for BOT_REFRESH_INTERVAL, and an identity missing from it is re-read at most once a second — so a bot registered a moment ago is served, and a flood of unknown identities is not a query each.

Registering a thousand of them

python manage.py tgbot_webhook reconcile
python manage.py tgbot_webhook reconcile --bot 123456 --force
python manage.py tgbot_webhook delete --bot 123456

reconcile asks Telegram what it has — getWebhookInfo — compares it with what each bot should have, and repairs the difference in both directions, as far as the tokens reach: a bot this deployment serves gets its webhook, and a bot whose row is switched off has its webhook deleted, because a disabled bot is one nobody serves and Telegram would go on posting updates that answer 404. That is the only way to know which way the drift went — nothing local can tell you. A bot already registered costs one read, so running it again is cheap and running it after every deploy is the intended use.

It paces itself: --pause between calls, jittered, so a thousand bots starting at once are not a thousand setWebhook calls arriving together — and two containers that started together do not walk the list in step. One bot's failure is its own; the pass says what it could not do and carries on.

Telegram never reports the secret, so a rotated one looks like no change from here: --force is how it is applied.

Switching a row off is not deregistering its webhook, so reconcile does it: a disabled bot is one nobody serves, and its updates would get a 404 while Telegram went on posting them. delete --bot <id> reaches a switched-off bot too, for the same reason, and says so when it does.

Delete before removing the row, not after. Deleting a webhook needs that bot's token, and once the row is gone this deployment has none — so Telegram goes on posting to a URL that answers 404 for ever. reconcile cannot repair that either: there is nothing left to ask with.

4. Run a worker for the queue.

python manage.py start_tgbot

In webhook mode the same command consumes the queue and never calls getUpdates. Skipping it means bot.send() from your app queues messages nobody delivers.

--mode polling and --mode webhook override the setting for one run, which is what you want when trying the other mode without editing anything. It changes this process only — the view reads the setting — so the command warns when the two disagree and says what will happen: a webhook worker whose setting says polling gets no updates, because the view refuses them, and polling while a webhook is registered fails at getUpdates. For a real switch, change MODE (or DJANGO_AIOGRAM_MODE) everywhere.

Going back to polling

python manage.py tgbot_webhook delete
python manage.py start_tgbot --mode polling

getUpdates refuses to run while a webhook is registered, so polling will not start until the webhook is deleted. Set MODE back to 'polling' once you have decided to stay there — tgbot_webhook set warns when you register a webhook that the configured mode will not use.

What to watch out for

Handlers run in your web workers. A handler that blocks holds a request worker for as long as it takes. That is fine for a reply and wrong for a job that takes a minute — queue that work instead.

FSM state must be shared. With several web workers, the update that starts a dialogue and the one that continues it land in different processes. FSM_STORAGE: 'redis' is the default and it is what makes that work; 'memory' cannot.

Telegram retries a non-2xx. The view answers 200 even when a handler raised, because a handler that failed once will fail the same way on redelivery — that is a loop, not a retry. Failures are logged; see Logging.

It answers 503 for the other case: an update that was refused rather than handled, because nothing ran it. Shutting down is one reason; a loop already closed by an earlier close() is another, and that one used to answer 200 — so the update was lost and Telegram was told not to try again. There redelivery is the point — during a rolling restart it is the difference between the update moving to the next instance and disappearing into a 200 nobody acted on.

What a POST gets back: 200 handled, or a handler raised; 503 refused, so redeliver; 403 the secret does not match — the comparison is on bytes, so a secret outside ASCII is compared like any other and a matching one passes; 404 the path names a bot this deployment does not serve, where redelivery would be Telegram trying for ever; 400 the body is not an update Telegram could have sent. Anything that is not a POST gets 405.

All seven reasons for a 503, in the order the view checks them: ENABLED is off in this process; MODE cannot be read; MODE is not webhook, so a worker is polling and two sources of updates would be one too many; the bot the path names cannot be resolved; there is no WEBHOOK_SECRET for it — read only after the mode check, because a polling deployment has no reason to set one; building the bot raised ImproperlyConfigured, most often a TOKEN that is missing or malformed; and nothing ran the update — the process is shutting down, its loop is closed, or the loop's own thread had not started yet. Each has its own log line, and Troubleshooting lists them.

Updates are not queued at all. They go straight from the request to the dispatcher, whichever transport is configured: the broker carries outbound messages only, in both modes.

Health

The healthcheck reads the consumer's heartbeat — python -m django_aiogram.healthcheck in a container, manage.py tgbot_healthcheck by hand — so in webhook mode it answers for the start_tgbot worker rather than for the web process: the worker still runs the queue consumer, it just does not poll. Do not point it at the web service, which writes no heartbeat and would read unhealthy for ever. See Deployment.