Secure Telegram Deep Links in PHP: Mapping start Payloads Safely

When launching a marketing campaign, referral program, or offline QR code onboarding for a Telegram bot, you rely on deep links. These links take the form of t.me/YourBot?start=payload. When a user clicks this link and presses the "Start" button, Telegram sends a standard /start payload message to your webhook.

If you treat this payload as raw, trusted input—or attempt to pack complex state directly into the URL—your application will break. Telegram imposes strict character limits and formatting rules on the start parameter. Furthermore, malicious users can easily manipulate the URL parameters to spoof referral IDs, access unauthorized resources, or trigger SQL injection vulnerabilities.

This tutorial demonstrates how to build a secure, database-backed deep linking system in PHP. You will learn how to bypass Telegram's payload limits using opaque keys, handle the difference between new and returning users, and prevent parameter tampering.

The Constraints of Telegram Deep Linking

Before writing any code, you must understand the technical limitations imposed by the Telegram Bot API on deep links:

1. Size Limit: The start parameter accepts a maximum of 64 characters. 2. Character Set: Only alphanumeric characters, underscores (_), and hyphens (-) are allowed. Slashes (/), equals signs (=), and spaces are strictly forbidden. 3. Encoding Pitfalls: If you attempt to Base64-encode a JSON payload (such as {"ref":1029,"src":"fb"}), the resulting string will often contain padding characters (=) or slashes (/), which are invalid. Even if you use a URL-safe Base64 variant, a moderately sized JSON object will quickly exceed the 64-character limit.

The Failing Path: Direct Parameter Trust

Consider a naive implementation where a bot expects a referral link like t.me/MyBot?start=ref123. When the webhook receives the payload, it parses ref123, extracts 123, and credits the user.

This approach fails for two reasons: * Spoofing: A user can change the URL to t.me/MyBot?start=ref1 to guess and credit the admin account or another user's ID. * Data Leakage: You expose internal database IDs directly in the URL.

The Secure Path: Opaque Database Mapping

Instead of passing raw data, generate a short, high-entropy, random token (an "opaque key") that maps to a rich metadata record in your database.

For example, instead of t.me/MyBot?start=user_id_999_campaign_summer_2024, you generate a 16-character hexadecimal key: t.me/MyBot?start=a8f3b2c9d1e0f4a7. This key fits perfectly within the 64-character limit, contains only valid characters, and reveals absolutely nothing about your internal database structure or business logic.

---

Designing the Database Mapping Layer

To implement opaque mapping, you need a database table to store the deep link payloads. This table acts as a registry, linking your short keys to structured JSON metadata, expiration dates, and usage counters.

Here is the SQL schema for the mapping table:

CREATE TABLE bot_deep_links (
id INT AUTO_INCREMENT PRIMARY KEY,
payload_key VARCHAR(64) NOT NULL UNIQUE,
entity_type VARCHAR(32) NOT NULL, -- e.g., 'referral', 'campaign', 'lead'
entity_id INT DEFAULT NULL, -- The target ID in your business logic
metadata JSON DEFAULT NULL, -- Extra context (e.g., source, medium, discount_code)
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP NULL DEFAULT NULL,
INDEX idx_payload_key (payload_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Next, write a PHP helper class to generate these secure keys and insert them into the database. We will use bin2hex(random_bytes(8)) to generate a cryptographically secure 16-character hexadecimal string.

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

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

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

/**
* Generates a secure deep link and stores its metadata.
*/
public function createDeepLink(
string $entityType,
?int $entityId,
array $metadata = [],
?int $ttlSeconds = null
): string {
// Generate a cryptographically secure 16-character hex key
$payloadKey = bin2hex(random_bytes(8));

$expiresAt = null;
if ($ttlSeconds !== null) {
$expiresAt = date('Y-m-d H:i:s', time() + $ttlSeconds);
}

$stmt = $this->db->prepare('
INSERT INTO bot_deep_links (payload_key, entity_type, entity_id, metadata, expires_at)
VALUES (:payload_key, :entity_type, :entity_id, :metadata, :expires_at)
');

$stmt->execute([
':payload_key' => $payloadKey,
':entity_type' => $entityType,
':entity_id' => $entityId,
':metadata' => json_encode($metadata),
':expires_at' => $expiresAt,
]);

return "https://t.me/{$this->botUsername}?start={$payloadKey}";
}
}

---

Processing the Webhook and Resolving Payloads

When a user clicks your link and starts the bot, Telegram sends a POST request to your webhook. The payload is delivered inside the message.text field. If the user is starting the bot for the first time, the text will look like this:

/start a8f3b2c9d1e0f4a7

Your webhook controller must: 1. Verify the incoming request authenticity using a secret token. 2. Parse the /start command and extract the payload key. 3. Query the database to resolve the key. 4. Execute the corresponding business logic (e.g., register a referral). 5. Send a confirmation message back to the user.

Here is a complete, production-ready webhook controller:

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

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

// 1. Authenticate the webhook request using Telegram's secret token header
$secretToken = $_SERVER['HTTP_X_TELEGRAM_BOT_API_SECRET_TOKEN'] ?? '';
$expectedToken = getenv('TELEGRAM_SECRET_TOKEN');

if (empty($expectedToken) || $secretToken !== $expectedToken) {
http_response_code(403);
echo json_encode(['error' => 'Unauthorized']);
exit;
}

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

if (json_last_error() !== JSON_ERROR_NONE || !isset($update['message'])) {
// Always return 200 OK to Telegram to acknowledge receipt of malformed updates
http_response_code(200);
echo json_encode(['status' => 'ignored']);
exit;
}

$message = $update['message'];
$chatId = $message['chat']['id'] ?? null;
$messageText = $message['text'] ?? '';
$telegramId = $message['from']['id'] ?? null;

if (!$chatId || !$telegramId) {
http_response_code(200);
exit;
}

// 2. Parse the /start command and extract the payload
if (strpos($messageText, '/start') === 0) {
$parts = explode(' ', $messageText, 2);
$payloadKey = $parts[1] ?? null;

if ($payloadKey) {
processDeepLink($chatId, $telegramId, $payloadKey);
} else {
sendTelegramMessage($chatId, "Welcome! You started the bot without a deep link.");
}
}

http_response_code(200);
echo json_encode(['status' => 'ok']);

/**
* Resolves the payload key and executes business logic.
*/
function processDeepLink(int $chatId, int $telegramId, string $payloadKey): void
{
$db = getDatabaseConnection();

// 3. Query the database for the payload key
$stmt = $db->prepare('
SELECT * FROM bot_deep_links
WHERE payload_key = :payload_key
LIMIT 1
');
$stmt->execute([':payload_key' => $payloadKey]);
$link = $stmt->fetch(PDO::FETCH_ASSOC);

if (!$link) {
sendTelegramMessage($chatId, "Invalid or expired link.");
return;
}

// Check expiration
if ($link['expires_at'] && strtotime($link['expires_at']) < time()) {
sendTelegramMessage($chatId, "This link has expired.");
return;
}

$entityType = $link['entity_type'];
$entityId = (int)$link['entity_id'];
$metadata = json_decode($link['metadata'] ?? '{}', true);

// 4. Execute business logic based on entity type
switch ($entityType) {
case 'referral':
handleReferral($chatId, $telegramId, $entityId);
break;
case 'campaign':
handleCampaign($chatId, $telegramId, $entityId, $metadata);
break;
default:
sendTelegramMessage($chatId, "Welcome! Link processed successfully.");
break;
}
}

function handleReferral(int $chatId, int $telegramId, int $referrerId): void
{
$db = getDatabaseConnection();

// Prevent self-referral
if ($telegramId === $referrerId) {
sendTelegramMessage($chatId, "You cannot refer yourself.");
return;
}

// Check if this user has already been referred
$stmt = $db->prepare('SELECT id FROM users WHERE telegram_id = :telegram_id LIMIT 1');
$stmt->execute([':telegram_id' => $telegramId]);
$user = $stmt->fetch();

if ($user) {
sendTelegramMessage($chatId, "Welcome back! You are already registered.");
return;
}

// Register the new user and attribute the referral
$stmt = $db->prepare('
INSERT INTO users (telegram_id, referrer_id, created_at)
VALUES (:telegram_id, :referrer_id, NOW())
');
$stmt->execute([
':telegram_id' => $telegramId,
':referrer_id' => $referrerId
]);

sendTelegramMessage($chatId, "Thank you for joining via referral! You both received a bonus.");
sendTelegramMessage($referrerId, "A new user joined using your referral link!");
}

function handleCampaign(int $chatId, int $telegramId, int $campaignId, array $metadata): void
{
$source = htmlspecialchars($metadata['source'] ?? 'unknown');
sendTelegramMessage($chatId, "Welcome! You joined from our {$source} campaign.");
}

/**
* Sends a message via Telegram Bot API.
*/
function sendTelegramMessage(int $chatId, string $text): void
{
$token = getenv('TELEGRAM_BOT_TOKEN');
$url = "https://api.telegram.org/bot{$token}/sendMessage";

$payload = [
'chat_id' => $chatId,
'text' => $text,
'parse_mode' => 'HTML'
];

$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);

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

if ($httpCode !== 200) {
error_log("Telegram API error: Status {$httpCode}, Response: {$response}");
}
}

function getDatabaseConnection(): PDO
{
static $pdo = null;
if ($pdo === null) {
$dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', getenv('DB_HOST'), getenv('DB_NAME'));
$pdo = new PDO($dsn, getenv('DB_USER'), getenv('DB_PASS'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}

---

Production Considerations: Expiry, Replay Prevention, and State Collisions

When deploying deep linking in production, you must account for several edge cases that can disrupt your application state or expose you to abuse.

1. Replay Attacks and Double Processing

If a user clicks a referral link, joins your bot, and then clicks the same link (or a different referral link) a week later, they will trigger the /start command again.

Without proper validation, your application might process the referral a second time, double-crediting the referrer. To prevent this: * Check User State: Always query your database to see if the user already exists before applying referral logic. * One-Time Payloads: If a payload is meant for one-time use (e.g., a password reset link or a single-use invite), mark the payload key as "used" in the bot_deep_links table immediately after processing it.

2. State Continuation vs. Deep Link Interruptions

If an existing user is in the middle of a multi-step conversation flow (e.g., filling out a shipping address) and suddenly clicks a deep link, the incoming /start command will interrupt their current state.

Your bot's state machine must handle this gracefully: * Confirm Context Switch: If the user has an active state, instead of immediately executing the deep link logic, ask them: *"You are currently filling out a form. Do you want to cancel and open this new link instead?"* with inline keyboard buttons. * Save Pending State: Alternatively, save the deep link payload in the user's session variables, complete the current form, and then process the pending deep link once the current flow is finished.

3. Handling Non-Alphanumeric Data Safely

If you absolutely must pass structured data without a database (for example, to make your bot stateless), you must use a URL-safe Base64 encoding scheme that strips padding characters and replaces invalid symbols.

In PHP, you can implement this using strtr:

function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}

Keep in mind that even with this encoding, the 64-character limit remains absolute. A JSON payload like {"c":"fb","g":12} encodes to eyJjIjoiZmIiLCJnIjoxMn0, which is 24 characters. This leaves you with very little room for additional parameters. Opaque database mapping remains the most robust and secure choice for production environments.

For more details on managing incoming updates and structuring your bot's webhook architecture, refer to the official documentation at https://botservice.biz/telegram-bot-api.

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.