Stop Telegram Webhook Timeout Drops in Laravel with Queued Updates and Redis Locking

Imagine running an e-commerce or booking service inside a Telegram bot built on Laravel. A user taps a custom inline keyboard to reserve an appointment or initiate a checkout. To fulfill this request synchronously, your controller must parse the incoming request payload, start a database transaction, lock the user record, query an external inventory or scheduling API, and finally dispatch an HTTP call back to Telegram's sendMessage API endpoint.

Under normal conditions, this entire flow takes 300 to 800 milliseconds. But when traffic spikes or an upstream API slows down, the processing time spikes beyond 5 seconds. Telegram enforces a strict timeout on webhook HTTP connections. If your server does not respond with an HTTP 200 OK within several seconds, Telegram terminates the TCP connection and assumes your server failed to receive the update. Telegram will then aggressively retry sending the exact same payload over and over again.

When your web server is processing updates synchronously during network degradation, these automatic retries trigger a cascade of duplicate operations. The same user ends up with multiple pending orders in the database, duplicate charge attempts, and spammy repetitive notifications. To make matters worse, open endpoints left unprotected without secret token verification can be hit by third-party web scanners, injecting bogus updates into your business logic.

To build a rock-solid Telegram webhook integration in Laravel, you must separate update reception from execution. The incoming webhook endpoint should validate the signature, push the raw payload to an asynchronous background worker, and return an immediate HTTP 200 OK response within milliseconds. Then, the background worker must use atomic lock guards around Telegram's update_id to guarantee that no update is ever processed twice.

Securing the Webhook Route with Secret Token Middleware

Telegram allows developers to set a secret_token string up to 256 characters when configuring a webhook. When configured, Telegram includes this string in every HTTP request inside the X-Telegram-Bot-Api-Secret-Token header. Before accepting any payload into your application queue, you must verify this header against your stored application secret.

Never rely on obscure URL paths alone to secure your bot endpoints. Publicly accessible routes can be discovered in web server access logs, browser history, or network traces. Standardizing header validation via dedicated middleware guarantees that forged requests are dropped at the HTTP boundary before consuming application memory or queue capacity.

Create a dedicated middleware named VerifyTelegramSecret using the Artisan command line:

php artisan make:middleware VerifyTelegramSecret

Inside this middleware, extract the incoming request header and perform a constant-time string comparison against your configured environment token. Using hash_equals prevents timing attacks where an attacker infers token characters by measuring microsecond differences in execution time.

<?php

namespace App\Http\Middleware;

Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyTelegramSecret
{
public function handle(Request $request, Closure $next): Response
{
$expectedToken = config('services.telegram.webhook_secret');
$providedToken = $request->header('X-Telegram-Bot-Api-Secret-Token');

if (!$expectedToken || !$providedToken || !hash_equals($expectedToken, $providedToken)) {
return response()->json(['error' => 'Unauthorized incoming request.'], 401);
}

return $next($request);
}
}

Register this route inside routes/api.php and attach the middleware. Make sure your route definition explicitly maps to an isolated controller method intended strictly for receiving raw incoming updates.

<?php

use App\Http\Controllers\TelegramWebhookController;
use App\Http\Middleware\VerifyTelegramSecret;
use Illuminate\Support\Facades\Route;

Route::post('/telegram/webhook',
[TelegramWebhookController::class, 'handle']
)->middleware(VerifyTelegramSecret::class);

Decoupling Ingestion from Processing Using Laravel Queues

With security enforced at the routing layer, the next critical step is ensuring the controller responds instantaneously. The controller must perform zero heavy database reads, zero third-party API calls, and zero external network requests. Its sole responsibility is validating that the JSON body contains a valid structure with an update_id, dispatching a queued job, and returning a JSON payload with an HTTP 200 status.

If you execute business logic directly inside the HTTP controller, you risk falling victim to web worker exhaustion. If 50 users send messages at once and each request takes 2 seconds to complete, your web server worker pool (FPM or Swoole/Octane) becomes fully saturated, leading to dropped connection attempts for other users.

Here is how your TelegramWebhookController should look:

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessTelegramUpdate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class TelegramWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$payload = $request->all();

if (!isset($payload['update_id'])) {
return response()->json(['error' => 'Missing update_id parameter.'], 400);
}

// Dispatch the background job to the queue worker pool immediately
ProcessTelegramUpdate::dispatch($payload);

// Acknowledge receipt to Telegram in under 50ms
return response()->json(['status' => 'queued']);
}
}

By returning an immediate response, Telegram records an instant HTTP 200 OK delivery and will not re-send the update over the network.

Enforcing Update Idempotency with Redis Locking

Offloading the update to a queue solves network timeouts, but it introduces a subtle race condition. What happens if Telegram's initial connection encountered a transient network drop on its end right after your server received the payload, leading Telegram to issue an automated retry before your initial job finishes? Or what if a queue worker crashes mid-execution and Laravel automatically retries the queued job?

To prevent double processing, every job must enforce strict idempotency using the unique update_id supplied in every Telegram payload. Because every discrete action or message in a Telegram chat generates an incremental, unique integer identifier, we can store this identifier in a shared atomic cache like Redis before executing any business logic.

Using Redis's atomic key initialization (Cache::add or Redis::setnx), we can check whether an update_id has already been claimed by another worker. If the key already exists, the worker safely skips processing and exits.

Here is the complete implementation of the ProcessTelegramUpdate job incorporating atomic Redis locks, HTML message escaping, and safe Telegram API requests:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ProcessTelegramUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public array $payload;

/**
* Create a new job instance.
*/
public function __construct(array $payload)
{
$this->payload = $payload;
}

/**
* Execute the job.
*/
public function handle(): void
{
$updateId = $this->payload['update_id'] ?? null;

if (!$updateId) {
return;
}

// Attempt to claim atomic lock for this update_id for 24 hours
$cacheKey = "telegram:update:processed:{$updateId}";
$isFirstAttempt = Cache::add($cacheKey, true, now()->addHours(24));

if (!$isFirstAttempt) {
Log::info("Duplicate update received and skipped: {$updateId}");
return;
}

if (isset($this->payload['message'])) {
$this->handleIncomingMessage($this->payload['message']);
} elseif (isset($this->payload['callback_query'])) {
$this->handleCallbackQuery($this->payload['callback_query']);
}
}

private function handleIncomingMessage(array $message): void
{
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if (!$chatId) {
return;
}

// Example: Handle /start command
if (str_starts_with($text, '/start')) {
$userName = htmlspecialchars($message['from']['first_name'] ?? 'User', ENT_QUOTES, 'UTF-8');
$replyText = "Hello <b>{$userName}</b>! Your session is secure and active.";

$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => $replyText,
'parse_mode' => 'HTML',
]);
}
}

private function handleCallbackQuery(array $callbackQuery): void
{
$callbackId = $callbackQuery['id'];
$chatId = $callbackQuery['message']['chat']['id'] ?? null;
$data = $callbackQuery['data'] ?? '';

// Always answer callback queries promptly to clear loading spinner
$this->sendTelegramRequest('answerCallbackQuery', [
'callback_query_id' => $callbackId,
'text' => 'Action processed successfully!',
]);

if ($chatId && $data === 'action_confirm') {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => 'Your order confirmation has been logged.',
]);
}
}

private function sendTelegramRequest(string $method, array $params): void
{
$botToken = config('services.telegram.bot_token');
$url = "https://api.telegram.org/bot{$botToken}/{$method}";

$response = Http::timeout(10)->post($url, $params);

if ($response->failed()) {
$status = $response->status();
$body = $response->body();
Log::error("Telegram API call to {$method} failed [{$status}]: {$body}");

// Handle rate limiting specifically
if ($status === 429) {
$retryAfter = $response->json('parameters.retry_after', 5);
$this->release($retryAfter);
}
}
}
}

Automating Secure Webhook Registration via Artisan

Hardcoding or manually configuring webhooks via cURL in the terminal leads to mistakes, forgotten secrets, and missing parameter settings. A resilient system should register its own webhooks via an automated command that pulls secrets directly from Laravel's environment variables.

When registering the webhook, pass your secret token and declare explicit allowed updates (such as message and callback_query). Restricting update types prevents your application from consuming unnecessary bandwidth on events your application logic does not process.

Create a custom Artisan command to register the webhook with Telegram:

php artisan make:command RegisterTelegramWebhook

Implement the command logic to fetch configurations, generate structural payload URLs, and dispatch the setup payload directly to Telegram's setWebhook endpoint:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;

class RegisterTelegramWebhook extends Command
{
protected $signature = 'telegram:register-webhook';
protected $description = 'Register the web application endpoint with Telegram Bot API';

public function handle(): int
{
$token = config('services.telegram.bot_token');
$secret = config('services.telegram.webhook_secret');
$domain = config('app.url');

if (!$token || !$secret) {
$this->error('Bot token or webhook secret is missing from environment variables.');
return Command::FAILURE;
}

$webhookUrl = "{$domain}/api/telegram/webhook";
$endpointUrl = "https://api.telegram.org/bot{$token}/setWebhook";

$response = Http::post($endpointUrl, [
'url' => $webhookUrl,
'secret_token' => $secret,
'allowed_updates' => ['message', 'callback_query'],
'drop_pending_updates' => false,
]);

if ($response->successful() && $response->json('ok') === true) {
$this->info("Webhook registered successfully: {$webhookUrl}");
return Command::SUCCESS;
}

$this->error('Failed to register webhook: ' . $response->body());
return Command::FAILURE;
}
}

Production Notes: Worker Pools, Rate Limits, and Monitoring

Running high-throughput webhooks in production requires special attention to queue worker configuration, API limits, and payload validation.

1. Queue Worker Configuration: Run your queue workers under a dedicated process manager like Supervisor or systemd. Use --tries=3 and --backoff=5 when invoking php artisan queue:work. This prevents transient network failures when communicating with Telegram from discarding jobs prematurely. 2. Handling 429 Too Many Requests: Telegram limits outbound messages to 30 messages per second across all chats, and 1 message per second inside a single group or individual chat. In the ProcessTelegramUpdate job code above, we check for HTTP status 429. If Telegram returns a 429 response, extract the parameters.retry_after field from the JSON body and call $this->release($retryAfter). This puts the job back into Laravel's queue delayed by the exact number of seconds Telegram requested. 3. Sanitizing Outbound Text: When using 'parse_mode' => 'HTML', always filter dynamic user input with htmlspecialchars($input, ENT_QUOTES, 'UTF-8'). If an unescaped character like < or & is sent to Telegram, the Bot API rejects the message with an Unmatched tag error, resulting in lost messages. 4. Handling Callback Queries Promptly: Every user interaction with an inline keyboard button triggers a callback_query. If your code does not invoke answerCallbackQuery, the Telegram client displays a perpetual loading spinner on the user's screen until it eventually times out. Always trigger answerCallbackQuery early in your job pipeline.

When scaling complex Telegram Mini Apps, webhooks, or automated lead capture systems, engineering every layer for concurrency and resilience is critical. If you are building high-volume Telegram software and need a dedicated team to ship your infrastructure without reliability flaws, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

We explain what to automate in your business and how it works in practice. No spam.