Architecture Overview
A Telegram Stars payment flow has three distinct phases. First, the bot sends an sendInvoice request containing a unique lead identifier and an HMAC-protected initData. The lead identifier is a 14-character hex string generated with bin2hex(random_bytes(7)) and stored in a database. Second, when the user taps the MainButton, the bot calls pre_checkout_query to verify the invoice hasn't been replayed, the bot token is still valid, and the auth_date is fresh. Third, on successful payment, successful_payment is invoked to mark the invoice as paid, update the lead state, and send a confirmation. If any step is skipped or validated incorrectly, an attacker can forge an initData and redirect funds to their own account.
sendInvoice — Create the Invoice
The sendInvoice function creates a Telegram Invoice object and returns the data field that the bot will send to the user. The critical part is the initData string: it must contain the lead identifier, amount, timestamp, and an HMAC signature. The HMAC is computed over a canonical JSON payload and signed with the bot's secret key. This signature is embedded in initData so the server can verify it later without trusting the front-end.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use Telegram\Bot\Client as TelegramClient;
use Telegram\Bot\Message\Invoice;
// Configuration – keep these in environment variables
$botToken = getenv('BOT_TOKEN');
$secretKey = getenv('BOT_SECRET_KEY'); // HMAC key for initData signing
function sendInvoice($leadId, $amount) {
// 1. Generate a unique hex lead identifier
$leadId = bin2hex(random_bytes(7));
// 2. Build the canonical payload for HMAC
$payload = json_encode([
'lead_id' => $leadId,
'amount' => (int)$amount,
'timestamp' => time(),
], JSON_PRETTY_PRINT);
// 3. HMAC-SHA256 signature
$hmac = hash_hmac('sha256', $payload, $secretKey);
// 4. Assemble initData with HMAC
$initData = sprintf(
'init=%s|amount=%d|timestamp=%d|hmac=%s',
$payload,
$hmac,
time()
);
// 5. Create and send the invoice via Telegram Bot API
$client = new TelegramClient($botToken, new \TelegramClient($botToken));
$client->setWebhook('https://yourdomain.com/star-webhook');
$invoice = new Invoice([
'data' => $initData,
'amount' => $amount,
'currency' => 'USDT',
]);
$response = $client->sendInvoice([
'chat_id' => $leadId,
'data' => $invoice->data,
]);
return $response->getMessage();
}
If the HMAC verification fails on the server, the invoice is never sent to the user and the bot should respond with an error (e.g., “Invalid invoice”). This prevents an attacker from crafting a fake initData that points to a different lead or a higher amount.
pre_checkout_query — Validate the Invoice
Before processing a payment, the bot must call pre_checkout_query to ensure the invoice is still valid and hasn't been replayed. This function performs three checks: the auth_date must be recent (within 30 minutes), the initData HMAC must match the stored value, and the update_id must not have been processed before. If any check fails, the invoice is rejected and the user is shown an error.
<?php
function pre_checkout_query($leadId, $expectedInvoiceData) {
// Load the invoice from database (simplified)
$invoice = getInvoiceByLeadId($leadId);
if (!$invoice) {
return false;
}
// 1. Reject stale auth_date (older than 30 minutes)
$authDate = (int)invoice->getAuthDate();
if (time() - $authDate > 1800) {
return false;
}
// 2. Verify the HMAC of initData
$payload = $invoice->getInitData();
$computedHmac = hash_hmac('sha256', $payload, $secretKey);
if (!hash_equals($computedHmac, $payload)) {
return false;
}
// 3. Idempotency check: ensure update_id hasn't been processed
$updateId = $invoice->getUpdateId();
if (is_processed($updateId)) {
return false;
}
// Mark as processed to prevent replay
markProcessed($updateId);
return true;
}
Note that getInvoiceByLeadId, is_processed, and markProcessed are assumed to be helper functions that query a database table with a unique constraint on update_id. The update_id acts as an idempotency key; if the same callback arrives twice, the second attempt will be rejected.
successful_payment — Handle the Payment Callback
When the user completes a payment, Telegram sends an answerCallbackQuery with callback_data that encodes the invoice ID. The successful_payment function receives this callback, looks up the invoice, and marks it as paid. It also updates the lead state to "paid" and optionally sends a confirmation message to the user.
<?php
function successful_payment($callbackData) {
$invoiceId = extract('invoice_id', $callbackData);
$invoice = getInvoiceById($invoiceId);
if (!$invoice) {
// Invoice not found – possible replay or deleted invoice
return;
}
// Mark the invoice as successfully paid
$invoice->setStatus('paid');
$invoice->setUpdatedAt(time());
$invoice->save();
// Update the lead in the database
$lead = getLeadById($invoice->getLeadId());
if ($lead) {
$lead->setStatus('paid');
$lead->setUpdatedAt(time());
$lead->save();
}
// Send a confirmation to the user (optional)
sendConfirmation($invoice->getLeadId(), 'Payment received');
}
If the payment fails (e.g., the user cancels or the network drops), the answerCallbackQuery will still be received. The function should not mark the invoice as paid in that case. Only when the Telegram API confirms the payment (via answerCallbackQuery with a success response) should the state be updated.
What to Check on the Server
When implementing the flow, verify these server-side checks:
-
HMAC integrity – Always recompute the HMAC over the canonical
initDataand compare it with the value sent by the client. A mismatched HMAC means theinitDatawas tampered with. -
Auth date freshness – Reject invoices where
auth_dateis older than 30 minutes. This prevents replay attacks from old, cached invoices. -
Idempotency via
update_id– Store each processedupdate_idin a database table with a unique constraint. Before processing a callback, check that theupdate_idhasn't been seen before. -
Bot token verification – Ensure the
callback_datais generated by your bot (e.g.,t.me/YourBot?start=invoice_123). TheanswerCallbackQueryendpoint should validate the bot token before processing. - Amount and currency validation – Confirm the amount matches the lead's pricing table and that the currency is supported.
- Database transaction safety – Wrap invoice creation and status updates in a transaction to ensure atomicity. If the payment fails after the invoice is marked paid, you can roll back and avoid partial states.
-
Logging – Log every invoice creation, validation, and payment event with the
update_idand timestamp. This makes it possible to audit and recover from disputes.
Typical Errors and How to Avoid Them
-
Missing HMAC – If you send
initDatawithout an HMAC, an attacker can modify it to point to a different lead or a higher amount. Always signinitDataand verify on the server. -
Stale
auth_date– Without a freshness check, an old invoice could be replayed indefinitely. Enforce a tight window (e.g., 30 minutes) and reject older ones. -
Replay attacks – Without idempotency, the same
update_idcould be reused to mark an invoice as paid multiple times. Storeupdate_idin a DB table with a unique index and check before processing. -
Callback spoofing – If you accept any
callback_datawithout verifying the bot token, a malicious user can trigger the payment flow. Always validate the bot token on the server. -
Insufficient logging – Log every step with the
update_idand timestamp. This makes it possible to audit and recover from disputes. -
Incorrect HMAC algorithm – Using
md5orsha1instead ofsha256weakens security. Always usehash_hmac('sha256', ...). - Not handling payment failures – If the Telegram API returns an error (e.g., 400 for invalid payment), the function should not mark the invoice as paid. Only on a successful response should the state transition occur.
Production Notes
-
Webhook secret – Store your webhook secret in
getenv('WEBHOOK_SECRET')and verify it on every incoming request. Reject requests that don't match. -
Idempotency storage – Use a database table (or Redis) to track processed
update_idvalues. The table should have a TTL to prevent unbounded growth. -
Database schema hints – The
invoicetable should have columnsid,lead_id,amount,currency,status,auth_date,init_data,update_id,created_at,updated_at. Theupdate_idcolumn must be unique and indexed. -
Rate limiting – Protect the
/star-webhookendpoint from abuse by rate-limiting based onchat_idor IP address. A common pattern is to allow only one callback per lead per minute. - Error handling – Return appropriate HTTP status codes (e.g., 400 for invalid HMAC, 403 for missing bot token) and log errors for debugging.
- Graceful degradation – If the database is unavailable, do not process the invoice. Return an error to the user and queue the invoice for later processing.
By following this three-step flow — sendInvoice → pre_checkout_query → successful_payment — you ensure that only genuine Telegram users can complete a Stars payment, and you prevent the most common attack vectors: spoofed initData, replayed invoices, and unauthorized payment callbacks. The combination of HMAC signing, freshness checks, and idempotency is what makes the flow robust in production.
BotCreator — studio that ships Telegram bots / Mini Apps.
Top comments (0)