When building a Telegram bot at scale, handling incoming updates directly in the web server's request-response cycle is a recipe for timeouts and dropped updates. Telegram expects your webhook to return a 200 OK response within a few seconds. If your bot performs external API calls, database writes, or heavy processing, you must decouple ingestion from processing.
This tutorial demonstrates how to build a secure, idempotent, and queued Telegram webhook controller in Yii2. We will disable CSRF validation for the webhook endpoint, validate the X-Telegram-Bot-Api-Secret-Token header, log the update_id to prevent duplicate processing, and dispatch the payload to yii2-queue.
This implementation focuses strictly on the webhook ingestion layer. It does not cover message routing or response generation.
1. Database Schema for Idempotency
Telegram may occasionally redeliver the same update if your server takes too long to respond or if there is a network glitch. To prevent processing the same message twice, we track processed update_id values in a database table with a unique constraint.
Create a migration using the Yii2 CLI:
./yii migrate/create create_telegram_processed_update_table
Define the schema in the generated migration file:
<?php
use yii\db\Migration;
class m240101_000000_create_telegram_processed_update_table extends Migration
{
public function safeUp()
{
$this->createTable('{{%telegram_processed_update}}', [
'update_id' => $this->bigInteger()->notNull(),
'created_at' => $this->integer()->notNull(),
]);
$this->addPrimaryKey('pk-telegram_processed_update', '{{%telegram_processed_update}}', 'update_id');
}
public function safeDown()
{
$this->dropTable('{{%telegram_processed_update}}');
}
}
Run the migration:
./yii migrate
2. The Background Queue Job
We use the official yiisoft/yii2-queue extension to process updates asynchronously. First, define the job class that will handle the actual bot logic once the webhook has accepted the payload.
Create jobs/ProcessTelegramUpdateJob.php:
<?php
namespace app\jobs;
use yii\base\BaseObject;
use yii\queue\JobInterface;
class ProcessTelegramUpdateJob extends BaseObject implements JobInterface
{
/**
* @var array The raw Telegram update payload
*/
public $update;
/**
* @param \yii\queue\Queue $queue
*/
public function execute($queue)
{
// Implement your message routing and business logic here.
// Example: Accessing the message text:
// $text = $this->update['message']['text'] ?? null;
}
}
3. The Webhook Controller
Now, build the controller. We must disable Yii2's built-in CSRF validation because Telegram's POST requests do not carry Yii's CSRF token.
To secure the endpoint, we validate the X-Telegram-Bot-Api-Secret-Token header. This token is a shared secret configured when setting up the webhook via the Telegram Bot API.
Create controllers/TelegramController.php:
<?php
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\jobs\ProcessTelegramUpdateJob;
class TelegramController extends Controller
{
/**
* Disable CSRF validation for incoming Telegram POST requests.
*/
public $enableCsrfValidation = false;
/**
* Webhook endpoint for Telegram updates.
*
* @return array
* @throws BadRequestHttpException
*/
public function actionWebhook()
{
Yii::$app->response->format = Response::FORMAT_JSON;
// 1. Validate the Secret Token
$expectedToken = getenv('TELEGRAM_SECRET_TOKEN');
$receivedToken = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');
if (empty($expectedToken) || $receivedToken !== $expectedToken) {
throw new BadRequestHttpException('Unauthorized request.');
}
// 2. Parse and Validate Payload
$rawBody = Yii::$app->request->rawBody;
$update = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
throw new BadRequestHttpException('Invalid JSON payload.');
}
$updateId = (int)$update['update_id'];
// 3. Enforce Idempotency
$db = Yii::$app->db;
$exists = $db->createCommand(
'SELECT 1 FROM {{%telegram_processed_update}} WHERE [[update_id]] = :id',
[':id' => $updateId]
)->queryScalar();
if ($exists) {
// Already processed or currently processing. Return 200 OK to Telegram.
return ['status' => 'ignored', 'reason' => 'duplicate'];
}
try {
$db->createCommand()->insert('{{%telegram_processed_update}}', [
'update_id' => $updateId,
'created_at' => time(),
])->execute();
} catch (\yii\db\Exception $e) {
// Handle race conditions under high concurrency
return ['status' => 'ignored', 'reason' => 'duplicate_race'];
}
// 4. Hand off to Queue
Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'update' => $update,
]));
return ['status' => 'queued'];
}
}
Production Considerations
#### Queue Runner In production, do not use the default sync queue driver, as it processes jobs inline and defeats the purpose of decoupling. Use a production-ready driver like Redis or RabbitMQ. Ensure you run the queue listener as a background daemon using a process manager like Supervisor:
[program:yii-queue-listen]
command=/usr/bin/php /path/to/your/app/yii queue/listen --verbose=1
autostart=true
autorestart=true
user=www-data
numprocs=1
redirect_stderr=true
stdout_logfile=/path/to/your/app/runtime/logs/queue.log
#### Setting the Webhook When registering your webhook with the Telegram Bot API, pass your secret token in the secret_token parameter. You can do this with a simple curl request:
curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourdomain.com/telegram/webhook",
"secret_token": "YOUR_SECURE_RANDOM_SECRET_TOKEN"
}'
Keep YOUR_SECURE_RANDOM_SECRET_TOKEN stored securely in your server's environment variables (.env) and never commit it to version control.
Need assistance scaling your Telegram infrastructure or building complex integrations? BotCreator is a studio that ships Telegram bots and Mini Apps with robust, production-ready architectures.