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.