DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Send Website Form Submissions to Telegram with PHP

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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • htmlspecialchars runs before parse_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 on ok, not on HTTP 200 alone — Telegram returns 200 for almost everything, including rejected requests.
  • The lead row is inserted before sendMessage is called and before $leadId is used in callback_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_match line.

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';
Enter fullscreen mode Exit fullscreen mode

Two production details that are easy to get wrong:

  • Idempotency by update_id only 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 same update_id. The INSERT ... processed_updates above is the source of truth: the unique key on update_id guarantees only one worker proceeds. If you want to avoid MySQL for this, a Redis SETNX with a TTL works the same way.
  • The t.me/Bot?start=lead_ID trick is sometimes suggested as a "resume the lead chat" shortcut. It only deep-links /start to your bot — the bot still has to map the payload back to a lead, then to a session, then to a chat. Prefer short callback_data for in-chat hand-off and only use start= 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"]
  }'
Enter fullscreen mode Exit fullscreen mode

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. Use CF-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. sendMessage can fail (rate limits, transient 5xx). A simple flock-based queue or a wp cron / systemd retry that re-reads leads where status='new' and telegram_sent_at IS NULL is 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. getenv is the minimum. In production, read from your platform's secret store and rotate TELEGRAM_BOT_TOKEN if it ever appears in a log.

Verifying it works

  1. Submit the form on staging. You should see the lead in MySQL and a message in the manager's Telegram.
  2. Tap Take lead. The button should stop spinning, the alert should read "Marked as taken", and leads.status should flip to taken.
  3. Re-deliver the same webhook update manually (with curl and 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)