Stop Telegram Webhook Retries in Yii2 by Pushing Updates to an Idempotent Queue

Imagine building a booking system inside Telegram where clients pay for service reservations directly through a Mini App or conversational flow. When a user completes a booking, Telegram hits your endpoint with an update payload. If your server executes external API calls—such as generating a PDF receipt, triggering a CRM sync, or calling an LLM engine—directly inside the webhook controller HTTP lifecycle, execution time easily pushes past three seconds.

Telegram requires a fast response to every webhook POST request. If your server takes too long to respond, or crashes with a HTTP 500 error due to unhandled exceptions, Telegram assumes the message was lost. It re-sends the exact same update_id seconds later, and continues retrying with exponential backoff for up to 24 hours. Without a non-blocking architecture, your Yii2 backend will process the exact same booking three, four, or ten times, firing duplicate notifications, exhausting third-party API quotas, and corrupting order states.

To prevent duplicate update execution, a production-grade Telegram backend in Yii2 must split incoming requests into two separate steps: an immediate synchronous handshake that validates request authenticity and drops duplicates, followed by asynchronous processing handled by background queue workers.

Disabling CSRF and Enforcing Secret Token Validation in Yii2

By default, Yii2 enforces Cross-Site Request Forgery (CSRF) protection on every POST request handled by yii\web\Controller. Because Telegram sends webhooks from its own server infrastructure without a Yii-generated CSRF token in the request headers or body, Yii2 will reject incoming updates with a HTTP 400 or HTTP 405 error right at the action filtering level.

Simply turning off CSRF globally is dangerous because it opens up your web routes to forgery. You must disable CSRF validation *only* on the dedicated webhook controller, and immediately replace it with Telegram's official verification mechanism: the X-Telegram-Bot-Api-Secret-Token header.

When registering your webhook via Telegram's setWebhook API endpoint, you pass a random string in the secret_token field. Telegram includes this exact string in every HTTP POST delivery under the header X-Telegram-Bot-Api-Secret-Token. If a request arrives without this header or with an invalid value, your endpoint must immediately return HTTP 403 Forbidden without spending resources to parse the JSON body.

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\ForbiddenHttpException;
use yii\web\BadRequestHttpException;

class WebhookController extends Controller
{
// Telegram will not send Yii CSRF tokens; turn off built-in CSRF for this controller
public $enableCsrfValidation = false;

/**
* Handles incoming Telegram webhook updates.
*/
public function actionIndex()
{
Yii::$app->response->format = Response::FORMAT_JSON;

$expectedSecret = getenv('TELEGRAM_SECRET_TOKEN');
$incomingSecret = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (empty($expectedSecret) || !hash_equals($expectedSecret, (string)$incomingSecret)) {
Yii::warning('Unauthorized webhook request rejected.', 'telegram');
throw new ForbiddenHttpException('Invalid secret token.');
}

$rawBody = Yii::$app->request->getRawBody();
$payload = json_decode($rawBody, true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($payload['update_id'])) {
throw new BadRequestHttpException('Invalid JSON payload.');
}

return $this->processUpdate($payload);
}

private function processUpdate(array $payload): array
{
// Handshake placeholder: we must return HTTP 200 fast
return ['status' => 'ok'];
}
}

By using hash_equals(), you protect the token comparison against timing attacks. Request headers are extracted via Yii::$app->request->headers, avoiding raw $_SERVER access and maintaining proper framework encapsulation.

Eliminating Duplicates with Redis Idempotency and Yii Queue

Now that the request is proven to come from Telegram, you must handle idempotency. Telegram guarantees *at-least-once delivery*. This means under network degradation or server slowdowns, Telegram can and will re-deliver the same payload containing an identical update_id.

To guarantee idempotency, check whether the incoming update_id has already been received. Using a Redis atomic lock (SET update_id EX 86400 NX) allows you to mark an update as "seen" in constant time ($O(1)$) before doing any further work. If Redis returns false, the update was already received or is currently being processed by a queue worker; you can return HTTP 200 immediately to signal Telegram to stop retrying.

Instead of processing the payload inside the request lifecycle, instantiate a ProcessTelegramUpdateJob object and push it directly to Yii::$app->queue. The HTTP controller then returns {'status': 'ok'} within milliseconds, well below Telegram's timeout limits.

<?php

namespace app\controllers;

use Yii;
use yii\web\Controller;
use yii\web\Response;
use yii\web\ForbiddenHttpException;
use yii\web\BadRequestHttpException;
use app\jobs\ProcessTelegramUpdateJob;

class WebhookController extends Controller
{
public $enableCsrfValidation = false;

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

$expectedSecret = getenv('TELEGRAM_SECRET_TOKEN');
$incomingSecret = Yii::$app->request->headers->get('X-Telegram-Bot-Api-Secret-Token');

if (empty($expectedSecret) || !hash_equals($expectedSecret, (string)$incomingSecret)) {
throw new ForbiddenHttpException('Invalid secret token.');
}

$payload = json_decode(Yii::$app->request->getRawBody(), true);

if (json_last_error() !== JSON_ERROR_NONE || !isset($payload['update_id'])) {
throw new BadRequestHttpException('Invalid payload.');
}

$updateId = (int)$payload['update_id'];
$cacheKey = "tg_update_lock:" . $updateId;

// Atomically set key if it does not exist, expire in 24 hours (86400 seconds)
$isFirstAttempt = Yii::$app->redis->executeCommand('SET', [
$cacheKey,
'processed',
'EX',
86400,
'NX'
]);

if (!$isFirstAttempt) {
// Already accepted in a previous request. Return HTTP 200 to clear Telegram retry buffer.
return ['status' => 'duplicate_ignored'];
}

// Push payload to Yii Queue for asynchronous execution
Yii::$app->queue->push(new ProcessTelegramUpdateJob([
'payload' => $payload,
]));

return ['status' => 'queued'];
}
}

This pattern decouples the ingestion pipeline from execution failure. Even if your external services go down or your background worker fails, Telegram's delivery pipeline receives a HTTP 200 and considers the transmission complete, keeping your webhook queue health score high.

Building the Async Queue Worker Job

The payload pushed to Yii::$app->queue is serialized and saved in your queue broker (e.g., Redis or MySQL). A background CLI process running yii queue/listen picks up the job and invokes its execute() method.

Inside ProcessTelegramUpdateJob, keep business processing clean and guarded. Wrap network logic in try-catch blocks, validate user commands, escape raw HTML input to avoid parse errors when calling parse_mode=HTML, and handle inline button callbacks with answerCallbackQuery to stop Telegram loading spinners.

<?php

namespace app\jobs;

use Yii;
use yii\base\BaseObject;
use yii\queue\JobInterface;

class ProcessTelegramUpdateJob extends BaseObject implements JobInterface
{
/**
* @var array The raw Telegram update payload array
*/
public array $payload;

/**
* @param \yii\queue\Queue $queue
*/
public function execute($queue)
{
if (isset($this->payload['message'])) {
$this->handleMessage($this->payload['message']);
} elseif (isset($this->payload['callback_query'])) {
$this->handleCallbackQuery($this->payload['callback_query']);
}
}

private function handleMessage(array $message): void
{
$chatId = $message['chat']['id'] ?? null;
$text = $message['text'] ?? '';

if (!$chatId) {
return;
}

if (str_starts_with($text, '/start')) {
$safeName = htmlspecialchars($message['from']['first_name'] ?? 'User', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$responseText = "Hello, <b>{$safeName}</b>! Your order system is active.";

$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => $responseText,
'parse_mode' => 'HTML',
]);
}
}

private function handleCallbackQuery(array $callbackQuery): void
{
$callbackId = $callbackQuery['id'];
$chatId = $callbackQuery['message']['chat']['id'] ?? null;

// Immediately acknowledge the callback query so UI spinner stops on user device
$this->sendTelegramRequest('answerCallbackQuery', [
'callback_query_id' => $callbackId,
'text' => 'Processing request...',
]);

if ($chatId) {
$this->sendTelegramRequest('sendMessage', [
'chat_id' => $chatId,
'text' => 'Action registered successfully.',
]);
}
}

private function sendTelegramRequest(string $method, array $params): array
{
$botToken = getenv('TELEGRAM_BOT_TOKEN');
$url = "https://api.telegram.org/bot{$botToken}/{$method}";

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

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

if ($response === false) {
Yii::error("cURL error calling {$method}: {$curlError}", 'telegram');
return ['ok' => false];
}

$result = json_decode($response, true);
if ($httpCode !== 200 || !($result['ok'] ?? false)) {
Yii::error("Telegram API error [{$httpCode}]: " . $response, 'telegram');
}

return $result ?? ['ok' => false];
}
}

Notice how answerCallbackQuery is fired immediately when handling inline buttons. If you omit this call, the client's Telegram application shows a loading bar for up to 60 seconds, creating an impression that the app is non-responsive.

Production Notes and Deployment Checklist

Running a queue-driven Telegram integration in production requires strict operational safeguards:

1. Queue Supervisor: Do not run yii queue/listen inside short-lived cronjobs. Use a process manager like systemd or supervisor to keep background workers alive indefinitely. Ensure automatic worker restarts upon memory leaks or application updates (yii queue/restart). 2. Secret Token Key Rotation: Never hardcode your TELEGRAM_SECRET_TOKEN or TELEGRAM_BOT_TOKEN in config/web.php or config/params.php. Load them via environment variables using getenv(). To rotate secrets safely, update your environment configuration first, register the new secret via setWebhook on Telegram API, and reload worker processes. 3. Queue Driver Selection: While yii2-queue supports db, file, and redis drivers, use redis or amqp for production environments. Database queues introduce transaction lock contention when update volumes burst during marketing campaigns or broadcast pushes. 4. Retry Limits: Configure your queue worker driver with strict retry bounds. If an external API is down, failing jobs should move to a dead-letter queue after 3–5 failed attempts rather than looping infinitely and consuming worker capacity.

When building complex conversational interfaces, payment integrations, or high-throughput bots, maintaining custom server infrastructure requires deep framework configuration and continuous monitoring. If you need assistance scaling your custom Telegram ecosystem or integrating complex backend systems, consider partnering with BotCreator — a studio that ships high-performance Telegram bots and Mini Apps for modern production environments. For technical specifications on Telegram's underlying protocol requirements, consult their developer documentation at

New articles on Telegram

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