When building production-ready Telegram bots, handling webhooks efficiently is critical. Telegram expects your server to respond with an HTTP 200 OK status quickly. If your application performs database queries, external API calls, or heavy processing synchronously within the request cycle, you risk timing out. This causes Telegram to retry sending the same update, leading to duplicate processing.
This tutorial demonstrates how to build a robust Telegram webhook ingestion pipeline in Laravel. We will secure the endpoint using a custom middleware that validates Telegram's secret token, dispatch incoming payloads to a queued job, and enforce strict idempotency using Redis to prevent processing duplicate updates.
We do not cover setting up a full bot framework or managing long-running worker daemons here; we focus strictly on the webhook ingestion layer.
Step 1: Secure the Webhook with Middleware
When registering your webhook with the Telegram Bot API via setWebhook, you can specify a secret_token (a string containing A-Z, a-z, 0-9, _, and -). Telegram will include this token in every webhook request under the X-Telegram-Bot-Api-Secret-Token header. This allows you to verify that incoming requests originate from Telegram.
First, add your secret token to your config/services.php file:
// config/services.php
return [
// ...
'telegram' => [
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'secret_token' => env('TELEGRAM_WEBHOOK_SECRET'),
],
];
Next, create a middleware to validate this header:
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
{
$header = $request->header('X-Telegram-Bot-Api-Secret-Token');
$secret = config('services.telegram.secret_token');
if (!$secret || $header !== $secret) {
return response()->json(['error' => 'Unauthorized'], 403);
}
return $next($request);
}
}
Step 2: Define the Route and Dispatch the Job
To keep the HTTP response time as low as possible, the controller or route closure should only validate the basic payload structure, dispatch a queued job, and immediately return an HTTP 200 OK response.
Define the route in routes/api.php:
use App\Http\Middleware\VerifyTelegramSecret;
use App\Jobs\ProcessTelegramUpdate;
use Illuminate\Support
use Illuminate\Support\Facades\Route;
use Illuminate\Http\Request;
Route::post('/telegram/webhook', function (Request $request) {
$payload = $request->all();
if (!isset($payload['update_id'])) {
return response()->json(['error' => 'Malformed payload'], 400);
}
// Dispatch the job to the queue
ProcessTelegramUpdate::dispatch($payload);
return response()->json(['status' => 'queued'], 200);
})->middleware(VerifyTelegramSecret::class);
Step 3: Implement the Queued Job with Redis Idempotency
Telegram guarantees at-least-once delivery. Network hiccups, temporary timeouts, or worker restarts can cause Telegram to redeliver an update that your application has already processed. To prevent duplicate side effects (such as charging a user twice or sending duplicate messages), you must track processed update_id values.
We will use Redis to store processed update_id keys with a 24-hour expiration window. Using SET with the NX (Set if Not Exists) option ensures atomic verification and locking.
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
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Http;
class ProcessTelegramUpdate implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(protected array $payload) {}
public function handle(): void
{
$updateId = $this->payload['update_id'] ?? null;
if (!$updateId) {
return;
}
$lockKey = "tg_update:{$updateId}";
// Set key with 24-hour expiration (86400 seconds), only if it does not exist (NX)
$acquired = Redis::set($lockKey, '1', 'EX', 86400, 'NX');
if (!$acquired) {
Log::info("Duplicate Telegram update ignored: {$updateId}");
return;
}
try {
$this->process($this->payload);
} catch (\Throwable $e) {
// If processing fails, delete the lock so the retried job can run again
Redis::del($lockKey);
throw $e;
}
}
protected function process(array $payload): void
{
if (isset($payload['callback_query'])) {
$this->handleCallbackQuery($payload['callback_query']);
}
// Handle other update types (message, edited_message, etc.) here
}
protected function handleCallbackQuery(array $callbackQuery): void
{
$callbackId = $callbackQuery['id'];
$data = $callbackQuery['data'] ?? '';
// Ensure callback_data is within the 64-byte limit during your bot design
if (strlen($data) > 64) {
Log::warning("Callback data exceeds 64 bytes: {$data}");
}
// Always answer callback queries to remove the loading state on the user's client
$this->answerCallbackQuery($callbackId, 'Action processed successfully.');
}
protected function answerCallbackQuery(string $callbackId, string $text): void
{
$token = config('services.telegram.bot_token');
$response = Http::timeout(5)
->post("https://api.telegram.org/bot{$token}/answerCallbackQuery", [
'callback_query_id' => $callbackId,
'text' => $text,
]);
if ($response->failed()) {
Log::error("Failed to answer callback query {$callbackId}: " . $response->body());
}
}
}
Production Considerations
When running this architecture in production, ensure your queue worker is configured correctly. If you are using the database queue driver, ensure your database can handle the concurrent write load. For high-throughput environments, the Redis queue driver is highly recommended.
If you are sending HTML formatted messages back to users, always sanitize dynamic user input using htmlspecialchars() before embedding it in your payload to prevent parsing errors on Telegram's side.
Need assistance building or scaling your Telegram integrations? Contact BotCreator — studio that ships Telegram bots / Mini Apps.