DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Map Telegram Deep-Link Payloads to MySQL Entities Safely in PHP

Suppose you are building a PHP backend for a Telegram lead distribution bot or a custom e-commerce checkout flow. You want external marketing campaigns, partner web apps, or email templates to route users directly into specific workflows using deep links like t.me/YourBot?start=PAYLOAD. When a user clicks this link, Telegram opens the app and automatically sends a /start PAYLOAD text message on their behalf.

If you naively pass database primary keys directly in this payload—such as t.me/YourBot?start=order_7821—you create a massive security vulnerability and operational fragility. Users can manually modify the payload in their browser or Telegram search bar, changing 7821 to 7822 to claim another user's pre-allocated order, inspect competitors' lead counts through sequential enumeration, or trigger half-executed database updates. Furthermore, Telegram strictly caps the start payload to 64 bytes and restricts allowed characters to A-Za-z0-9_-. Passing standard Base64-encoded JSON strings or long UUIDs formatted with hyphens will cause silent truncation, parsing errors, or broken link execution on mobile clients.

To build a production-ready deep-linking architecture in PHP, you must separate raw payload strings from internal database IDs. This guide walks through parsing deep links, mapping opaque random tokens to MySQL entities, handling state transitions gracefully when a user clicks a deep link during an active conversation, and verifying payload security before updating your database.

Parsing the Start Command and Respecting Payload Limits

When Telegram delivers a deep-link request to your PHP webhook endpoint, it arrives as a standard Message update where the text field begins with /start. If a payload was attached to the t.me/YourBot?start=payload link, Telegram appends a space followed by the payload string (for example, /start ref_a9f81b3c2d). If no payload was provided, the message text is simply /start.

A common beginner mistake is using explode(' ', $messageText)[1] to extract the payload. If a user manually types /start followed by multiple spaces, unusual characters, or whitespace line breaks, explode() will yield index errors or return malformed data. Additionally, Telegram limits the payload to a maximum of 64 characters consisting strictly of alphanumeric characters, underscores, and hyphens ([a-zA-Z0-9_-]). Characters outside this set (such as =, +, or / from standard Base64 encoding) will cause Telegram clients to render the link incorrectly or omit the payload entirely during link execution.

Your PHP entry point must validate the incoming Webhook request header (using X-Telegram-Bot-Api-Secret-Token), parse the JSON body, and extract the payload using an explicit regular expression that strictly enforces Telegram's character set and size limit.

<?php
declare(strict_types=1);

// webhook.php
header('Content-Type: application/json');

$secretToken = getenv('TELEGRAM_WEBHOOK_SECRET');
$incomingSecret = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';

if (!hash_equals($secretToken ?: '', $incomingSecret)) {
    http_response_code(403);
    echo json_encode(['error' => 'Unauthorized webhook source']);
    exit;
}

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

if (json_last_error() !== JSON_ERROR_NONE || !isset($update['message'])) {
    http_response_code(200);
    echo json_encode(['status' => 'ignored']);
    exit;
}

$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$userId = $message['from']['id'] ?? null;
$text = trim($message['text'] ?? '');

if (!$chatId || !$userId || $text === '') {
    http_response_code(200);
    echo json_encode(['status' => 'no_action']);
    exit;
}

// Match /start followed by optional payload (1 to 64 chars, A-Za-z0-9_-)
$payload = null;
if (preg_match('/^\/start(?:\s+([a-zA-Z0-9_-]{1,64}))?$/', $text, $matches)) {
    if (isset($matches[1]) && $matches[1] !== '') {
        $payload = $matches[1];
    }
}

if ($payload === null && str_starts_with($text, '/start')) {
    // Handles cases where payload violated regex constraints (>64 chars or illegal characters)
    error_log("Invalid or malformed start payload attempt from Telegram User ID: {$userId}");
}
Enter fullscreen mode Exit fullscreen mode

Mapping Opaque Cryptographic Tokens to MySQL Entities

Exposing internal database IDs inside public URLs leads to insecure direct object references (IDOR). If your referral link is t.me/YourBot?start=invite_501, an attacker can easily write a script to send /start invite_500, /start invite_499, and so on, exhausting your promo budgets or claiming leads assigned to other sales reps.

Instead of exposing numeric IDs, generate high-entropy, single-use or scoped tokens using bin2hex(random_bytes(7)). This produces a 14-character hexadecimal string that easily fits within Telegram's 64-byte payload limit, contains only valid URL-safe characters (0-9a-f), and provides 56 bits of entropy—making brute-force enumeration practically impossible.

Before generating the t.me deep link, your PHP backend creates a record in a dedicated bot_deep_links table mapping the 14-character token to the underlying action, entity ID, metadata payload, and expiration timestamp.

-- schema.sql
CREATE TABLE IF NOT EXISTS bot_deep_links (
    id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    token VARCHAR(64) NOT NULL UNIQUE,
    action_type VARCHAR(32) NOT NULL,
    target_id BIGINT UNSIGNED NOT NULL,
    created_by_user_id BIGINT UNSIGNED NULL,
    used_by_user_id BIGINT UNSIGNED NULL,
    max_uses INT UNSIGNED NOT NULL DEFAULT 1,
    use_count INT UNSIGNED NOT NULL DEFAULT 0,
    expires_at DATETIME NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_token_status (token, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Enter fullscreen mode Exit fullscreen mode

Here is the PHP class responsible for minting these links before sending them out to users via marketing campaigns or web interfaces:

<?php
declare(strict_types=1);

class DeepLinkManager
{
    private PDO $db;
    private string $botUsername;

    public function __construct(PDO $db, string $botUsername)
    {
        $this->db = $this->db = $db;
        $this->botUsername = $botUsername;
    }

    public function createDeepLink(
        string $actionType,
        int $targetId,
        ?int $createdByUserId = null,
        int $ttlSeconds = 86400,
        int $maxUses = 1
    ): string {
        // Generate 14-character hex string (7 bytes)
        $token = bin2hex(random_bytes(7));
        $expiresAt = (new DateTimeImmutable())->modify("+{$ttlSeconds} seconds")->format('Y-m-d H:i:s');

        $stmt = $this->db->prepare('
            INSERT INTO bot_deep_links (token, action_type, target_id, created_by_user_id, max_uses, expires_at)
            VALUES (:token, :action_type, :target_id, :created_by_user_id, :max_uses, :expires_at)
        ');

        $stmt->execute([
            'token' => $token,
            'action_type' => $actionType,
            'target_id' => $targetId,
            'created_by_user_id' => $createdByUserId,
            'max_uses' => $maxUses,
            'expires_at' => $expiresAt,
        ]);

        return "https://t.me/{$this->botUsername}?start={$token}";
    }
}
Enter fullscreen mode Exit fullscreen mode

Managing Deep Links Against Ongoing Dialogue States

A user clicking a deep link is not always a new user. They might already be halfway through a Multi-Step Lead Collection Form or a Support Ticket Submission inside the bot. When they click t.me/YourBot?start=3f8a02b1c4e97a, Telegram sends /start 3f8a02b1c4e97a directly into the existing chat thread.

If your bot relies on a simple conversational state machine stored in MySQL or Redis (e.g., user_state = 'awaiting_email'), receiving a deep link must explicitly override or resolve the current state. Otherwise, your bot might treat /start 3f8a02b1c4e97a as the user's email address or prompt answer.

Furthermore, deep link actions must be idempotent. If a user clicks a "Claim Lead" deep link twice, or forwards the /start message to another user, your code must verify whether the token has already reached its maximum usage limit or been bound to a different Telegram user_id.

The processing pipeline must perform these actions inside a database transaction:

  1. Lock the token record using SELECT ... FOR UPDATE.
  2. Verify that the token exists, is unexpired, and has remaining uses (use_count < max_uses).
  3. Bind or update the record, incrementing use_count and recording used_by_user_id.
  4. Reset or update the user's dialogue state in your session store.
  5. Dispatch a Telegram API response via cURL, checking response status and handling Telegram error payloads.
<?php
declare(strict_types=1);

// handle_start.php

function handleStartCommand(
    PDO $db,
    int $chatId,
    int $telegramUserId,
    ?string $payloadToken,
    string $botToken
): void {
    if ($payloadToken === null) {
        sendTelegramMessage($botToken, $chatId, "Welcome! Send /help to view available commands.");
        return;
    }

    $db->beginTransaction();
    try {
        // 1. Lock token record for update
        $stmt = $db->prepare('
            SELECT id, action_type, target_id, max_uses, use_count, expires_at 
            FROM bot_deep_links 
            WHERE token = :token 
            FOR UPDATE
        ');
        $stmt->execute(['token' => $payloadToken]);
        $linkRecord = $stmt->fetch(PDO::FETCH_ASSOC);

        $now = new DateTimeImmutable();

        if (!$linkRecord) {
            $db->rollBack();
            sendTelegramMessage($botToken, $chatId, "⚠️ This link is invalid or has expired.");
            return;
        }

        $expiresAt = new DateTimeImmutable($linkRecord['expires_at']);
        if ($now > $expiresAt || (int)$linkRecord['use_count'] >= (int)$linkRecord['max_uses']) {
            $db->rollBack();
            sendTelegramMessage($botToken, $chatId, "⚠️ This link has expired or has already been used.");
            return;
        }

        // 2. Consume token usage
        $updateStmt = $db->prepare('
            UPDATE bot_deep_links 
            SET use_count = use_count + 1, used_by_user_id = :user_id 
            WHERE id = :id
        ');
        $updateStmt->execute([
            'user_id' => $telegramUserId,
            'id' => $linkRecord['id'],
        ]);

        // 3. Clear existing dialogue state and execute action
        $clearStateStmt = $db->prepare('UPDATE user_sessions SET current_state = NULL WHERE telegram_user_id = :user_id');
        $clearStateStmt->execute(['user_id' => $telegramUserId]);

        $actionType = $linkRecord['action_type'];
        $targetId = (int)$linkRecord['target_id'];

        $db->commit();

        // 4. Dispatch domain response
        if ($actionType === 'claim_lead') {
            $safeLeadId = htmlspecialchars((string)$targetId, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
            $text = "✅ <b>Lead Claimed!</b>\n\nYou have successfully claimed Lead ID #{$safeLeadId}.";
            sendTelegramMessage($botToken, $chatId, $text, 'HTML');
        } else {
            sendTelegramMessage($botToken, $chatId, "Link processed successfully.");
        }

    } catch (Throwable $e) {
        if ($db->inTransaction()) {
            $db->rollBack();
        }
        error_log("Failed to process deep link: " . $e->getMessage());
        sendTelegramMessage($botToken, $chatId, "An internal error occurred while processing your request.");
    }
}

function sendTelegramMessage(string $botToken, int $chatId, string $text, string $parseMode = ''): bool {
    $url = "https://api.telegram.org/bot{$botToken}/sendMessage";

    $payload = [
        'chat_id' => $chatId,
        'text' => $text,
    ];
    if ($parseMode !== '') {
        $payload['parse_mode'] = $parseMode;
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => json_encode($payload),
        CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 5,
        CURLOPT_CONNECTTIMEOUT => 3,
    ]);

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

    if ($response === false) {
        error_log("cURL failed executing sendMessage: {$curlError}");
        return false;
    }

    if ($httpCode !== 200) {
        error_log("Telegram returned HTTP status {$httpCode}: {$response}");
        return false;
    }

    $responseData = json_decode($response, true);
    if (json_last_error() !== JSON_ERROR_NONE || !($responseData['ok'] ?? false)) {
        error_log("Telegram API returned ok: false or invalid JSON: {$response}");
        return false;
    }

    return true;
}
Enter fullscreen mode Exit fullscreen mode

Production Considerations: Security, Expiration, and Error Handling

Operating deep links in production requires strict adherence to security hygiene and resilient network communication.

First, always sanitize dynamic content before rendering HTML in Telegram messages. If a deep link resolves to a product name or user-generated lead title stored in your database (e.g., John & Co <Sales>), failing to wrap this string in htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') before sending with parse_mode=HTML will cause Telegram's HTML parser to reject the message with a 400 Bad Request: can't parse entities error.

Second, always configure timeouts and check cURL execution results when communicating with the Telegram API. Network glitches between your web server and Telegram's edge servers occur regularly. Inspect curl_getinfo($ch, CURLINFO_HTTP_CODE) and ensure json_decode() returns valid JSON with ['ok'] === true. If Telegram returns a 429 Too Many Requests error with a retry_after parameter, log the error and schedule a delayed retry rather than crashing the webhook execution.

Third, cleanup stale tokens periodically. Unclaimed promotional deep links or expired lead links accumulate in your bot_deep_links table over time. Run a scheduled CLI script or cron job in PHP that deletes or archives expired tokens (WHERE expires_at < NOW() AND use_count = 0) to keep your index sizes optimal.

When designing complex referral programs, multi-channel user attribution, or deep-linked Mini Apps, partner with seasoned teams like BotCreator — studio that ships Telegram bots / Mini Apps.

Top comments (0)