RE:NODE

Telegram webhook not working: reading getWebhookInfo

By RE:NODE Team
15 min read


When a Telegram bot on a webhook stops receiving updates, the answer is almost always sitting in one API call: getWebhookInfo. It tells you which URL Telegram thinks it should deliver to, which IP address it resolved, how many updates are queued, and - most usefully - the last error your server returned, quoted back to you in plain text. Read url, pending_update_count and last_error_message together and you know which of four things is wrong: the registration, the TLS, the HTTP response, or the speed of your handler. Fix that one thing, and the queued updates drain on their own.

This post is the troubleshooting half of the subject. The choice between polling and webhooks, BotFather settings, library code and deployment are covered in Telegram bot hosting: polling and webhooks; here the webhook is already set and something is not arriving.

Start with getWebhookInfo#

It takes no parameters and works from any machine with the token, so you can run it from your laptop while the bot runs elsewhere:

bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"

A broken webhook looks something like this (formatted for reading):

json
{  "ok": true,  "result": {    "url": "https://bot.example.com/tg/hook",    "has_custom_certificate": false,    "pending_update_count": 37,    "ip_address": "203.0.113.10",    "last_error_date": 1760511600,    "last_error_message": "Wrong response from the webhook: 404 Not Found",    "max_connections": 40,    "allowed_updates": ["message", "callback_query"]  }}

The fields, as the Bot API defines them:

FieldWhat it tells you
urlWhere Telegram delivers. Empty means no webhook - the bot is in polling mode
has_custom_certificatetrue if you uploaded a certificate with setWebhook
pending_update_countUpdates waiting to be delivered
ip_addressThe address Telegram is actually sending to
last_error_dateUnix time of the most recent delivery failure
last_error_messageHuman-readable text of that failure
last_synchronization_error_dateA sync error inside Telegram, not on your side
max_connectionsConcurrent deliveries allowed, 1-100
allowed_updatesUpdate types the bot is subscribed to

The optional fields simply disappear when they have nothing to say, so a healthy webhook shows no last_error_* keys at all, or only an old date. last_error_date is seconds since the epoch; date -d @1760511600 on Linux turns it into something readable, and comparing it with the current time is the first thing to do. An error from three days ago with pending_update_count at zero is history, not a problem.

Reading the fields together#

No single field diagnoses anything. The combinations do:

What you seeWhat it means
url is emptyNo webhook is registered. Something called deleteWebhook, or a poller cleared it
url is wrong or oldA stale registration: old hostname, old path, staging URL
Count rising, recent errorTelegram cannot deliver and is retrying
Count rising, no recent errorDeliveries succeed but your handler is too slow
Count zero, no error, no repliesUpdates arrive; the bug is in your code or allowed_updates

The first row catches more people than it should. Several libraries call deleteWebhook when you start them in polling mode, so running the bot locally once "to test something" with the production token silently removes the production webhook. The webhook does not come back until something calls setWebhook again. If your deploy only registers the webhook when the URL changes, it may never come back by itself.

The last row is the one where getWebhookInfo cannot help you, because from Telegram's side everything is fine: it posted the update, your server answered 200, done. Check allowed_updates first - if it lists only message, button presses (callback_query) never arrive, and chat_member updates are only sent when you ask for them explicitly. Then check your logs; an exception swallowed after the 200 was sent looks exactly like silence.

ip_address deserves a glance every time. It is the address Telegram resolved from your hostname, and if it is not the server you think it is, you have a DNS problem: an A record still pointing at an old host, or a record that has not finished propagating. Telegram's webhook delivery is IPv4 only - the webhook guide states plainly that IPv6 is not supported - so a hostname with only an AAAA record cannot receive webhooks at all. DNS records explained covers checking which records a name actually has.

SSL errors#

Any last_error_message beginning with SSL error means Telegram reached your address and refused the TLS handshake. The text usually contains an OpenSSL error string such as certificate verify failed, and the exact wording has changed as Telegram's OpenSSL version has changed, so match on the meaning rather than the code.

Telegram's published requirements are short and strict:

  • TLS 1.2 or newer. SSLv2, SSLv3, TLS 1.0 and 1.1 are refused.
  • The certificate's CN or a SAN must match the hostname in the webhook URL.
  • The server must send the full chain - your certificate plus every intermediate.
  • A self-signed certificate works only if you upload it with the certificate parameter of setWebhook, after which has_custom_certificate shows true.
  • TLS is required on every allowed port, including 80. Port 80 is permitted, plain HTTP is not.

The missing intermediate is the classic. Browsers often paper over it because they cache intermediates or fetch them themselves, so the site looks fine in Chrome while Telegram, which does neither, rejects it. Test the way a strict client would:

bash
$ openssl s_client -connect bot.example.com:443 -servername bot.example.com </dev/null \    | grep -E "Verify return code|subject=|issuer="

Verify return code: 0 (ok) is what you want. Code 21 (unable to verify the first certificate) is the missing chain: in nginx that means ssl_certificate points at the leaf certificate instead of the full chain file. Certbot writes both, and fullchain.pem is the one to use. An expired certificate gives code 10, and a hostname mismatch is visible in the subject= line. SSL certificate errors explained goes through each of these in detail.

One less obvious cause: a self-signed certificate uploaded months ago, then replaced with a real one without re-registering. Telegram still pins the old one. Call setWebhook again without the certificate parameter and has_custom_certificate goes back to false.

Wrong response from the webhook#

Wrong response from the webhook: <status> means the TLS handshake worked and your server answered, but not with a 2xx. Telegram treats anything outside 2xx as a failure and retries. The status in the message is the clue:

StatusUsual cause
404 Not FoundPath mismatch between the registered URL and your route
401 / 403Your secret-token check rejecting Telegram (see below)
405 Method Not AllowedRoute registered for GET; Telegram only POSTs
301 / 302 / 308A redirect, which Telegram treats as a failure
500 Internal Server ErrorYour handler threw before responding
502 / 503 / 504The proxy is up, the app behind it is not

The 404 is almost always a trailing slash, a prefix, or a path with the token in it that no longer matches since the token was revoked. Compare url from getWebhookInfo character by character with the route in your code. Libraries that build the route from a url_path option and the URL from a separate webhook_url option make it easy to change one and not the other.

Redirects come from the layer in front of your app: a redirect from the bare domain to www or the reverse, a proxy that sends one path prefix somewhere else, or a framework that appends a trailing slash. Register the exact final URL, the one that answers without a redirect.

The 5xx group splits cleanly. A 500 came from your code, so the stack trace is in your application log. A 502 came from the reverse proxy, which means the app is down, crashed on start, or is listening somewhere the proxy is not looking. The commonest form is an app bound to 127.0.0.1 inside a container while the proxy connects from outside it, or an app listening on a hard-coded port that is not the one the plan allocated. What a reverse proxy actually does explains why the proxy and the app can disagree about where the app lives.

One more source of 500s that looks like a Telegram problem: a handler that crashes on one particular update. Telegram retries it, the handler crashes again, and because the update keeps failing the rest of the queue grows behind it. A single malformed message can stall the bot until that update is dropped. Wrap your update processing so an exception is logged and the request still gets its 200.

Timeouts and connection errors#

When the message talks about the connection rather than a status, Telegram never got an HTTP answer at all. The wording varies, but the families are:

  • Connection refused - the address resolved and something replied that nothing is listening on that port. The proxy is not running, or the port in the URL is not open.
  • Connection timed out - packets went nowhere. A firewall dropping traffic, a wrong IP, or a home router with no port forward.
  • Read timeout expired - the connection opened and your server took too long to answer. This is a slow handler, not a network problem.
  • Failed to resolve host - the hostname has no A record Telegram can use.

Telegram does not publish its delivery timeout, so do not design to a number. Design so that the answer takes milliseconds regardless.

If you restrict inbound traffic by source address, Telegram's webhook guide lists the ranges it delivers from: 149.154.160.0/20 and 91.108.4.0/22. The same page says they may change, so an allow-list built from them is something to recheck when deliveries suddenly start timing out. Most people are better served by the secret token than by an IP allow-list. Firewall rules that matter covers the trade-off.

Ports and the setWebhook errors#

Webhooks are delivered to exactly four ports: 443, 80, 88 and 8443. Not 3000, not 5000, not 8080. setWebhook refuses any other port with a bad webhook error at registration time, so if you are seeing that error you have not got as far as a delivery failure. Other refusals at registration: a URL that is not HTTPS, and a hostname that does not resolve.

The port in the URL is the port Telegram connects to, not the port your app listens on. The normal arrangement is a reverse proxy terminating TLS on 443 and forwarding to the app's own port:

Failed to resolve hostSSL error, refused, timeout502 if the app is down200 within millisecondsTelegram149.154.160.0/20DNS lookupA record, IPv4Reverse proxyTLS on 443Bot app0.0.0.0, plan port
Where a webhook delivery can fail

Each arrow is a different family of last_error_message, which is why that field narrows the search so quickly. On RE:NODE app plans this arrangement is the proxy slot: point an A record at the address shown, and the certificate is issued and renewed automatically inside a 21-day window, with TLS on 443 and the request forwarded to the port your app listens on. The app still has to listen on 0.0.0.0 and the plan's port; that part is yours. Running the proxy yourself on your own machine is covered in the nginx reverse proxy guide.

The ip_address parameter of setWebhook lets you give Telegram a fixed IP to send to instead of resolving the hostname. TLS is still checked against the hostname. It is useful when DNS is in transition and you cannot wait, and a trap when you forget it was set and later move the bot - the new A record is then ignored. getWebhookInfo shows whichever address is in use.

The backlog and drop_pending_updates#

While deliveries fail, updates queue. Telegram keeps undelivered updates for up to 24 hours, retries delivery, and once you fix the cause the backlog is delivered in a rush - limited by max_connections, which defaults to 40 concurrent requests. That has two effects people do not expect. A bot that was broken for a night answers a night's worth of messages within seconds of the fix, replying to conversations that ended hours ago. And on a small server, 40 simultaneous deliveries can be enough load to cause a second round of timeouts.

When the backlog is worthless, throw it away. Both setWebhook and deleteWebhook take drop_pending_updates:

bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/setWebhook" \    -d "url=https://bot.example.com/tg/hook" \    -d "secret_token=$WEBHOOK_SECRET" \    -d "max_connections=10" \    -d "drop_pending_updates=true"

pending_update_count should read 0 straight afterwards. Note that setWebhook replaces the whole configuration: leave out secret_token or allowed_updates on a re-registration and they are gone. Always pass the full set you want.

Do not drop the backlog by reflex. For a bot that records orders, payments or form submissions, those queued updates are real work, and the right move is to fix the fault, lower max_connections to something your server handles, and let them drain. Watch the count fall with repeated getWebhookInfo calls.

The secret_token header#

secret_token is a string you choose, 1-256 characters from A-Z, a-z, 0-9, _ and -, passed to setWebhook. Telegram then sends it in an X-Telegram-Bot-Api-Secret-Token header on every delivery, and your handler compares it and rejects anything that does not match. It is the cheap, correct way to make sure only Telegram can feed your bot updates.

It also produces a specific failure. Your handler returns 401 or 403, last_error_message shows Wrong response from the webhook: 403 Forbidden, and the cause is one of three things:

  1. The secret was rotated in your environment but setWebhook was not called with the new value.
  2. A re-registration left secret_token out, so Telegram now sends no header at all.
  3. A proxy in between strips unfamiliar headers. Uncommon, but worth checking if the first two are ruled out.

The fix for all three is the same: set the variable, call setWebhook with the same value, and check that your environment and Telegram agree. The secret is not visible in getWebhookInfo, so the only proof is a delivery that succeeds. Keep it next to the token, not in the repository - environment variables and secrets has the reasons.

Respond fast, then process#

If pending_update_count climbs with no error, or you see read timeouts, your handler is doing its work before it answers. Every delivery holds one of the max_connections slots until you respond. A handler that calls an external API, writes to a database and sends two messages before returning 200 might take two seconds; with a burst of messages, the slots fill, deliveries queue, and eventually some time out and are retried - which adds to the load.

The fix is structural: verify the secret, hand the update to something else, answer 200, and do the work afterwards.

FastAPI, answer first
import asyncio, osfrom fastapi import FastAPI, Request, Responseapp = FastAPI()SECRET = os.environ["WEBHOOK_SECRET"]queue: asyncio.Queue = asyncio.Queue()@app.post("/tg/hook")async def hook(request: Request):    if request.headers.get("X-Telegram-Bot-Api-Secret-Token") != SECRET:        return Response(status_code=403)    await queue.put(await request.json())    return Response(status_code=200)async def worker():    while True:        update = await queue.get()        try:            await handle(update)          # your bot logic        except Exception as exc:            print("update failed:", exc, flush=True)@app.on_event("startup")async def start_worker():    asyncio.create_task(worker())

An in-memory queue loses whatever is in it if the process restarts, which is acceptable for a chat bot and not for anything that takes payments. When the work must survive a restart, put it in a real queue; background jobs on a small server covers the options that fit a single plan. Some libraries, grammY's webhookCallback among them, apply their own timeout to the handler and answer on your behalf when it runs out - better than a Telegram timeout, but a sign the handler is too slow, not a solution.

Also lower max_connections to what the server can actually run in parallel. On a 0.5 or 1 vCPU plan, 40 concurrent deliveries is not capacity, it is contention. Something between 5 and 20 is usually honest for a small bot.

Going back to polling#

Sometimes the right fix is to stop using a webhook. If the bot has a few hundred users, no domain to spare, or you are debugging and want to see updates arrive in a terminal, polling is simpler and loses nothing that matters.

bash
$ curl -s "https://api.telegram.org/bot$BOT_TOKEN/deleteWebhook" \    -d "drop_pending_updates=false"

With drop_pending_updates=false (the default), the queued updates stay and getUpdates delivers them on the next poll. Afterwards getWebhookInfo returns an empty url, and polling works. Until you delete it, every getUpdates call fails with a 409 conflict, which is how people discover that a webhook they forgot about is still registered. Remember the reverse as well: going back to the webhook later needs a fresh setWebhook with the full parameter set, and the deploy should check getWebhookInfo afterwards rather than assume.

A checklist in order#

When it is late and the bot is silent, go through it in this order:

  1. Run getWebhookInfo. Is url set, and is it exactly the URL your code serves?
  2. Is ip_address your server? If not, fix DNS, or clear a fixed ip_address.
  3. Read last_error_date. Is the error recent? If not, the problem is in your code or allowed_updates.
  4. SSL error: check the chain and hostname with openssl s_client.
  5. Wrong response: the status tells you whether it is path, secret, redirect, or a crashed app.
  6. Connection errors: check the port is one of the four, the proxy is running, and nothing is firewalled.
  7. Count rising with no error: answer first, process later, lower max_connections.
  8. Fixed and the backlog is stale: re-register with drop_pending_updates=true.

Keep your application log open while you work. How to read a server console without guessing helps separate the errors that matter from the noise around them.

FAQ#

Why is pending_update_count growing when there is no error?

Telegram is delivering successfully but your server answers too slowly to keep up, so deliveries wait for a free connection slot. Return 200 as soon as the request is validated and do the work in a background task, and set max_connections to what your server can genuinely handle in parallel.

How long does Telegram keep updates my webhook did not receive?

Up to 24 hours. Anything older is discarded whether or not you fix the webhook in time, so a bot that was down for two days loses the first day's messages regardless.

Can I use a webhook on port 3000 or 8080?

Not directly. Telegram delivers only to 443, 80, 88 and 8443, and setWebhook rejects other ports. Run the app on any port you like and put a reverse proxy on 443 in front of it.

Does Telegram follow redirects to my webhook?

Treat it as no. A 3xx response is recorded as a wrong response and the delivery is retried. Register the exact final URL, including the scheme, host and any trailing slash your framework expects.

Will deleteWebhook lose my messages?

Not unless you pass drop_pending_updates=true. Without it the queued updates stay on Telegram's side and are handed to the first getUpdates call, within the 24-hour limit.

Do I need the IP allow-list if I use secret_token?

No. The secret token already proves a request came from Telegram, and it does not break when Telegram changes its address ranges. An allow-list is an extra layer, not a replacement, and it is the one more likely to cause an outage.