Prevent Unassigned Web Leads: Process Telegram Manager Claim Keyboards in PHP

When building website lead collection forms—such as booking forms, request-for-quote pages, or high-touch sales inquiries—developers often route entries directly to a Telegram group or channel where sales managers can react. However, a naïve implementation that directly forwards form payloads to Telegram without persisting state first creates critical operational flaws. First, if the Telegram Bot API suffers transient downtime or returns a rate-limit error, the customer lead is permanently lost. Second, when multiple managers view the same alert in a shared group chat, two representatives frequently attempt to process the exact same submission simultaneously. Without a structured backend transaction and an inline claim mechanism, customer experience suffers due to duplicate outreach.

To resolve this, you must structure the lead intake pipeline into two distinct phases: durable record creation followed by transactional callback handling. In this guide, we will implement a robust PHP pipeline that receives website form data, generates a cryptographically secure 14-character lead ID using bin2hex(random_bytes(7)), persists the record into MySQL before touching the network, and dispatches a formatted alert containing an interactive Telegram inline keyboard. Then, we will build a secure webhook script that processes manager button presses atomically using SQL conditional updates, instantly answers Telegram callback queries, and modifies the chat UI to visually lock the lead.

Step 1: Generating Lead IDs and Persisting Form Submissions

Before contacting the Telegram API, the submission must live safely in your local database. Relying on auto-incrementing integer IDs (such as 12345) inside public or semi-public callback buttons exposes your system to sequence-guessing vectors. Instead, we use bin2hex(random_bytes(7)) to generate a 14-character hex identifier. This string is short enough to respect Telegram's strict 64-byte callback_data ceiling while providing 56 bits of entropy—more than enough to eliminate collision risks across millions of leads.

Let us detail why Telegram's 64-byte limit matters. The callback_data field attached to an InlineKeyboardButton holds metadata passed back to your webhook when clicked. If your string exceeds 64 UTF-8 bytes, Telegram rejects the API request outright with a 400 Bad Request: BUTTON_DATA_INVALID response. Prefixing our action code as take: followed by a 14-character hex string yields take:a1b2c3d4e5f678, consuming only 19 bytes. This leaves abundant room for future action flags or routing codes without breaking Telegram's payload boundary.

Here is the primary database table definition and submission endpoint (submit_lead.php). Notice how we sanitize user inputs using htmlspecialchars() before constructing HTML payloads for Telegram. Passing raw user input into a Telegram message formatted with parse_mode=HTML will crash your API call whenever a customer includes characters like <, >, or & in their message or name.

<?php
// schema.sql
/*
CREATE TABLE leads (
id VARCHAR(14) PRIMARY KEY,
customer_name VARCHAR(100) NOT NULL,
customer_phone VARCHAR(30) NOT NULL,
message TEXT,
status ENUM('pending', 'claimed') DEFAULT 'pending',
manager_id BIGINT NULL,
manager_name VARCHAR(100) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
*/

// submit_lead.php
declare(strict_types=1);

$botToken = getenv('TELEGRAM_BOT_TOKEN');
$chatId = getenv('TELEGRAM_MANAGER_CHAT_ID');

if (!$botToken || !$chatId) {
http_response_code(500);
echo json_encode(['error' => 'Server misconfiguration: Missing environment variables']);
exit;
}

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}

// Extract and validate incoming form data
$rawName = trim($_POST['name'] ?? '');
$rawPhone = trim($_POST['phone'] ?? '');
$rawMessage = trim($_POST['message'] ?? '');

if (empty($rawName) || empty($rawPhone)) {
http_response_code(400);
echo json_encode(['error' => 'Name and phone are required fields']);
exit;
}

// Connect to database
$pdo = new PDO(
getenv('DB_DSN'),
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

// Generate unique 14-char hex lead identifier (7 bytes = 14 hex chars)
$leadId = bin2hex(random_bytes(7));

// 1. Durably store the lead before making external API network calls
$stmt = $pdo->prepare(
'INSERT INTO leads (id, customer_name, customer_phone, message, status) VALUES (:id, :name, :phone, :msg, "pending")'
);
$stmt->execute([
':id' => $leadId,
':name' => $rawName,
':phone' => $rawPhone,
':msg' => $rawMessage
]);

// 2. Prepare sanitized text for Telegram HTML parsing
$safeName = htmlspecialchars($rawName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safePhone = htmlspecialchars($rawPhone, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$safeMessage = htmlspecialchars($rawMessage, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

$text = "<b>📥 New Website Lead</b>\n\n"
. "<b>ID:</b> <code>{$leadId}</code>\n"
. "<b>Name:</b> {$safeName}\n"
. "<b>Phone:</b> {$safePhone}\n";

if (!empty($safeMessage)) {
$text .= "<b>Message:</b> {$safeMessage}\n";
}

// Build the inline keyboard with 'take:ID' callback data (19 bytes total)
$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'reply_markup' => [
'inline_keyboard' => [
[
[
'text' => '✋ Claim Lead',
'callback_data' => 'take:' . $leadId
]
]
]
]
];

// 3. Dispatch payload to Telegram using cURL with explicit error checks
$ch = curl_init("https://api.telegram.org/bot{$botToken}/sendMessage");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 5
]);

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

if ($curlErr !== '') {
error_log("Telegram API cURL Error: " . $curlErr);
http_response_code(500);
echo json_encode(['error' => 'Lead saved, but notification dispatch failed']);
exit;
}

$responseData = json_decode((string)$response, true);
if ($httpCode !== 200 || !isset($responseData['ok']) || $responseData['ok'] !== true) {
error_log("Telegram API Error Response: " . $response);
http_response_code(500);
echo json_encode(['error' => 'Failed to publish lead to manager channel']);
exit;
}

echo json_encode(['status' => 'success', 'lead_id' => $leadId]);

Step 2: Atomic State Processing and Preventing Double-Claims

When Telegram alerts arrive in a shared manager chat, multiple sales reps may tap the "✋ Claim Lead" button simultaneously. If your webhook implementation reads the database record, checks status === 'pending', and then performs an update in separate steps, you introduce a race condition. Under concurrent requests, two distinct HTTP calls can read pending at the same time and both assign the lead to different representatives.

To eliminate this, the update query itself must act as the synchronization boundary. We execute an atomic SQL update statement: UPDATE leads SET status = 'claimed', manager_id = :mid, manager_name = :mname WHERE id = :id AND status = 'pending'.

If two webhook executions occur simultaneously for the same lead_id, MySQL locks the row for the first transaction. The first query updates the row and returns an affected row count of 1. The second query waits for the lock, runs against the newly updated state where status is now 'claimed', and returns an affected row count of 0.

Another crucial rule when handling inline button clicks is invoking answerCallbackQuery. When a manager taps an inline button, the Telegram client displays a spinning loading indicator on the button. If your webhook script fails to call answerCallbackQuery within a few seconds—or crashes due to an unhandled exception—the user interface hangs until it times out. Furthermore, answerCallbackQuery allows you to display a native toast message or alert modal directly inside the manager's Telegram client explaining whether they successfully secured the lead or lost it to a colleague.

Here is the complete webhook handler (webhook.php):

<?php
// webhook.php
declare(strict_types=1);

$botToken = getenv('TELEGRAM_BOT_TOKEN');
$secretToken = getenv('TELEGRAM_WEBHOOK_SECRET');

if (!$botToken || !$secretToken) {
http_response_code(500);
exit('Server configuration error');
}

// Validate Telegram Webhook Secret Token header
$incomingSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
if (!hash_equals($secretToken, $incomingSecret)) {
http_response_code(403);
exit('Unauthorized request');
}

$rawInput = file_get_contents('php://input');
$update = json_decode((string)$rawInput, true);

if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
exit('Invalid JSON payload');
}

// We only process callback_query updates in this handler
if (!isset($update['callback_query'])) {
http_response_code(200);
echo 'OK';
exit;
}

$callback = $update['callback_query'];
$callbackId = $callback['id'];
$data = $callback['data'] ?? '';
$from = $callback['from'];
$managerId = $from['id'];
$managerFirstName = $from['first_name'] ?? 'Manager';
$message = $callback['message'] ?? null;

// Verify callback format starts with 'take:'
if (strpos($data, 'take:') !== 0) {
answerCallback($botToken, $callbackId, 'Unknown command syntax', true);
http_response_code(200);
exit;
}

$leadId = substr($data, 5);

// Validate that lead ID matches expected hex format (14 hex chars)
if (!ctype_xdigit($leadId) || strlen($leadId) !== 14) {
answerCallback($botToken, $callbackId, 'Invalid lead identifier', true);
http_response_code(200);
exit;
}

$pdo = new PDO(
getenv('DB_DSN'),
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

// Atomic UPDATE execution to guarantee single ownership
$stmt = $pdo->prepare('
UPDATE leads
SET status = "claimed", manager_id = :mid, manager_name = :mname
WHERE id = :id AND status = "pending"
');

$stmt->execute([
':mid' => $managerId,
':mname' => $managerFirstName,
':id' => $leadId
]);

$wasClaimedByMe = ($stmt->rowCount() === 1);

if ($wasClaimedByMe) {
// Successful claim path
answerCallback($botToken, $callbackId, '✅ You successfully claimed this lead!');

// Update chat message to remove action button and show claim info
if ($message && isset($message['chat']['id'], $message['message_id'])) {
$chatId = $message['chat']['id'];
$msgId = $message['message_id'];
$originalText = $message['text'] ?? '';

$safeManager = htmlspecialchars($managerFirstName, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$updatedText = htmlspecialchars($originalText, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
. "\n\n<b>📌 Status:</b> Claimed by {$safeManager}";

editTelegramMessage($botToken, $chatId, $msgId, $updatedText);
}
} else {
// Lead was already claimed by another rep or doesn't exist
$stmtCheck = $pdo->prepare('SELECT manager_name FROM leads WHERE id = :id');
$stmtCheck->execute([':id' => $leadId]);
$existing = $stmtCheck->fetch(PDO::FETCH_ASSOC);

$claimedBy = $existing['manager_name'] ?? 'another manager';
$safeClaimedBy = htmlspecialchars($claimedBy, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

answerCallback($botToken, $callbackId, "⚠️ Lead already claimed by {$safeClaimedBy}", true);
}

http_response_code(200);
echo 'OK';

function answerCallback(string $token, string $callbackId, string $text, bool $showAlert = false): void {
$ch = curl_init("https://api.telegram.org/bot{$token}/answerCallbackQuery");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'callback_query_id' => $callbackId,
'text' => $text,
'show_alert' => $showAlert
]),
CURLOPT_TIMEOUT => 3
]);
curl_exec($ch);
curl_close($ch);
}

function editTelegramMessage(string $token, $chatId, int $messageId, string $newText): void {
$ch = curl_init("https://api.telegram.org/bot{$token}/editMessageText");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode([
'chat_id' => $chatId,
'message_id' => $messageId,
'text' => $newText,
'parse_mode' => 'HTML',
'reply_markup' => ['inline_keyboard' => []]
]),
CURLOPT_TIMEOUT => 3
]);
curl_exec($ch);
curl_close($ch);
}

Step 3: Production Security and Telegram API Failure Cases

Deploying lead routing solutions into active production requires handling operational edge cases beyond simple happy paths. Here are the essential requirements for keeping this integration reliable:

### 1. Telegram 64-Byte Callback Limit Budget Telegram's Bot API silently drops or rejects requests with callback_data larger than 64 bytes. When designing complex callback actions, calculate your string length precisely: - Action prefix (take:): 5 bytes - Unique ID (bin2hex(random_bytes(7))): 14 bytes - Total consumed: 19 bytes (well below the 64-byte threshold).

If you attempt to stuff raw JSON like {"action":"claim","id":"123456789"} into callback_data, you consume 32+ bytes immediately. Adding additional query attributes will push you over the 64-byte boundary, triggering HTTP 400 errors.

### 2. Secret Token Authentication Never accept raw webhook POST requests without verifying source authenticity. Attackers who discover your webhook.php public URL can send fake callback_query payloads to mark leads as claimed or alter database states. Always configure a strong secret token during webhook registration:

curl -X POST "https://api.telegram.org/bot<YOUR_BOT_TOKEN>/setWebhook" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourdomain.com/webhook.php", "secret_token": "YOUR_HIGH_ENTROPY_SECRET_HERE"}'

Your PHP code validates this token via $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] using constant-time string comparison (hash_equals()) before executing any database logic.

### 3. Immediate HTTP 200 Responses Telegram expects your webhook endpoint to return a 200 OK status code within 5 seconds. If your database query or downstream logic hangs, Telegram marks the attempt as failed and retries sending the same update multiple times. This can cause duplicate updates to flood your server. Ensure cURL timeout values on outward calls (such as answerCallbackQuery and editMessageText) are capped at 3 seconds, and always return HTTP 200 to Telegram even when handling business-logic failures (such as a lead already being claimed).

---

If you need tailored messaging infrastructure or full-stack Telegram integration, reach out to BotCreator — studio that ships Telegram bots / Mini Apps.

New articles on Telegram

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