When developing high-load and fault-tolerant Telegram-бот s on the Yii2 framework, webhooks are the preferred way to receive events. However, in production environments, developers often face challenges: request blocking by Yii2's CSRF protection mechanism, duplicate message processing due to Telegram retries, and webhook hangs during heavy operations.
In this article, we will build a webhook controller architecture that solves these problems: we will disable CSRF validation, configure authentication of incoming requests via X-Telegram-Bot-Api-Secret-Token, ensure idempotency by update_id, and move all business logic to the yii2-queue background queue.
1. Routing and Disabling CSRF Validation in Yii2
By default, Yii2 checks for a CSRF token in POST requests. Obviously, Telegram servers do not transmit a Yii2 token, so without special configuration, the controller will return an HTTP error 400 Bad Request.
To disable the check, it is sufficient to set the property public $enableCsrfValidation = false; directly in the webhook controller. It is also important to explicitly set the response format to Response::FORMAT_JSON so that the server always returns a valid JSON response with correct headers.
2. Endpoint Protection: Checking X-Telegram-Bot-Api-Secret-Token
Since the webhook endpoint is publicly accessible on the web, any attacker can send a fake HTTP POST request to it. To guarantee that the request came specifically from Telegram, the secret_token parameter is used when calling the setWebhook method.
With each call, Telegram passes this string in the X-Telegram-Bot-Api-Secret-Token HTTP header. The controller must compare the received value with the secret from the application configuration (for example, Yii::$app->params['telegram_secret_token'] or environment variables getenv()). If the header is missing or does not match, the request is immediately rejected with a 403 Forbidden code before parsing the request body.
3. Idempotency and Duplicate Prevention via update_id
Telegram expects an HTTP 200 OK response within a few seconds. If the web server takes longer to respond (due to network latency, failures, or timeouts), Telegram considers the delivery failed and retries sending the exact same update_id with an exponential backoff.
Without a deduplication mechanism, this leads to duplicate data entry in the database, duplicate orders, or multiple messages sent to the user. The solution is to save the incoming update_id to the database (or atomically in Redis) using a unique index before sending the task to the queue. If the update_id is already present in the system, the controller immediately returns 200 OK without recreating the task.
4. Fast Response and Offloading Logic to yii2-queue
The webhook controller should not perform heavy tasks: calling external CRMs, generating PDFs, or executing complex SQL queries. The sole task of actionWebhook is to accept the JSON, verify the secret, record the update_id, queue the task, and return HTTP 200 OK within 10–50 milliseconds.
All business logic is handled by the yii2-queue component (using DB, Redis, or RabbitMQ as a driver). This completely eliminates timeouts from the Telegram API.
5. Implementing the Webhook Controller in Yii2
Below is the working code for the TelegramWebhookController.php controller. The Токен бота and the webhook secret are read from the application configuration.
<?php
namespace app\controllers;
use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\ForbiddenHttpException;
use app\jobs\TelegramUpdateJob;
class TelegramWebhookController extends Controller
{
/**
* Отключаем CSRF-валидацию для приема вебхуков от Telegram
*/
public $enableCsrfValidation = false;
public function actionIndex()
{
Yii::$app->response->format = Response::FORMAT_JSON;
// 1. Проверяем secret_token из заголовка
$secretHeader = Yii::$app->request->getHeaders()->get('X-Telegram-Bot-Api-Secret-Token');
$expectedSecret = Yii::$app->params['telegram_secret_token'] ?? getenv('TELEGRAM_SECRET_TOKEN');
if (empty($expectedSecret) || $secretHeader !== $expectedSecret) {
Yii::warning('Недействительный Secret Token в Telegram Webhook', 'telegram');
throw new ForbiddenHttpException('Invalid secret token');
}
// 2. Получаем и декодируем RAW JSON
$rawBody = Yii::$app->request->getRawBody();
$update = json_decode($rawBody, true);
if (json_last_error() !== JSON_ERROR_NONE || !isset($update['update_id'])) {
return ['status' => 'error', 'message' => 'Invalid JSON payload'];
}
$updateId = (int)$update['update_id'];
// 3. Проверка идемпотентности через БД
$db = Yii::$app->db;
$exists = $db->createCommand(
'SELECT 1 FROM telegram_processed_updates WHERE update_id = :id',
[':id' => $updateId]
)->queryScalar();
if ($exists) {
// Игнорируем дубликат, отдаем 200 OK
return ['status' => 'ok', 'message' => 'Already processed'];
}
// Регистрируем update_id в таблице обработанных событий
$db->createCommand()->insert('telegram_processed_updates', [
'update_id' => $updateId,
'created_at' => date('Y-m-d H:i:s'),
])->execute();
// 4. Отправляем апдейт в очередь yii2-queue
Yii::$app->queue->push(new TelegramUpdateJob([
'update' => $update,
]));
return ['status' => 'ok'];
}
}
6. Processing Incoming Events in a Background Queue Job
Below is the TelegramUpdateJob.php class, which is executed by the queue worker. Here, commands are parsed, the lead is saved to the database without using sessions ($_SESSION is prohibited in the console context of the queue), and responses are sent via cURL with error and response status checks Telegram Bot API.
<?php
namespace app\jobs;
use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
class TelegramUpdateJob extends BaseObject implements JobInterface
{
public array $update;
public function execute($queue)
{
if (isset($this->update['message'])) {
$this->handleMessage($this->update['message']);
} elseif (isset($this->update['callback_query'])) {
$this->handleCallbackQuery($this->update['callback_query']);
}
}
private function handleMessage(array $message): void
{
$chatId = $message['chat']['id'] ?? null;
$text = trim($message['text'] ?? '');
if (!$chatId) {
return;
}
if (str_starts_with($text, '/start')) {
// Генерация случайного идентификатора лида
$leadId = bin2hex(random_bytes(7));
// Сохраняем заявку в БД
Yii::$app->db->createCommand()->insert('leads', [
'lead_id' => $leadId,
'telegram_chat_id' => $chatId,
'status' => 'new',
'created_at' => date('Y-m-d H:i:s'),
])->execute();
// callback_data не должен превышать 64 байта
$keyboard = [
'inline_keyboard' => [[
['text' => 'Подтвердить заявку', 'callback_data' => 'cnf_' . $leadId]
]]
];
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Заявка №{$leadId} создана. Нажмите кнопку для подтверждения.",
'reply_markup' => json_encode($keyboard),
]);
}
}
private function handleCallbackQuery(array $callbackQuery): void
{
$callbackId = $callbackQuery['id'];
$chatId = $callbackQuery['message']['chat']['id'] ?? null;
$data = $callbackQuery['data'] ?? '';
if (str_starts_with($data, 'cnf_')) {
$leadId = substr($data, 4);
Yii::$app->db->createCommand()->update('leads',
['status' => 'confirmed'],
'lead_id = :lid',
[':lid' => $leadId]
)->execute();
// Обязательный ответ на Callback Query
$this->sendTelegramRequest('answerCallbackQuery', [
'callback_query_id' => $callbackId,
'text' => 'Заявка подтверждена!',
]);
if ($chatId) {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Статус заявки №{$leadId} изменен на "Подтверждена".",
]);
}
}
}
/**
* Метод отправки запросов к Telegram Bot API через cURL
*/
private function sendTelegramRequest(string $method, array $params): array
{
$botToken = Yii::$app->params['telegram_bot_token'] ?? getenv('TELEGRAM_BOT_TOKEN');
$url = "https://api.telegram.org/bot{$botToken}/{$method}";
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query($params),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$curlError = curl_error($ch);
curl_close($ch);
if ($response === false) {
Yii::error("Telegram cURL Error: {$curlError}", 'telegram');
return ['ok' => false];
}
$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
Yii::error("Telegram invalid JSON: {$response}", 'telegram');
return ['ok' => false];
}
if ($httpCode !== 200 || !($result['ok'] ?? false)) {
Yii::warning("Telegram API Error [{$httpCode}]: " . json_encode($result), 'telegram');
}
return $result;
}
}
7. Table for Storing Processed update_ids
To ensure the idempotency check works correctly, create a table in your database using a Yii2 migration:
CREATE TABLE `telegram_processed_updates` (
`update_id` BIGINT NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`update_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
It is recommended to set up periodic cleanup of this table using a Yii2 console command (for example, deleting records older than 3–7 days), as Telegram will not retry after such a long time.
Summary
The described scheme guarantees a high level of security and fault tolerance for webhooks in Yii2. Секретный токен protects against unauthorized calls, the database prevents reprocessing due to duplicate Telegram requests, and yii2-queue guarantees a fast HTTP 200 OK response without web server hangs.
If you need professional development of high-load Telegram bots or integration of Mini Apps with your infrastructure, contact the specialists at BotCreator.