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

Handling Telegram updates inside the HTTP request is fragile. Telegram expects a quick 200 OK. Slow database work, external APIs, or heavy logic cause timeouts and retries — and those retries can run the same update twice.

A solid Laravel webhook stack does four things: verify the secret token, acknowledge fast, deduplicate by update_id, and process work in a queue.

This guide covers that pipeline: middleware for the webhook secret, Redis (or DB) idempotency, and a queued job for the real work.

What we build (and what we skip)

We build a backend path to receive, verify, deduplicate, and queue Telegram updates. We do not build a full conversational framework, long polling, or Mini App integration.

Step 1: Configuration and routing

Keep credentials out of source. Put them in .env and map them through Laravel config.

TELEGRAM_BOT_TOKEN=123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ
TELEGRAM_WEBHOOK_SECRET=a_secure_random_string_here

Register them in config/services.php:

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

Telegram posts updates as POST. Define the route in routes/api.php (or routes/web.php with CSRF excluded) and attach middleware:

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: Middleware for the secret token

When you call setWebhook, pass a secret_token. Telegram then sends that value on every request in the X-Telegram-Bot-Api-Secret-Token header. Reject anything that does not match.

php artisan make:middleware VerifyTelegramSecret

Implement it in app/Http/Middleware/VerifyTelegramSecret.php:

<?php

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
{
$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);
}
}

Use hash_equals so the comparison is timing-safe.

Step 3: Controller and idempotency

Every update has a unique update_id. If you answer slowly, Telegram may resend the same payload. Deduplicate before you queue work.

With Redis, set a key with a TTL using NX (only if missing). If the key already exists, return 200 and skip the job.

php artisan make:controller TelegramWebhookController --invokable
<?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}";

// 24h TTL; set only if the key does not exist
$isUnique = Redis::connection()->client()->set(
$redisKey,
'1',
'EX',
86400,
'NX'
);

if (!$isUnique) {
return response()->json(['status' => 'duplicate_ignored'], 200);
}

ProcessTelegramUpdate::dispatch($payload);

return response()->json(['status' => 'queued'], 200);
}
}

Without Redis, insert update_id into a table with a unique constraint. Catch the duplicate-key error and still return 200.

Step 4: Queued job

The job parses the update, runs business logic, and calls the Bot API. Keep the HTTP response light; do the work here.

php artisan make:job ProcessTelegramUpdate
<?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;

public int $tries = 3;
public int $backoff = 5;

public function __construct(protected array $payload)
{
}

public function handle(): void
{
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.');
}
}

if (isset($this->payload['callback_query'])) {
$callbackQuery = $this->payload['callback_query'];
$callbackQueryId = $callbackQuery['id'];
$data = $callbackQuery['data'] ?? '';

$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";
$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 notes

Callback data limit

callback_data is capped at 64 bytes. Do not stuff large JSON into buttons. Store state in Redis or the database and put a short id in the button.

Rate limits and retries

Telegram limits outbound traffic (roughly 30 messages per second globally, about 1 per second per chat). On HTTP 429, read Retry-After (or parameters.retry_after) and release the job with that delay.

Write state before you confirm

For payments or leads, commit the database write before you send a confirmation. Example:

$leadId = bin2hex(random_bytes(7));

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

$this->sendTextMessage($chatId, "Your lead ID is: <b>{$leadId}</b>");

If the insert fails, the job fails and the user never gets a fake confirmation.

Need a studio that ships this kind of webhook stack for bots and Mini Apps? Start at botservice.biz.

New articles on Telegram

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