What we build and what we do not claim
A static HTML form on your site posts to a small PHP handler. The handler validates the input, generates a lead ID, persists the lead, and calls the Telegram Bot API sendMessage to a manager's chat. The manager sees the lead instantly and can press a button to "take" it.
This tutorial is honest about its limits:
- It is a forwarding pattern, not a CRM. If you need multi-pipeline deal tracking, use a real CRM and treat Telegram as the notification + hand-off channel.
- A weak phone regex is a sanity check, not a real validator. If phone format is business-critical, use a library such as
libphonenumber(or a server-side validator) and normalize before storage. - The core handler is single-process. If you run multiple PHP-FPM workers behind a load balancer, the optional webhook section explains how to avoid double-processing.
If you only need "form → manager chat", the core path below is enough. Buttons and the webhook are labeled optional.
Core: HTML form → PHP → Telegram
1. The form
Keep it short. Name, phone, and a free-text field are usually enough; add UTM tags if your analytics needs them.
<form action="/leads.php" method="post">
<input name="name" required maxlength="100">
<input name="phone" required maxlength="32">
<textarea name="message" maxlength="1000"></textarea>
<input type="hidden" name="page" value="/pricing">
<button type="submit">Send</button>
</form>
2. The PHP handler
Two environment variables, both read via getenv so they are not committed to the repo:
-
TELEGRAM_BOT_TOKEN— the bot you created with@BotFather. -
TELEGRAM_MANAGER_CHAT_ID— the numeric chat ID of the manager (or a group where the bot has been added).
The handler validates input, generates a 14-character lead ID, persists the lead to MySQL before calling Telegram (so a webhook callback can look it up later), and only then sends the message. If Telegram fails, the lead is still saved and the manager can be notified by other means.
<?php
// leads.php — receives form posts and forwards them to a Telegram manager chat.
declare(strict_types=1);
function tg_send(string $method, array $params): array {
$token = getenv('TELEGRAM_BOT_TOKEN');
if (!$token) {
return ['ok' => false, 'description' => 'TELEGRAM_BOT_TOKEN not set'];
}
$ch = curl_init("https://api.telegram.org/bot{$token}/{$method}");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $params,
CURLOPT_TIMEOUT => 10,
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($raw === false || $code !== 200) {
return ['ok' => false, 'description' => "HTTP {$code}", 'raw' => $raw];
}
$decoded = json_decode((string) $raw, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($decoded)) {
return ['ok' => false, 'description' => 'Invalid JSON from Telegram'];
}
return $decoded;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
$name = trim((string) ($_POST['name'] ?? ''));
$phone = trim((string) ($_POST['phone'] ?? ''));
$message = trim((string) ($_POST['message'] ?? ''));
$page = trim((string) ($_POST['page'] ?? ''));
if ($name === '' || $phone === '') {
http_response_code(422);
exit('Name and phone are required.');
}
// Sanity check only. Replace with a real validator if format matters.
if (!preg_match('/^[+0-9 ()\-]{6,32}$/', $phone)) {
http_response_code(422);
exit('Phone looks invalid.');
}
// 14 hex chars, plenty of headroom for a website lead table.
$leadId = bin2hex(random_bytes(7));
// Persist BEFORE sendMessage. The lead ID is what the Take-lead callback will look up.
$pdo = new PDO('mysql:host=127.0.0.1;dbname=site;charset=utf8mb4', getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $pdo->prepare(
'INSERT INTO leads (id, name, phone, message, page, status, created_at)
VALUES (?, ?, ?, ?, ?, "new", NOW())'
);
$stmt->execute([$leadId, $name, $phone, $message, $page]);
$chatId = getenv('TELEGRAM_MANAGER_CHAT_ID');
$text = "<b>New lead</b> #{$leadId}\n"
. "Name: " . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n"
. "Phone: " . htmlspecialchars($phone, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n"
. "Page: " . htmlspecialchars($page, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . "\n"
. "Message: " . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$replyMarkup = json_encode([
'inline_keyboard' => [
[
// callback_data has a hard 64-byte limit. "take:" + 14 hex = 19 bytes, fine.
['text' => 'Take lead', 'callback_data' => "take:{$leadId}"],
['text' => 'Reject', 'callback_data' => "drop:{$leadId}"],
],
],
]);
$resp = tg_send('sendMessage', [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML',
'reply_markup' => $replyMarkup,
]);
if (!($resp['ok'] ?? false)) {
error_log('Telegram sendMessage failed: ' . ($resp['description'] ?? 'unknown'));
// Lead is already saved, so the manager can be retried out of band.
}
header('Location: /thank-you.html');
exit;
A few things worth calling out:
-
htmlspecialcharsruns beforeparse_mode=HTML. Telegram interprets<b>,<i>,<a>, and friends literally; an unescaped<in user input can break your markup or, worse, inject a link. - Telegram replies with
{"ok": true, "result": ...}on success and{"ok": false, "description": "..."}on failure. Always branch onok, not on HTTP 200 alone — Telegram returns 200 for almost everything, including rejected requests. - The lead row is inserted before
sendMessageis called and before$leadIdis used incallback_data. This is the order that matters: if you reversed it, a fast callback from a manager could arrive before the row exists. - A weak regex is a sanity check. If your business rules require a strict format, plug a real validator in at the
preg_matchline.
Optional: the Take-lead callback
When the manager taps Take lead, Telegram POSTs an update to your webhook (next section). The handler below answers the alert and updates the DB. The take: payload is short on purpose — callback_data is capped at 64 bytes.
<?php
// webhook.php — receives callback_query updates from Telegram.
declare(strict_types=1);
$body = file_get_contents('php://input');
$update = json_decode($body, true);
if (json_last_error() !== JSON_ERROR_NONE || !is_array($update)) {
http_response_code(400);
exit('bad json');
}
// Idempotency: Telegram may redeliver. Skip already-processed update_id.
$updateId = (int) ($update['update_id'] ?? 0);
if ($updateId > 0) {
$pdo = new PDO('mysql:host=127.0.0.1;dbname=site;charset=utf8mb4', getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
try {
$pdo->prepare('INSERT INTO processed_updates (update_id) VALUES (?)')->execute([$updateId]);
} catch (PDOException $e) {
// Duplicate key — already handled. Just acknowledge.
echo 'OK';
exit;
}
}
$cb = $update['callback_query'] ?? null;
if (!$cb) { echo 'OK'; exit; }
$token = getenv('TELEGRAM_BOT_TOKEN');
$cbId = $cb['id'];
$data = (string) ($cb['data'] ?? '');
// Always answer the callback so the button stops showing the loading spinner.
$ch = curl_init("https://api.telegram.org/bot{$token}/answerCallbackQuery");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => ['callback_query_id' => $cbId, 'text' => 'Marked as taken'],
]);
curl_exec($ch);
curl_close($ch);
if (str_starts_with($data, 'take:')) {
$leadId = substr($data, 5);
$pdo->prepare('UPDATE leads SET status = "taken", taken_at = NOW() WHERE id = ? AND status = "new"')
->execute([$leadId]);
}
echo 'OK';
Two production details that are easy to get wrong:
-
Idempotency by
update_idonly is not enough when you run more than one PHP-FPM worker. Telegram will redeliver an update if your 200 OK takes too long, and two workers can race on the sameupdate_id. TheINSERT ... processed_updatesabove is the source of truth: the unique key onupdate_idguarantees only one worker proceeds. If you want to avoid MySQL for this, a RedisSETNXwith a TTL works the same way. - The
t.me/Bot?start=lead_IDtrick is sometimes suggested as a "resume the lead chat" shortcut. It only deep-links/startto your bot — the bot still has to map the payload back to a lead, then to a session, then to a chat. Prefer shortcallback_datafor in-chat hand-off and only usestart=when you genuinely want the lead to open a fresh conversation with a known context.
Optional: registering the webhook
A self-signed certificate is fine; Telegram will accept it. Replace the values and run once. Do not paste this into a shell history that gets logged — the secret_token is sensitive. Prefer running it from a script file with chmod 600, or skip the secret and rely on the URL being unguessable.
curl -sS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/setWebhook" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/webhook.php",
"secret_token": "change-me-to-a-long-random-string",
"allowed_updates": ["callback_query", "message"]
}'
Production notes
-
Logging IPs. If you log
$_SERVER['REMOTE_ADDR']for spam analysis, remember that behind Cloudflare or Nginx this is the proxy, not the client. UseCF-Connecting-IP(Cloudflare) or your load balancer's header, and treat those as untrusted user input. Also consider GDPR: raw IPs are personal data in many jurisdictions — hash or truncate before storage if you do not need the full value. - Spam. A bot-friendly form is also a spammer-friendly form. Add a honeypot field, rate-limit by IP, and consider a CAPTCHA on suspicious traffic. None of that replaces monitoring the manager chat for junk.
-
Reliability.
sendMessagecan fail (rate limits, transient 5xx). A simpleflock-based queue or awp cron/systemdretry that re-readsleadswherestatus='new'andtelegram_sent_at IS NULLis enough for most small sites. Email fallback is fine as a last resort — but you started this project because email is exactly the channel that loses leads. -
Secrets.
getenvis the minimum. In production, read from your platform's secret store and rotateTELEGRAM_BOT_TOKENif it ever appears in a log.
Verifying it works
- Submit the form on staging. You should see the lead in MySQL and a message in the manager's Telegram.
- Tap Take lead. The button should stop spinning, the alert should read "Marked as taken", and
leads.statusshould flip totaken. - Re-deliver the same webhook update manually (with
curland the same JSON body) and confirm the second request is a no-op — that is your idempotency check doing its job.
That is the entire pipeline. A few hundred lines, a database table, and one HTTPS endpoint — and a form submission lands in a manager's pocket in under a second.
If you would rather skip the handler and just have the pipeline running, BotCreator can ship a managed version of this flow with the manager buttons, retry, and webhook already wired up.
Top comments (0)