Webhook controller for the Telegram bot on Yii2: secret_token, idempotence and queue

A webhook is the production way to receive Telegram bot updates: faster than long polling and works behind a load balancer. In Yii2 you handle updates in one action — and that is exactly where CSRF, secret checks, and update_id idempotency usually break. Below is a working controller with a queue.

1. setWebhook and config

Keep the token and secret in params / ENV only. Register the webhook:

<?php
// console or one-shot deploy script
$token = Yii::$app->params['telegram.botToken'];
$secret = Yii::$app->params['telegram.webhookSecret']; // 16–64 chars
$url = 'https://example.com/telegram/webhook';

$ch = curl_init("https://api.telegram.org/bot{$token}/setWebhook");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => [
'url' => $url,
'secret_token' => $secret,
'allowed_updates' => json_encode(['message', 'callback_query']),
'drop_pending_updates' => false,
],
]);
$raw = curl_exec($ch);
curl_close($ch);
$result = json_decode((string) $raw, true);
if (empty($result['ok'])) {
throw new RuntimeException($result['description'] ?? 'setWebhook failed');
}

2. Disable CSRF for the webhook action only

Telegram does not send Yii CSRF tokens. Do not disable CSRF globally — turn it off for this action only.

<?php
namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\filters\VerbFilter;

class TelegramController extends Controller
{
public $enableCsrfValidation = true;

public function beforeAction($action)
{
if ($action->id === 'webhook') {
$this->enableCsrfValidation = false;
}
return parent::beforeAction($action);
}

public function behaviors()
{
return [
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'webhook' => ['POST'],
],
],
];
}
}

3. Verify secret_token with hash_equals

Telegram sends the X-Telegram-Bot-Api-Secret-Token header. Compare in constant time.

private function assertSecret(): void
{
$expected = (string) Yii::$app->params['telegram.webhookSecret'];
$got = (string) Yii::$app->request->headers->get(
'X-Telegram-Bot-Api-Secret-Token',
''
);
if ($expected === '' || !hash_equals($expected, $got)) {
Yii::$app->response->statusCode = 403;
throw new \yii\web\ForbiddenHttpException('bad secret');
}
}

4. Idempotency table

CREATE TABLE processed_updates (
update_id BIGINT UNSIGNED NOT NULL PRIMARY KEY,
received_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;

5. actionWebhook + Yii Queue

Telegram waits roughly 60 seconds for a response. In the action: verify, persist update_id, push a job. Heavy work belongs in a worker.

<?php
use app\jobs\ProcessTelegramUpdateJob;
use yii\db\IntegrityException;

public function actionWebhook()
{
Yii::$app->response->format = Response::FORMAT_RAW;
$this->assertSecret();

$raw = Yii::$app->request->getRawBody();
$update = json_decode($raw, true);
if (!is_array($update) || !isset($update['update_id'])) {
Yii::$app->response->statusCode = 400;
return 'bad payload';
}

$updateId = (int) $update['update_id'];
$db = Yii::$app->db;

$tx = $db->beginTransaction();
try {
$db->createCommand()->insert('processed_updates', [
'update_id' => $updateId,
])->execute();

Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'update' => $update,
]));

$tx->commit();
} catch (IntegrityException $e) {
$tx->rollBack();
// duplicate update_id — already accepted, still return 200
} catch (\Throwable $e) {
$tx->rollBack();
Yii::error($e->getMessage(), __METHOD__);
Yii::$app->response->statusCode = 500;
return 'error';
}

Yii::$app->response->statusCode = 200;
return 'ok';
}

6. cURL client for Bot API

<?php
namespace app\components;

use Yii;
use yii\base\Component;
use RuntimeException;

class TelegramApi extends Component
{
public string $token;

public function call(string $method, array $params = []): array
{
$ch = curl_init("https://api.telegram.org/bot{$this->token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
CURLOPT_POSTFIELDS => $params,
]);
$body = curl_exec($ch);
$http = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($body === false) {
$err = curl_error($ch);
curl_close($ch);
throw new RuntimeException("cURL: {$err}");
}
curl_close($ch);

$data = json_decode($body, true);
if (!is_array($data) || empty($data['ok'])) {
$desc = is_array($data) ? ($data['description'] ?? 'unknown') : 'bad JSON';
throw new RuntimeException("TG {$http}: {$desc}");
}
return $data['result'];
}

public function sendMessage($chatId, string $text, array $extra = []): array
{
return $this->call('sendMessage', array_merge([
'chat_id' => $chatId,
'text' => $text,
], $extra));
}
}

Register in config: 'telegramApi' => ['class' => TelegramApi::class, 'token' => $params['telegram.botToken']].

Checklist

  • CSRF disabled only for actionWebhook.
  • Secret compared with hash_equals.
  • Duplicate update_id values rejected via PK / IntegrityException.
  • HTTP 200 returns quickly; heavy work runs in Yii Queue.
  • Outbound calls use cURL and check ok.

For local debugging without HTTPS, see getUpdates long polling. Console maintenance lives in the maintenance command article.

A ready webhook + queue scaffold is available at botservice.biz.

New articles on Telegram

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