To test a Telegram bot webhook on localhost, expose your local server with a public HTTPS tunnel, call setWebhook with that URL, and validate Telegram's secret-token header on every request. This gives you real message, callback-query, and membership updates without deploying after every code change. The complete loop is: run the bot handler, start npx portpreview 3000, register the resulting URL, send your bot a message, and inspect the request locally.
Why Telegram cannot send updates directly to localhost
Telegram's Bot API sends webhook updates from Telegram infrastructure to an internet-reachable URL. localhost, 127.0.0.1, and private LAN addresses are not routable from that infrastructure. A localhost tunnel terminates HTTPS at a public address and forwards the unchanged HTTP request to your local port.
Telegram bots can receive updates in two mutually exclusive ways: long polling through getUpdates, or webhooks. The official setWebhook reference states that getUpdates is unavailable while an outgoing webhook is configured. If a polling process is still running, stop it before judging the webhook flow.
Build a local webhook endpoint
This Express example keeps the handler intentionally small. It checks the shared secret before touching the update, acknowledges quickly, and moves work outside the response path.
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.use(express.json({ limit: '1mb' }));
function sameSecret(received = '', expected = '') {
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/telegram', (req, res) => {
const received = req.get('x-telegram-bot-api-secret-token') || '';
if (!sameSecret(received, process.env.TELEGRAM_WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
const update = req.body;
res.sendStatus(200);
queueMicrotask(() => handleUpdate(update));
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Telegram sends a JSON-serialized Update. Unlike HMAC-based providers, Telegram's secret_token feature does not sign the body. It places your chosen value in X-Telegram-Bot-Api-Secret-Token. The token proves that the sender knows the value used when the webhook was registered, but it does not provide a payload digest. TLS protects the request in transit.
Expose the endpoint with HTTPS
- Start the app and confirm
curl -i http://localhost:3000/webhooks/telegramreaches the server, even if GET returns 404. - Open a second terminal and run
npx portpreview 3000. - Copy the public HTTPS origin and append
/webhooks/telegram. - Keep the tunnel process running while Telegram delivers updates.
The Bot API accepts HTTPS webhook URLs. Telegram documents webhook support on ports 443, 80, 88, and 8443; a managed tunnel's public endpoint normally uses 443 even when the forwarded local process listens on 3000.
Register the Telegram webhook safely
Create a random secret containing only letters, digits, underscores, or hyphens. Telegram permits 1–256 characters. Do not reuse the bot token as this value.
export TELEGRAM_WEBHOOK_SECRET="$(openssl rand -hex 32)"
curl -sS -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \
-d "url=https://YOUR-TUNNEL.portpreview.dev/webhooks/telegram" \
-d "secret_token=${TELEGRAM_WEBHOOK_SECRET}" \
-d 'allowed_updates=["message","callback_query"]' \
-d "drop_pending_updates=true"
allowed_updates reduces noise and should list only update types the bot handles. drop_pending_updates=true is useful when starting a fresh local session, but it permanently discards queued updates, so omit it when those events matter. Telegram's Update documentation describes fields such as message, callback_query, and my_chat_member.
Confirm registration before debugging code
Use getWebhookInfo to separate configuration failures from handler failures:
curl -sS "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getWebhookInfo"
Check url, pending_update_count, last_error_message, and last_error_date. An empty URL means registration did not stick. A growing pending count usually means Telegram cannot connect or your endpoint returns a non-2xx status. Send a direct message to the bot after registration; merely opening the chat does not necessarily create an update.
Handle updates without causing retries
Acknowledge before slow work
Return a 2xx response as soon as the request is authenticated and durably accepted. Database exports, AI calls, and third-party APIs should run asynchronously. Telegram retries unsuccessful requests after non-2xx responses, so slow synchronous work can create duplicates.
Deduplicate with update_id
Every update has an update_id. Store processed IDs with an expiration window or enforce a unique database key. A retry must not send a second payment receipt, create a duplicate ticket, or execute the same callback twice.
Model every update type explicitly
Not every update contains message.text. Callback buttons arrive under callback_query; channel posts and membership changes have other fields. Branch on the present top-level field and treat unknown types as valid no-ops rather than throwing.
Security rules for local Telegram bot testing
- Validate the secret header first. Reject missing or incorrect values before logging or parsing sensitive fields.
- Keep tokens out of URLs and logs. The Bot API token in the registration command is a credential. Avoid shell history on shared systems and rotate an exposed token through BotFather.
- Use an unguessable route and secret. The route is defense in depth; the secret header is the actual application check.
- Limit captured data. Messages can contain names, usernames, phone numbers, files, and private conversation text. Redact logs and delete local captures when finished.
- Never disable authentication in development. A public tunnel is public. Local code should exercise the same checks as production.
See the broader localhost tunnel security guide for access-control and data-retention practices.
Troubleshoot common Telegram webhook failures
Telegram reports a certificate or connection error
Use the tunnel's HTTPS URL, not its local HTTP target. Confirm the tunnel is active and the URL has not changed. If you provide your own self-signed certificate instead, Telegram requires uploading the public certificate as a file; a managed TLS endpoint avoids that setup.
The endpoint returns 401
Compare the secret passed to setWebhook with the environment variable used by the process. Header names are case-insensitive, but proxies or middleware can strip custom headers. Inspect the incoming headers without printing the secret value.
No requests arrive
Run getWebhookInfo, verify the registered path exactly matches your route, and ensure no firewall blocks the tunnel's local connection. If you recently used polling, confirm the webhook URL is now populated. Trigger an actual update by messaging the bot.
Updates arrive repeatedly
Log status and response time. Exceptions after receiving the request may turn an intended 200 into a 500. Return 200 promptly, make processing idempotent, and use controlled webhook replay rather than waiting for provider retries during debugging.
Test callback queries and files, not only text
A useful bot test matrix covers more than message.text. Send a photo with a caption, share a contact, edit a message, and press an inline-keyboard button. For callback queries, call answerCallbackQuery promptly so the client stops showing its progress indicator, then perform slower work separately. File updates contain identifiers; downloading the bytes is a second Bot API operation and should not delay the webhook response.
Keep fixtures made from sanitized updates for unit tests, but preserve the full transport path for at least one test of each supported type. A fixture proves your dispatcher understands a payload; a real tunneled delivery also proves registration, TLS, headers, body parsing, and acknowledgement behavior. When adding a new allowed_updates entry, call setWebhook again and verify getWebhookInfo reflects the intended configuration.
Remove the webhook after the local session
curl -sS -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/deleteWebhook" \
-d "drop_pending_updates=false"
Deleting the webhook lets you return to getUpdates. If the tunnel URL changes on the next session, call setWebhook again. For additional diagnostics, follow the general local webhook debugging workflow.
