DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Stop Telegram Webhook Retries and Spoofed Hits in Laravel with Queued Jobs

Building a customer support or e-commerce bot in Laravel seems straightforward at first: you register a POST route, capture incoming JSON from Telegram, process the data, and send a reply. However, as soon as your application encounters real user traffic or external API latency, critical failure modes emerge.

When Telegram sends a webhook request to your server, it expects an HTTP 200 OK response within a few seconds. If your controller performs long-running database transactions, calls external APIs, or generates files inline, the HTTP connection will time out. Telegram interprets any timeout or non-200 response as a delivery failure and will automatically retry sending the same update payload repeatedly with exponential backoff. If your code is not idempotent, a single customer action will result in duplicated orders, repeated payment prompts, or looping database records.

Furthermore, if your webhook endpoint is publicly accessible without request validation, anyone who discovers your URL can send fake POST payloads to your server, impersonating users and spoofing commands. To prevent unauthorized updates and duplicate executions, you must enforce request authentication at the HTTP layer, return an immediate HTTP 200 response, and delegate payload processing to an background queue guarded by atomic idempotency checks.

Blocking Unauthenticated Requests with Secret Token Middleware

When registering a webhook URL using the Telegram Bot API setWebhook method, you can provide a secret_token string consisting of 1 to 256 characters (alphanumeric, underscores, and hyphens). Telegram then includes this string in every incoming webhook POST request inside the X-Telegram-Bot-Api-Secret-Token header.

If you process incoming updates without validating this header, your endpoint remains open to arbitrary POST requests. A attacker could send payloads containing arbitrary user_id or chat_id values, trigger internal workflows, or flood your queue worker.

In Laravel, create a custom middleware to reject any request that lacks the matching secret token header. Use hash_equals() to compare the incoming token with your application's stored secret. Using hash_equals() prevents timing attacks where an attacker measures response times to infer character matches in your token.

namespace App\Http\Middleware;

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

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

        if (!$secret || !$header || !hash_equals($secret, $header)) {
            return response()->json(['error' => 'Unauthorized payload source.'], 403);
        }

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Register this middleware in your bootstrap/app.php file or assign it directly to your webhook route in routes/api.php. Ensure your secret token is defined in your .env file and accessed via standard Laravel configuration files, never hardcoded.

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

Route::post('/telegram/webhook', TelegramWebhookController::class)
    ->middleware(VerifyTelegramSecret::class);
Enter fullscreen mode Exit fullscreen mode

If a request fails this validation step, Laravel returns an HTTP 403 response before reading or parsing the body payload, shielding your queue infrastructure from malicious traffic.

Offloading Webhook Payload Processing to Queued Jobs

Your HTTP controller must do only two things: accept valid incoming requests and dispatch them to an asynchronous queue worker. It must return an HTTP 200 status as fast as possible to signal successful receipt to Telegram.

Attempting to handle business logic, write to disk, query third-party APIs, or dispatch Telegram API replies directly inside the controller execution path creates an immediate bottleneck. If your database experiences high latency or an outbound API call takes three seconds, Telegram may consider the HTTP connection dead and begin queueing retry attempts.

Create a dedicated job class implementing ShouldQueue. Pass the raw webhook array payload to the job's constructor.

namespace App\Http\Controllers;

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

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

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

        ProcessTelegramUpdate::dispatch($payload);

        return response()->json(['status' => 'ok']);
    }
}
Enter fullscreen mode Exit fullscreen mode

This controller responds within milliseconds. By returning {'status': 'ok'} with an HTTP 200 code immediately, you satisfy Telegram's strict delivery deadline and prevent automatic retries from being scheduled.

Enforcing Idempotency using Redis and Update IDs

Every payload sent by Telegram contains a unique numerical update_id. Update IDs are strictly increasing, sequential integers. However, due to transient network issues—such as a dropped TCP connection right after your server returns HTTP 200—Telegram might occasionally resend an update that your queue worker has already started processing.

If your queue worker executes business logic twice for the same update_id, side effects will multiply. You must guarantee that every incoming update_id is processed exactly once.

To achieve atomic lock verification, use Redis inside your job's handle() method. Execute a SET key value EX seconds NX command via Laravel's Redis facade before executing any domain logic. The NX option ensures the key is stored only if it does not already exist, while EX sets an expiration period (such as 86,400 seconds / 24 hours) to prevent memory leaks.

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\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;

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

    public function __construct(public array $payload)
    {
    }

    public function handle(): void
    {
        $updateId = $this->payload['update_id'] ?? null;

        if (!$updateId) {
            return;
        }

        $lockKey = "telegram:update:{$updateId}";

        // Attempt to acquire an atomic lock valid for 24 hours (86400 seconds)
        $acquired = Redis::set($lockKey, 'processed', 'EX', 86400, 'NX');

        if (!$acquired) {
            Log::info("Duplicate update_id {$updateId} discarded.");
            return;
        }

        $this->processPayload($this->payload);
    }

    private function processPayload(array $payload): void
    {
        if (isset($payload['message'])) {
            $chatId = $payload['message']['chat']['id'];
            $text = htmlspecialchars($payload['message']['text'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

            $token = config('services.telegram.bot_token');

            $response = Http::timeout(5)->post("https://api.telegram.org/bot{$token}/sendMessage", [
                'chat_id' => $chatId,
                'text' => "Received message: {$text}",
                'parse_mode' => 'HTML',
            ]);

            if (!$response->successful()) {
                Log::error('Telegram API error', [
                    'status' => $response->status(),
                    'body' => $response->json(),
                ]);
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

If a network hiccup triggers a duplicate payload push, the second instance of ProcessTelegramUpdate will fail to acquire the Redis lock and exit cleanly without re-executing processPayload().

Production Notes

When deploying this setup to production environments, configure administrative scripts and queue monitoring rules to maintain reliability under load.

Registering the Webhook Securely

Never set webhooks manually through a web browser URL bar. Create a dedicated Artisan command to set or update your webhook URL programmatically. This ensures your secret_token is transmitted safely from environment variables.

namespace App\Console\Commands;

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

class RegisterTelegramWebhook extends Command
{
    protected $signature = 'telegram:set-webhook';
    protected $description = 'Register the bot webhook URL with Telegram API';

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

        $response = Http::post("https://api.telegram.org/bot{$token}/setWebhook", [
            'url' => $url,
            'secret_token' => $secret,
            'drop_pending_updates' => false,
        ]);

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

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

Handling Callback Queries Promptly

If your bot processes inline keyboards, Telegram sends updates containing a callback_query object. When users tap an inline button, the Telegram client shows a loading indicator on the button until your server calls the answerCallbackQuery Bot API method.

If you push callback_query updates to your background queue, ensure the queue worker processes them before the client times out. If your queue experiences processing backlog, the user interface will remain stuck in a loading state. Monitor queue backlog depth closely using tools like Laravel Horizon.

Handling Failures and Retries in Queue Workers

Because idempotency is checked inside the job execution path via Redis, configure your job's retry limits carefully. If an outbound HTTP request to Telegram fails due to transient 5xx API errors, you may want Laravel to retry the job. However, if your Redis lock key was already created on the first attempt, subsequent job retries will hit the idempotency wall and abort.

To accommodate retries for genuinely failed jobs (like external API outages), release the Redis key inside a catch block or set the lock key only after successfully completing your business logic execution.

When building complex conversational systems, high-volume transactional bots, or mini apps, infrastructure requirements expand quickly. BotCreator — studio that ships Telegram bots / Mini Apps.

Top comments (0)