Process Telegram Webhooks in Laravel: Middleware, Queued Jobs, and Redis Idempotency

When building a Telegram bot at scale, handling incoming updates directly within the web request cycle is a recipe for failure. Telegram expects your webhook endpoint to return a 200 OK response within a few seconds. If your server takes too long—due to database queries, external API calls, or processing logic—Telegram will timeout and retry sending the same update, leading to duplicate executions.

To build a resilient webhook handler in Laravel, you must secure the endpoint, acknowledge the request immediately, prevent duplicate processing of the same update_id, and offload the actual execution to a background queue.

This tutorial demonstrates how to build a production-ready Telegram webhook architecture in Laravel using custom middleware for secret token verification, Redis for idempotency, and queued jobs for background processing.

### What We Are Building and What We Are Not We are building a robust backend pipeline to receive, verify, deduplicate, and queue incoming Telegram updates. We are not building a complete conversational framework, nor are we covering long-polling or frontend Mini App integration.

---

Step 1: Configuration and Routing

First, configure your Telegram credentials. Do not hardcode these values. Add them to your .env file and map them through Laravel's configuration system.

Add the following to your .env file:

TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_WEBHOOK_SECRET=a_secure_random_string_here

Next, register these keys in config/services.php:

return [
    // ... other services
    'telegram' => [
        'token' => env('TELEGRAM_BOT_TOKEN'),
        'webhook_secret' => env('TELEGRAM_WEBHOOK_SECRET'),
    ],
];

Now, define the webhook route. Since Telegram sends updates via POST requests, define a route in routes/api.php (or routes/web.php with CSRF exclusion). We will apply a custom middleware to verify the incoming payload's authenticity.

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

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

---

Step 2: Securing the Webhook with Middleware

When you register your webhook with Telegram using the setWebhook method, you should provide a secret_token parameter. Telegram will then include this token in every incoming request within the X-Telegram-Bot-Api-Secret-Token header. This allows you to verify that the request originated from Telegram and not an unauthorized third party.

Create the middleware using Artisan:

php artisan make:middleware VerifyTelegramSecret

Implement the verification logic inside app/Http/Middleware/VerifyTelegramSecret.php:

<?php

namespace App\Http\Middleware;

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

class VerifyTelegramSecret
{
    /**
     * Handle an incoming request.
     */
    public function handle(Request $request, Closure $next): Response
    {
        $expectedSecret = config('services.telegram.webhook_secret');
        
        if (empty($expectedSecret)) {
            return response()->json(['error' => 'Webhook secret is not configured.'], 500);
        }

        $providedSecret = $request->header('X-Telegram-Bot-Api-Secret-Token');

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

        return $next($request);
    }
}

Using hash_equals prevents timing attacks when comparing the secret tokens.

---

Step 3: Controller and Idempotency via Redis

Every update sent by Telegram contains a unique integer called update_id. If your server fails to respond with a 200 OK status code quickly enough, Telegram will resend the exact same update. To prevent executing the same action multiple times, you must implement an idempotency check.

We will use Redis to store processed update_id keys. We attempt to set a key in Redis with an expiration time (e.g., 24 hours) using the NX (set if not exists) option. If the key already exists, we know the update has already been received, and we can safely return a 200 OK immediately without queuing it again.

Create the controller:

php artisan make:controller TelegramWebhookController --invokable

Implement the controller in app/Http/Controllers/TelegramWebhookController.php:

<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessTelegramUpdate;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redis;

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

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

        $updateId = (int) $payload['update_id'];
        $redisKey = "telegram:update:{$updateId}";

        // Attempt to set the key with a 24-hour (86400 seconds) TTL, only if it does not exist
        $isUnique = Redis::connection()->client()->set(
            $redisKey,
            '1',
            'EX',
            86400,
            'NX'
        );

        if (!$isUnique) {
            // Update already processed or currently processing. Return 200 to stop retries.
            return response()->json(['status' => 'duplicate_ignored'], 200);
        }

        // Dispatch the job to the queue
        ProcessTelegramUpdate::dispatch($payload);

        // Respond immediately to Telegram
        return response()->json(['status' => 'queued'], 200);
    }
}

*Note: If you are not using Redis, you can implement a similar check using your primary database by inserting the update_id into a table with a unique constraint, catching the query exception on duplicate entry, and returning a 200 OK.*

---

Step 4: Processing the Update in a Queued Job

Now that the update is safely stored and deduplicated, we process it asynchronously. The queued job will handle parsing the message, executing business logic, and sending a response back to the Telegram Bot API.

Create the job:

php artisan make:job ProcessTelegramUpdate

Implement the job in app/Jobs/ProcessTelegramUpdate.php:

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

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

    /**
     * The number of times the job may be attempted.
     */
    public int $tries = 3;

    /**
     * The number of seconds to wait before retrying the job.
     */
    public int $backoff = 5;

    public function __construct(protected array $payload)
    {
    }

    public function handle(): void
    {
        // Check if this is a standard text message
        if (isset($this->payload['message']['text'])) {
            $chatId = $this->payload['message']['chat']['id'];
            $text = $this->payload['message']['text'];

            if (str_starts_with($text, '/start')) {
                $this->sendTextMessage($chatId, "Hello! Welcome to our bot.");
            }
        }

        // Handle callback queries (inline keyboard buttons)
        if (isset($this->payload['callback_query'])) {
            $callbackQuery = $this->payload['callback_query'];
            $callbackQueryId = $callbackQuery['id'];
            $chatId = $callbackQuery['message']['chat']['id'] ?? null;
            $data = $callbackQuery['data'] ?? '';

            // Always answer the callback query to remove the loading state on the client
            $this->answerCallbackQuery($callbackQueryId, "Action received: " . $data);
        }
    }

    protected function sendTextMessage(int $chatId, string $text): void
    {
        $token = config('services.telegram.token');
        $url = "https://api.telegram.org/bot{$token}/sendMessage";

        // Always escape HTML output when using parse_mode=HTML
        $safeText = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

        $response = Http::timeout(10)
            ->post($url, [
                'chat_id' => $chatId,
                'text' => $safeText,
                'parse_mode' => 'HTML',
            ]);

        if ($response->failed()) {
            Log::error('Telegram API Error', [
                'status' => $response->status(),
                'body' => $response->body(),
            ]);
            throw new \RuntimeException('Failed to send Telegram message');
        }

        $responseData = $response->json();
        if (!($responseData['ok'] ?? false)) {
            Log::error('Telegram returned ok=false', ['response' => $responseData]);
            throw new \RuntimeException('Telegram API returned success status false');
        }
    }

    protected function answerCallbackQuery(string $callbackQueryId, string $text): void
    {
        $token = config('services.telegram.token');
        $url = "https://api.telegram.org/bot{$token}/answerCallbackQuery";

        Http::timeout(5)->post($url, [
            'callback_query_id' => $callbackQueryId,
            'text' => $text,
            'show_alert' => false,
        ]);
    }

    public function failed(Throwable $exception): void
    {
        Log::error('Telegram update processing failed permanently', [
            'update_id' => $this->payload['update_id'] ?? null,
            'exception' => $exception->getMessage(),
        ]);
    }
}

---

Production Considerations

#### 1. Handling Callback Query Limits When designing inline keyboards, remember that the callback_data field has a strict limit of 64 bytes. If you need to pass complex state, do not serialize large JSON payloads into the button. Instead, generate a short unique identifier, save the state in your database or Redis cache, and pass only the identifier in the callback_data payload.

#### 2. Rate Limiting and Retries Telegram enforces strict rate limits (e.g., no more than 30 messages per second globally, and 1 message per second per chat). If your job fails with an HTTP status code 429, parse the Retry-After header or response field, and release the job back onto the queue with a delay matching that value to prevent further rate limiting.

#### 3. Database Transactions and State If your bot processes payments or generates leads, always perform database operations *before* sending confirmation messages to the user. For example, if generating a lead ID:

$leadId = bin2hex(random_bytes(7));

// Insert into database first
DB::table('leads')->insert([
    'lead_id' => $leadId,
    'chat_id' => $chatId,
    'created_at' => now(),
]);

// Only send message after successful DB write
$this->sendTextMessage($chatId, "Your lead ID is: <b>{$leadId}</b>");

This sequence ensures that if your database write fails, the job fails, and the user does not receive a confirmation message containing a non-existent ID.

For more details on the underlying payloads and methods, reference the official documentation at https://botservice.biz/telegram-bot-api.

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.