Prevent Duplicate Telegram Bot Actions: Build an Idempotent Webhook Handler in Laravel

Imagine running a Telegram-based digital storefront or a booking bot. A user clicks a button to purchase a subscription or confirm a booking. Your server receives the webhook from Telegram, begins processing the payment, and updates your database. However, due to a temporary network hiccup or a slow external API call, your server takes more than five seconds to respond.

Telegram's webhook engine assumes your server failed to receive the payload if it does not get an immediate HTTP 200 OK response. It will retry sending the exact same payload. If your webhook handler runs synchronously and lacks protection, the user is charged twice, or duplicate database records are created.

To build a production-grade Telegram bot, you must solve three critical problems: secure your webhook endpoint from unauthorized requests, respond to Telegram instantly to prevent retries, and ensure that each unique update payload is processed exactly once. This walkthrough demonstrates how to implement a secure, queued, and idempotent Telegram webhook architecture in Laravel using custom middleware, Redis-backed atomic locks, and queued jobs.

Securing the Entry Point with Telegram Secret Tokens

Before processing any payload, you must verify that the incoming request actually originated from Telegram. Anyone who discovers your webhook URL could send spoofed payloads to manipulate your application's state.

Telegram provides a secure verification mechanism: the X-Telegram-Bot-Api-Secret-Token header. When you register your webhook using the setWebhook method, you can pass an arbitrary string as the secret_token. Telegram will then include this token in the header of every single webhook request. If the header is missing or does not match your configured secret, you can immediately reject the request.

First, add your Telegram configuration to your config/services.php file:

return [
// ... other services
'telegram' => [
'bot_token' => env('TELEGRAM_BOT_TOKEN'),
'secret_token' => env('TELEGRAM_SECRET_TOKEN'),
],
];

Next, generate a custom middleware to handle this verification:

php artisan make:middleware VerifyTelegramSecret

Open the newly created middleware file and implement the verification logic. We will compare the incoming header against our configured secret token. To prevent timing attacks, we use PHP's hash_equals function for a constant-time string comparison.

<?php

namespace App\Http\Middleware;

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

class VerifyTelegramSecret
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
$configuredSecret = config('services.telegram.secret_token');

// If no secret is configured, block requests by default in production
if (empty($configuredSecret)) {
return response()->json(['error' => 'Webhook secret token not configured.'], 500);
}

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

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

return $next($request);
}
}

Now, register this middleware in your application. If you are using Laravel 11, you can apply it directly to your routes in routes/api.php or register it in bootstrap/app.php. Let's define the route in routes/api.php and apply our middleware:

<?php

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

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

Dispatching to Queues and Ensuring Idempotency with Redis

Once the request passes the security check, we must handle it. To prevent Telegram from timing out and retrying, we must not perform any heavy lifting (such as database writes, external API calls, or sending messages back to Telegram) inside the HTTP request lifecycle. We must dispatch a queued job and return an HTTP 200 OK immediately.

However, simply dispatching a job does not solve the duplication problem. If Telegram retries the request before your server can return 200 OK, or if a network delay causes the same update to be sent twice, your queue will receive duplicate jobs.

To prevent this, we use the unique update_id provided in every Telegram update payload. We can leverage Redis to perform an atomic "set if not exists" (NX) operation. If we successfully store the update_id in Redis, it means this is the first time we are seeing this update. If the key already exists, we discard the request immediately and return 200 OK to tell Telegram we have received it.

Generate the controller:

php artisan make:controller TelegramWebhookController --invokable

Implement the controller logic using Laravel's Redis facade to enforce idempotency:

<?php

namespace App\Http\Controllers;

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

class TelegramWebhookController
{
/**
* Handle the incoming Telegram webhook.
*/
public function __invoke(Request $request): JsonResponse
{
$payload = $request->all();
$updateId = $payload['update_id'] ?? null;

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

$redisKey = "telegram_update:{$updateId}";

// Attempt to set the key with a 24-hour (86400 seconds) expiration time.
// 'NX' ensures the key is only set if it does not already exist.
$isUnique = Redis::executeRaw([
'SET',
$redisKey,
'processed',
'EX',
86400,
'NX'
]);

if (!$isUnique) {
Log::info("Duplicate Telegram update ignored.", ['update_id' => $updateId]);
// Return 200 OK so Telegram stops retrying this specific update
return response()->json(['status' => 'duplicate_ignored']);
}

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

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

Handling the Queued Job Safely

Now that the payload is safely validated and deduplicated, we can process it in a queued job. If the job fails due to an external service outage, Laravel's queue worker can retry it. Because we already marked the update_id as processed in Redis during the controller phase, subsequent retries of the *same* webhook request from Telegram will be ignored, but our *internal* queue retries will still run safely to completion.

Generate the queued job:

php artisan make:job ProcessTelegramUpdate

Inside the job, we will parse the payload and execute our business logic. For this example, we will handle a basic text message and reply to the user using Laravel's HTTP client. We must also handle potential API errors from Telegram gracefully.

<?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 Exception;

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;

/**
* Create a new job instance.
*/
public function __construct(protected array $payload)
{
}

/**
* Execute the job.
*/
public function handle(): void
{
$message = $this->payload['message'] ?? null;
if (!$message) {
return;
}

$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if (!$chatId || empty($text)) {
return;
}

// Example business logic: Echo back the message
$replyText = "You said: " . htmlspecialchars($text, ENT_QUOTES, 'UTF-8');

$this->sendTelegramMessage($chatId, $replyText);
}

/**
* Send a message back to Telegram via the Bot API.
*/
protected function sendTelegramMessage(int $chatId, string $text): void
{
$token = config('services.telegram.bot_token');
$url = "https://api.telegram.org/bot{$token}/sendMessage";

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

if ($response->failed()) {
$errorData = $response->json();
Log::error('Telegram API error', [
'status' => $response->status(),
'response' => $errorData,
]);

// If Telegram returns a 429 (Too Many Requests) or a 5xx server error, retry the job
if ($response->status() === 429 || $response->status() >= 500) {
throw new Exception("Telegram API failed with status " . $response->status());
}
}
}
}

Production Notes

When deploying this architecture to production, keep the following operational details in mind:

1. Setting the Webhook Correctly: You must register your webhook with Telegram and provide the secret token. You can do this via a simple curl request. Replace <YOUR_BOT_TOKEN> with your actual bot token, and set the url and secret_token parameters:

   curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourdomain.com/api/telegram/webhook", "secret_token": "your_secure_random_secret_token_here"}'

2. Redis Key Expiration (TTL): In our controller, we set the Redis key expiration to 24 hours (86400 seconds). Telegram does not keep retrying updates for longer than 24 hours. This TTL prevents your Redis instance from growing indefinitely while ensuring that any delayed retries from Telegram are still caught by the deduplication layer.

3. Queue Worker Configuration: Ensure your queue workers are running continuously in production using a process monitor like Supervisor. If your queue worker stops, updates will pile up in your queue database, but your webhook controller will still return 200 OK to Telegram, preventing Telegram from spamming your server with retries.

4. Database Transactions: If your job performs database writes, wrap them in a transaction. If the job fails halfway through and retries, you want to ensure that any partial database changes are rolled back before the next attempt runs.

If you need help building reliable, high-performance messaging systems or custom integrations, reach out to BotCreator — studio that ships Telegram bots / Mini Apps. For more details on working with Telegram's official endpoints, consult the documentation at

New articles on Telegram

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