Building a Fault-Tolerant Telegram Webhook in Yii2: Bypassing CSRF, Secret Token Protection, and Yii Queue

When developing high-load Telegram-бот s in Yii2, the standard approach of processing updates "on the fly" quickly hits platform limitations. Telegram expects a fast HTTP 200 OK response from your server (preferably within 1–2 seconds). If your code starts executing "heavy" operations—requests to third-party APIs, image generation, or complex DB transactions—the timeout is exceeded. Telegram considers this a delivery failure and begins resending the same events (updates), which exponentially increases the load and leads to duplicate actions.

Architectural Pattern: Fast Ingestion and Background Processing

The only reliable way to design a webhook is to separate message ingestion from business logic processing. The schema looks like this:

  1. The controller receives a POST request from Telegram.
  2. The request signature (Secret Token) is verified to protect against spam.
  3. A duplicate check (idempotency) is performed using the unique update_id.
  4. The raw JSON is saved to a DB buffer table with a pending status.
  5. A lightweight processing task is pushed to the queue (Yii Queue).
  6. The controller instantly returns an HTTP 200 OK response.
  7. A background worker asynchronously processes the task from the queue.

Step 1. Creating a Table for Incoming Updates

To ensure idempotency and logging of all incoming payloads, we will need a database table. The update_id field provided by Telegram is ideal as a primary key. This guarantees that at the DBMS level, we will never write the same event twice.

Let's create a Yii2 migration:

use yii\db\Migration;

class m240101_000000_create_telegram_update_table extends Migration
{
public function safeUp()
{
$this->createTable('{{%telegram_update}}', [
'id' => $this->bigInteger()->notNull(), // update_id от Telegram
'payload' => $this->text()->notNull(),
'status' => $this->string(32)->notNull()->defaultValue('pending'),
'created_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP'),
'updated_at' => $this->timestamp()->defaultExpression('CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'),
]);

$this->addPrimaryKey('pk-telegram_update-id', '{{%telegram_update}}', 'id');
$this->createIndex('idx-telegram_update-status', '{{%telegram_update}}', 'status');
}

public function safeDown()
{
$this->dropTable('{{%telegram_update}}');
}
}

Step 2. Bypassing CSRF and Validating X-Telegram-Bot-Api-Secret-Token

By default, Yii2 protects all POST requests using a CSRF mechanism. Requests from Telegram come from outside, so CSRF validation for the webhook must be disabled. This is done by declaring the property $enableCsrfValidation = false in the controller.

To protect the route from unauthorized requests, we must use the secret_token parameter when registering the webhook via the setWebhook method. Telegram will pass this token in the X-Telegram-Bot-Api-Secret-Token header. Our controller must compare it with the value from the configuration.

Let's implement the WebhookController:

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\BadRequestHttpException;
use yii\web\Response;
use app\models\TelegramUpdate;
use app\jobs\TelegramProcessJob;

class WebhookController extends Controller
{
// Отключаем встроенную CSRF-защиту Yii2
public $enableCsrfValidation = false;

public function actionIndex()
{
Yii::$app->response->format = Response::FORMAT_JSON;

// 1. Валидация секретного токена
$expectedToken = Yii::$app->params['telegram_webhook_secret_token'] ?? null;
$receivedToken = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (empty($expectedToken) || $receivedToken !== $expectedToken) {
throw new BadRequestHttpException('Access denied. Invalid secret token.');
}

// 2. Чтение и парсинг тела запроса
$rawBody = Yii::$app->request->getRawBody();
$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. Обеспечение идемпотентности через транзакцию БД
$transaction = Yii::$app->db->beginTransaction();
try {
$exists = TelegramUpdate::find()->where(['id' => $updateId])->exists();
if ($exists) {
$transaction->rollBack();
// Возвращаем 200 OK, так как этот апдейт уже сохранен/обрабатывается
return ['status' => 'duplicate', 'update_id' => $updateId];
}

$dbUpdate = new TelegramUpdate();
$dbUpdate->id = $updateId;
$dbUpdate->payload = $rawBody;
$dbUpdate->status = 'pending';

if (!$dbUpdate->save()) {
throw new \Exception('Failed to save update to database.');
}

$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
Yii::error('Webhook DB error: ' . $e->getMessage(), 'telegram');
// Возвращаем HTTP 200, чтобы избежать бесконечного спама повторами от Telegram
return ['status' => 'db_error', 'message' => $e->getMessage()];
}

// 4. Постановка задачи в очередь Yii Queue
Yii::$app->queue->push(new TelegramProcessJob([
'updateId' => $updateId,
]));

return ['status' => 'accepted', 'update_id' => $updateId];
}
}

Step 3. Asynchronous Worker with Yii Queue

To work with queues in Yii2, the yiisoft/yii2-queue extension is typically used. Our job must fetch the raw payload from the database by the received updateId, execute the entire business logic chain, send a response to the user via Telegram Bot API, and update the record status to processed.

To send requests to Telegram, we use classic cURL. Note: we strictly check the HTTP response code, cURL errors, and the ok flag in the Telegram API JSON response. No unsafe file_get_contents.

namespace app\jobs;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;
use app\models\TelegramUpdate;

class TelegramProcessJob extends BaseObject implements JobInterface
{
/** @var int */
public $updateId;

public function execute($queue)
{
$dbUpdate = TelegramUpdate::findOne($this->updateId);
if (!$dbUpdate || $dbUpdate->status !== 'pending') {
return;
}

$payload = json_decode($dbUpdate->payload, true);
if (!$payload) {
$dbUpdate->status = 'failed';
$dbUpdate->save(false);
return;
}

try {
$this->processPayload($payload);

$dbUpdate->status = 'processed';
$dbUpdate->save(false);
} catch (\Exception $e) {
Yii::error("Failed processing update {$this->updateId}: " . $e->getMessage(), 'telegram');
$dbUpdate->status = 'failed';
$dbUpdate->save(false);

// Выбрасываем исключение дальше, чтобы очередь могла повторить попытку позже
throw $e;
}
}

protected function processPayload(array $payload)
{
// Пример обработки текстового сообщения
if (isset($payload['message']['chat']['id']) && isset($payload['message']['text'])) {
$chatId = $payload['message']['chat']['id'];
$text = trim($payload['message']['text']);

if ($text === '/start') {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => "Добро пожаловать! Ваш запрос отправлен в обработку.",
]);
}
}
}

protected function sendTelegramRequest(string $method, array $params)
{
$token = Yii::$app->params['telegram_bot_token'] ?? null;
if (!$token) {
throw new \Exception('Telegram bot token is not configured.');
}

$url = "https://api.telegram.org/bot{$token}/{$method}";
$ch = curl_init();

curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
]);

$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);

if ($error) {
throw new \Exception("cURL Error: {$error}");
}

if ($httpCode !== 200) {
throw new \Exception("Telegram API returned HTTP Code {$httpCode}. Response: {$response}");
}

$result = json_decode($response, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Telegram response is not valid JSON.');
}

if (!($result['ok'] ?? false)) {
$description = $result['description'] ?? 'No description';
throw new \Exception("Telegram API error: {$description}");
}

return $result;
}
}

Operational and Logging Recommendations

When using queues, it is important to properly set up monitoring. If a worker fails due to an error (for example, an external integration timeout), the task should return to the queue with a retry delay. Configure the attempt limit (ttr) in the console component configuration of Yii2 Queue to avoid infinite looping of broken tasks.

For high-load systems, it is recommended to periodically clean the telegram_update buffer table. Keeping records for the last 3–7 days is sufficient for incident investigation. Rotation can be performed via a Yii2 console command scheduled via cron once a day.

If you need help designing the architecture of high-load bots, the team of experts at BotCreator will help implement fault-tolerant solutions of any complexity.

New articles on Telegram

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