Don't trust Laravel's default HTTP client for Telegram webhooks: build a thin wrapper with idempotency

Telegram bots that receive updates via webhook need to be both secure and idempotent. A lead‑generation Mini App, for example, sends a JSON payload with the user’s contact details to your Laravel endpoint. If you accept the request blindly, an attacker can replay the same update or forge a request that looks like it came from Telegram, creating duplicate leads or injecting bogus data. Laravel’s built‑in HTTP facade hides the raw response from the Bot API, making it easy to miss a failed ok:false reply and silently drop messages.

This guide walks through a complete, copy‑pasteable implementation: registering a webhook with a secret token, verifying the request, storing the update_id to guarantee exactly‑once processing, and sending outgoing messages through a minimal HTTP client that checks Telegram’s ok field. Each section first shows what breaks when you skip a step, then provides the fix.

1. Register the webhook and protect it with a secret token

If you register a webhook without the secret_token parameter, Telegram will still POST updates to your URL, but anyone who knows the endpoint can send arbitrary JSON. Your controller would treat that payload as a legitimate Update, potentially triggering business logic with fake data.

Failing path – routes/webhook.php without secret verification:

// routes/webhook.php
use Illuminate\Support\Facades\Route;

Route::post('/telegram/webhook', function (\Illuminate\Http\Request $request) {
// No verification – assumes request came from Telegram
$update = $request->json()->all();
// …process $update
return response('', 204);
});

An attacker could POST {"update_id":999999999,"message":{"chat":{"id":123},"text":"/start"}} and your Laravel app would act on it.

Fix – create a middleware that checks the X-Telegram-Bot-Api-Secret-Token header against the value you gave Telegram when calling setWebhook.

First, store the secret in .env:

TELEGRAM_BOT_TOKEN=123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11
TELEGRAM_WEBHOOK_SECRET=super-secret-webhook-token

Then publish a config file (optional) or read directly from env.

Create the middleware:

php artisan make:middleware VerifyTelegramSecret
// app/Http/Middleware/VerifyTelegramSecret.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\BadRequestException;

class VerifyTelegramSecret
{
public function handle(Request $request, Closure $next)
{
$signature = $request->header('X-Telegram-Bot-Api-Secret-Token');
$expected = config('telegram.webhook_secret');

if ($signature !== $expected) {
throw new BadRequestException('Invalid Telegram secret token');
}

return $next($request);
}
}

Register it in app/Http/Kernel.php:

protected $routeMiddleware = [
// …
'telegram.secret' => \App\Http\Middleware\VerifyTelegramSecret::class,
];

Finally, protect the route:

// routes/webhook.php
Route::post('/telegram/webhook', function (\Illuminate\Http\Request $request) {
$update = $request->json()->all();
// …process $update
return response('', 204);
})->middleware('telegram.secret');

When you call setWebhook from the Bot API, include the secret:

curl -F "url=https://example.com/telegram/webhook" \
-F "secret_token=$TELEGRAM_WEBHOOK_SECRET" \
"https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook"

Now any request lacking the correct header is rejected with 400 before it reaches your controller.

2. Guarantee idempotency by storing processed update_id

Telegram may resend the same update if your webhook returns anything other than 200‑299, or if there is a temporary network glitch. Without deduplication, your job would handle the same payload multiple times, creating duplicate leads or sending the same confirmation message twice.

Failing path – a job that processes the update without checking whether it has already been seen:

// app/Jobs/ProcessTelegramUpdate.php
namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;

class ProcessTelegramUpdate implements ShouldQueue
{
use Dispatchable, Queueable;

public $update;

public function __construct(array $update)
{
$this->update = $update;
}

public function handle()
{
// Assume $this->update['message'] contains a lead
$lead = $this->update['message'];
// …store lead in DB, send confirmation, etc.
// No check – same update_id can be processed again
}
}

If Telegram retries the webhook three times, you end up with three identical rows in your leads table.

Fix – persist every processed update_id in a fast store (Redis or a dedicated DB table) and skip the job if the id already exists. The check must happen *before* dispatching the job to avoid queue overhead.

Create a simple table:

CREATE TABLE processed_updates (
update_id BIGINT PRIMARY KEY,
processed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

Add a repository method:

// app/Services/UpdateStore.php
namespace App\Services;

use Illuminate\Support\Facades\DB;

class UpdateStore
{
public function exists(int $updateId): bool
{
return DB::table('processed_updates')->where('update_id', $updateId)->exists();
}

public function markAsProcessed(int $updateId): void
{
DB::table('processed_updates')->insertOrIgnore([
'update_id' => $updateId,
]);
}
}

Update the controller to use the store:

// routes/webhook.php
use App\Services\UpdateStore;
use App\Jobs\ProcessTelegramUpdate;

Route::post('/telegram/webhook', function (\Illuminate\Http\Request $request, UpdateStore $store) {
$payload = $request->json()->all();
$updateId = $payload['update_id'] ?? null;

if ($updateId === null) {
return response('', 400);
}

if ($store->exists($updateId)) {
// Already handled – acknowledge to stop retries
return response('', 204);
}

// Mark as processed *before* dispatching to avoid race conditions
$store->markAsProcessed($updateId);

ProcessTelegramUpdate::dispatch($payload);

return response('', 204);
})->middleware('telegram.secret');

Now even if Telegram retries, the second and third attempts hit the exists check and return 204 immediately, leaving your job to run exactly once.

3. Send outgoing messages via a thin HTTP client that validates Telegram’s response

Laravel’s Http facade is convenient, but it swallows the ok field and only throws on HTTP errors (non‑2xx). Telegram returns HTTP 200 even when the Bot API signals failure (ok:false, error_code, description). If you rely solely on the facade, you might think a message was sent while Telegram actually rejected it because of an invalid chat_id or missing parse mode, leading to silent data loss.

Failing path – using the facade directly:

// app/Services/TelegramClient.php (naive)
namespace App\Services;

use Illuminate\Support\Facades\Http;

class TelegramClient
{
protected string $token;

public function __construct()
{
$this->token = config('telegram.bot_token');
}

public function sendMessage(int $chatId, string $text): void
{
$response = Http::post(
"https://api.telegram.org/bot{$this->token}/sendMessage",
['chat_id' => $chatId, 'text' => $text]
);

// No check of $response->json()['ok']
// Errors are ignored
}
}

If $chatId is wrong, Telegram replies with {"ok":false,"error_code":400,"description":"Bad Request: chat not found"} but your code continues as if everything succeeded.

Fix – wrap the raw request, decode JSON, verify ok, and throw a domain‑specific exception on failure. Keep the client dependency‑free so you can swap it for a mock in tests.

// app/Services/TelegramClient.php
namespace App\Services;

use RuntimeException;

class TelegramClient
{
protected string $token;
protected string $apiUrl;

public function __construct()
{
$this->token = config('telegram.bot_token');
$this->apiUrl = "https://api.telegram.org/bot{$this->token}";
}

/**
* @throws RuntimeException when Telegram returns ok:false
*/
public function request(string $method, array $params = []): array
{
$url = $this->apiUrl . '/' . $method;

// Use Laravel's Http factory but inspect the raw response
$response = Http::asForm()->post($url, $params);

$status = $response->status();
if ($status < 200 || $status >= 300) {
throw new RuntimeException("HTTP {$status} from Telegram API");
}

$data = $response->json();
if (json_last_error() !== JSON_ERROR_NONE) {
throw new RuntimeException('Invalid JSON from Telegram');
}

if (!isset($data['ok']) || !$data['ok']) {
$errorCode = $data['error_code'] ?? 'unknown';
$description = $data['description'] ?? 'No description';
throw new RuntimeException("Telegram API error {$errorCode}: {$description}");
}

return $data['result'] ?? [];
}

public function sendMessage(int $chatId, string $text): void
{
$this->request('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML', // example
]);
}
}

Now any API error bubbles up as an exception, which you can catch in your job and decide whether to retry, dead‑letter, or alert an operator.

4. Queue the job and handle failures gracefully

Processing a lead may involve external APIs (CRM, email service) that are slower than the webhook timeout. By dispatching ProcessTelegramUpdate to a queue, you acknowledge the webhook quickly (204) and let the worker run the business logic.

If the job fails, Laravel will retry it according to your queue configuration. Because we already marked the update_id as processed, a retry would otherwise skip the work entirely. To allow retries while still preventing duplicate side‑effects, we store a *processing* flag rather than a final flag, or we make the job itself idempotent (e.g., using a unique constraint on the leads table).

A simple approach: add a processed_at column that is set only after the job succeeds. The controller marks the update as *seen* (to stop Telegram retries) but leaves processed_at NULL. The job, upon successful completion, sets the timestamp. If the job fails, the row remains with processed_at NULL, and a later retry will find the row and attempt again.

Adjust the store:

// app/Services/UpdateStore.php
public function markAsSeen(int $updateId): void
{
DB::table('processed_updates')->updateOrInsert([
'update_id' => $updateId,
], ['processed_at' => null]);
}

public function markAsSuccessful(int $updateId): void
{
DB::table('processed_updates')
->where('update_id', $updateId)
->update(['processed_at' => now()]);
}

public function needsProcessing(int $updateId): bool
{
$row = DB::table('processed_updates')
->where('update_id', $updateId)
->first();

return !$row || !$row->processed_at;
}

Controller:

Route::post('/telegram/webhook', function (\Illuminate\Http\Request $request, UpdateStore $store) {
$payload = $request->json()->all();
$updateId = $payload['update_id'] ?? null;

if ($updateId === null || !$store->needsProcessing($updateId)) {
return response('', 204);
}

$store->markAsSeen($updateId);
ProcessTelegramUpdate::dispatch($payload);

return response('', 204);
})->middleware('telegram.secret');

Job:

// app/Jobs/ProcessTelegramUpdate.php
public function handle(UpdateStore $store, TelegramClient $telegram)
{
$update = $this->update;
$message = $update['message'] ?? null;
if (!$message || !isset($message['chat']['id'])) {
return; // nothing to do
}

$chatId = $message['chat']['id'];
$text = $message['text'] ?? '';

// Example: store a lead
\App\Models\Lead::create([
'telegram_id' => $chatId,
'payload' => json_encode($update),
'received_at' => now(),
]);

// Send confirmation
$telegram->sendMessage($chatId, "<b>Thanks!</b> We’ve received your request.");

// Mark update as successfully processed
$store->markAsSuccessful($update['update_id']);
}

With this pattern: - Telegram’s retries are stopped early by the needsProcessing check. - If the worker crashes after storing the lead but before sending the confirmation, the row still lacks processed_at, so the next retry will attempt the confirmation again. - Duplicate leads are prevented by a unique index on telegram_id + a hash of the payload, or by relying on the processed_at guard.

Production notes

* Expiry and replay attacks – Telegram includes an auth_date field only in Login Widget and Mini App initData; webhook updates do not. Nevertheless, always reject updates that are older than a few minutes if you ever use start payloads for deep links, because a malicious actor could replay an old deep‑link to trigger stale state. - MainButton timing – If you later build a Mini App that shows a MainButton, never enable it before receiving the WebApp.ready event from the Telegram WebApp SDK. Doing so causes the button to be invisible or to trigger errors in older clients. - Idempotency storage – Choose a store with low latency and atomic operations (Redis SET NX EX or a DB table with a primary key). Avoid in‑memory arrays; they disappear on worker restart and break the guarantee. - Monitoring – Log every thrown exception from TelegramClient and set up an alert on a rising rate of ok:false responses. This catches mis‑configured chat_ids or token issues early.

By combining a secret‑token‑protected route, an idempotency layer that persists update_id, and a thin HTTP client that validates Telegram’s ok field, you obtain a webhook handler that is both secure against spoofing and resilient to network glitches. The lead‑generation Mini App can now safely forward user data to your Laravel backend, confident that each update is processed exactly once and that any failure to contact the Bot API is surfaced rather than silently ignored.

BotCreator

Optional further reading:

New articles on Telegram

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