When a visitor submits a form, the lead should reach a sales manager within seconds. Email is slow; a CRM often needs manual triage. A site → PHP → Telegram flow with an inline "Take lead" button is faster: the manager opens the chat and claims the lead in one tap.
Architecture
The HTML form POSTs to your endpoint. PHP generates a short lead_id, inserts a row into leads, and calls sendMessage with an inline_keyboard into the managers chat. On tap, the bot receives a callback_query and updates status idempotently.
Telegram limit: callback_data must be at most 64 bytes. Put a compact id in the button, not a JSON blob with name and phone.
leads table
CREATE TABLE leads (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
lead_id CHAR(14) NOT NULL,
name VARCHAR(120) NOT NULL,
phone VARCHAR(32) NOT NULL,
email VARCHAR(190) NULL,
message TEXT NULL,
status ENUM('new','taken','closed') NOT NULL DEFAULT 'new',
taken_by BIGINT NULL,
taken_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_lead_id (lead_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Generating lead_id
bin2hex(random_bytes(7)) yields 14 hex characters: unpredictable and small enough for take: + id inside the 64-byte cap.
<?php
declare(strict_types=1);
function newLeadId(): string
{
return bin2hex(random_bytes(7)); // 14 chars, e.g. a3f8b2c1d9e04f
}
// callback_data = "take:" + lead_id → 5 + 14 = 19 bytes (< 64)
function takeCallbackData(string $leadId): string
{
$data = 'take:' . $leadId;
if (strlen($data) > 64) {
throw new InvalidArgumentException('callback_data too long');
}
return $data;
}
Form handler and sendMessage
<?php
declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
$token = getenv('TG_BOT_TOKEN') ?: '';
$managerChat = getenv('TG_MANAGER_CHAT_ID') ?: '';
if ($token === '' || $managerChat === '') {
http_response_code(500);
echo json_encode(['ok' => false, 'error' => 'bot not configured']);
exit;
}
$input = json_decode(file_get_contents('php://input') ?: '{}', true) ?: $_POST;
$name = trim((string) ($input['name'] ?? ''));
$phone = trim((string) ($input['phone'] ?? ''));
$email = trim((string) ($input['email'] ?? ''));
$msg = trim((string) ($input['message'] ?? ''));
if ($name === '' || $phone === '') {
http_response_code(422);
echo json_encode(['ok' => false, 'error' => 'name and phone required']);
exit;
}
$pdo = new PDO(
getenv('DSN'),
getenv('DB_USER'),
getenv('DB_PASS'),
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
$leadId = newLeadId();
$pdo->prepare(
'INSERT INTO leads (lead_id, name, phone, email, message)
VALUES (:lead_id, :name, :phone, :email, :message)'
)->execute([
':lead_id' => $leadId,
':name' => $name,
':phone' => $phone,
':email' => $email !== '' ? $email : null,
':message' => $msg !== '' ? $msg : null,
]);
$text = "<b>New lead</b>\n"
. 'ID: <code>' . htmlspecialchars($leadId) . "</code>\n"
. 'Name: ' . htmlspecialchars($name) . "\n"
. 'Phone: ' . htmlspecialchars($phone);
$keyboard = [
'inline_keyboard' => [[
[
'text' => 'Take lead',
'callback_data' => takeCallbackData($leadId),
],
]],
];
$ch = curl_init("https://api.telegram.org/bot{$token}/sendMessage");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => [
'chat_id' => $managerChat,
'text' => $text,
'parse_mode' => 'HTML',
'reply_markup' => json_encode($keyboard, JSON_UNESCAPED_UNICODE),
],
]);
$body = curl_exec($ch);
curl_close($ch);
$decoded = json_decode((string) $body, true);
if (empty($decoded['ok'])) {
http_response_code(502);
echo json_encode(['ok' => false, 'error' => 'telegram send failed']);
exit;
}
echo json_encode(['ok' => true, 'lead_id' => $leadId]);
callback_query: idempotent UPDATE
Two managers may tap at once. An atomic UPDATE … WHERE status='new' makes only the first click win.
<?php
declare(strict_types=1);
function handleCallbackQuery(PDO $pdo, string $token, array $cq): void
{
$data = (string) ($cq['data'] ?? '');
$fromId = (int) ($cq['from']['id'] ?? 0);
$cqId = (string) ($cq['id'] ?? '');
if (!preg_match('/^take:([a-f0-9]{14})$/', $data, $m)) {
answerCallback($token, $cqId, 'Unknown button');
return;
}
$leadId = $m[1];
$stmt = $pdo->prepare(
"UPDATE leads
SET status = 'taken', taken_by = :uid, taken_at = NOW()
WHERE lead_id = :lead_id AND status = 'new'"
);
$stmt->execute([':uid' => $fromId, ':lead_id' => $leadId]);
if ($stmt->rowCount() === 0) {
answerCallback($token, $cqId, 'Lead already taken or not found');
return;
}
$lead = $pdo->prepare('SELECT * FROM leads WHERE lead_id = ?');
$lead->execute([$leadId]);
$row = $lead->fetch(PDO::FETCH_ASSOC);
answerCallback($token, $cqId, 'Lead assigned to you');
$detail = "Lead <code>{$leadId}</code>\n"
. "Name: {$row['name']}\nPhone: {$row['phone']}\n"
. ($row['email'] ? "Email: {$row['email']}\n" : '')
. ($row['message'] ? "Message: {$row['message']}" : '');
tgSend($token, [
'chat_id' => $fromId,
'text' => $detail,
'parse_mode' => 'HTML',
]);
}
function answerCallback(string $token, string $callbackQueryId, string $text): void
{
tgSend($token, [
'callback_query_id' => $callbackQueryId,
'text' => $text,
'show_alert' => false,
], 'answerCallbackQuery');
}
function tgSend(string $token, array $params, string $method = 'sendMessage'): void
{
$ch = curl_init("https://api.telegram.org/bot{$token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => $params,
]);
curl_exec($ch);
curl_close($ch);
}
Why this beats email routing
- Managers see the lead in a familiar chat within 1–2 seconds.
- Double-taps are safe thanks to the SQL status guard.
- Customer data lives in MySQL, not inside the 64-byte button payload.
Related: inline keyboards in PHP and Yii2 webhook controller.
Ship this funnel faster with a ready scaffold at botservice.biz.