Telegram is no longer just a messaging app. With over 1 billion monthly active users as of early 2025 1 and a deeply integrated blockchain ecosystem built around the TON network, it has become a serious platform for commerce, gaming, and decentralized applications. Developers building Telegram bots and Mini Apps now have a genuine opportunity to monetize their work by accepting cryptocurrency payments directly within the chat interface.
The official Telegram Wallet Pay API makes this possible, allowing merchants to accept TON, USDT, BTC, and NOT from users without ever redirecting them outside of Telegram 2. But integrating a raw REST API is never trivial. You need to handle authentication, construct request payloads, verify cryptographic webhook signatures, map HTTP status codes to meaningful errors, and do all of this in a maintainable way.
This is exactly the problem that telegram-wallet-php solves. Created by Igor Sazonov, this open-source PHP SDK wraps the Wallet Pay API v1.2.0 in a clean, modern, and fully typed interface. It works as a standalone library in any PHP 8.1+ project and provides first-class integration for Laravel 9 through 13. In this article, we will explore every facet of the library — from installation and architecture to advanced webhook handling and production best practices.
The Landscape: Why Telegram Payments Matter Right Now
Before diving into the code, it is worth understanding the context in which this library operates. Telegram's growth trajectory is remarkable: the platform grew from 900 million to 1 billion monthly active users between March 2024 and March 2025 1. More importantly for developers, the ecosystem of Telegram Mini Apps (TMAs) has exploded, with over 4,700 apps listed in dedicated directories and millions of users engaging with them daily 3.
The TON blockchain, which is natively integrated into Telegram's Wallet feature, has positioned itself as the payment rail for this ecosystem. Wallet Pay is the merchant-facing layer on top of this infrastructure — a business platform within the Wallet app that enables payment transactions between merchants and customers 2. For PHP developers, who power a significant portion of the web's backend infrastructure, having a well-crafted SDK for this API is not a luxury but a necessity.
What Is telegram-wallet-php?
At its core, telegram-wallet-php is a Composer package that provides a PHP client for the Wallet Pay REST API. The library is published under the MIT license and is available on Packagist. Its key design goals, as stated by the author, are:
- Framework agnosticism with Laravel priority. The library works in any PHP 8.1+ project but provides a dedicated Service Provider, Facade, and Middleware for Laravel applications.
- Type safety. All request and response objects are represented as typed DTOs (Data Transfer Objects), and all status values are represented as PHP 8.1 Enums.
- Security by default. Webhook signature verification using HMAC-SHA256 is built into the library and exposed as a Laravel Middleware.
- Testability. The package ships with a comprehensive PHPUnit test suite and supports dependency injection of a custom Guzzle HTTP client.
Installation and Requirements
Getting started is straightforward. The only hard requirements are PHP 8.1 or higher and the ext-json extension. The library depends on guzzlehttp/guzzle version 7.0 or higher for HTTP communication, which is a widely used and battle-tested HTTP client in the PHP ecosystem.
composer require tigusigalpa/telegram-wallet-php
For Laravel projects, the Service Provider is automatically discovered via Composer's package auto-discovery mechanism, so no manual registration is required. If you need to publish the configuration file, run:
php artisan vendor:publish --tag=walletpay-config
This creates a config/walletpay.php file in your application:
<?php
return [
'api_key' => env('WALLETPAY_API_KEY'),
'base_url' => env('WALLETPAY_BASE_URL', 'https://pay.wallet.tg'),
'timeout' => env('WALLETPAY_TIMEOUT', 30),
'webhook_path' => env('WALLETPAY_WEBHOOK_PATH', '/webhook/walletpay'),
];
You then add the corresponding values to your .env file:
WALLETPAY_API_KEY=your_store_api_key_here
WALLETPAY_WEBHOOK_PATH=/webhook/walletpay
The API key is obtained from the Wallet Pay merchant account, which you create through the @WalletPay_Support_Bot on Telegram.
| Requirement | Version |
|---|---|
| PHP | 8.1 or higher |
ext-json |
Any |
guzzlehttp/guzzle |
^7.0 |
| Laravel (optional) | 9.x, 10.x, 11.x, 12.x, 13.x |
Architecture Deep Dive
Understanding the library's internal structure helps you use it more effectively and extend it if needed. The src/ directory is organized into six logical namespaces:
src/
├── Contracts/ # Interfaces (WalletPayClientInterface)
├── DTO/ # Data Transfer Objects (CreateOrderRequest, MoneyAmount, OrderPreview, etc.)
├── Enums/ # PHP 8.1 Enums (OrderStatus, WebhookEventType, CurrencyCode)
├── Exceptions/ # Typed exception hierarchy
├── Laravel/ # Service Provider, Facade, Middleware
├── Webhook/ # WebhookVerifier class
└── WalletPayClient.php # Main client class
The Client: WalletPayClient
The main entry point is the WalletPayClient class, which implements WalletPayClientInterface. Its constructor accepts three parameters:
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl = 'https://pay.wallet.tg',
private readonly int $timeout = 30,
?ClientInterface $httpClient = null,
)
The $httpClient parameter accepts any PSR-18-compatible HTTP client, which is critical for unit testing — you can inject a mock client to test your application logic without making real HTTP calls. If no client is provided, the constructor creates a default Guzzle instance configured with the base URL, timeout, and the required Wpay-Store-Api-Key authentication header.
Data Transfer Objects (DTOs)
The library uses DTOs extensively to represent both request payloads and API responses. This is a significant advantage over raw array-based approaches. Consider the CreateOrderRequest DTO:
// Instead of this error-prone approach:
$payload = [
'amount' => ['currencyCode' => 'USD', 'amount' => '9.99'],
'description' => 'Premium subscription',
// ... easy to mistype keys or forget required fields
];
// You use this type-safe approach:
$request = new CreateOrderRequest(
amount: new MoneyAmount('USD', '9.99'),
description: 'Premium subscription',
externalId: 'ORDER-' . uniqid(),
timeoutSeconds: 3600,
customerTelegramUserId: 123456789,
autoConversionCurrency: 'USDT',
returnUrl: 'https://t.me/YourBot/YourApp',
failReturnUrl: 'https://t.me/YourBot',
customData: json_encode(['user_id' => 42])
);
Named arguments (a PHP 8.0 feature) make the intent of each parameter crystal clear, and your IDE will warn you if you pass the wrong type.
Enums for Status Values
Order statuses and webhook event types are represented as PHP 8.1 Enums, eliminating the risk of typos in string comparisons:
use Tigusigalpa\TelegramWallet\Enums\WebhookEventType;
use Tigusigalpa\TelegramWallet\Enums\OrderStatus;
// Compile-time safety — no more "ORDER_PIAD" typos
if ($event->type === WebhookEventType::ORDER_PAID) {
// ...
}
if ($order->status === OrderStatus::ACTIVE) {
// ...
}
The order lifecycle follows a clear state machine:
| Status | Description |
|---|---|
ACTIVE |
The order has been created and is awaiting payment |
PAID |
Payment has been received successfully |
EXPIRED |
The timeoutSeconds duration has elapsed without payment |
CANCELLED |
The order was cancelled by the user or system |
Creating Your First Payment
Let's walk through the complete flow of creating a payment order and redirecting the user to pay.
In Plain PHP
<?php
use Tigusigalpa\TelegramWallet\WalletPayClient;
use Tigusigalpa\TelegramWallet\DTO\CreateOrderRequest;
use Tigusigalpa\TelegramWallet\DTO\MoneyAmount;
use Tigusigalpa\TelegramWallet\Exceptions\WalletPayException;
$client = new WalletPayClient(apiKey: getenv('WALLETPAY_API_KEY'));
try {
$order = $client->createOrder(new CreateOrderRequest(
amount: new MoneyAmount('USD', '9.99'),
description: 'Premium subscription for 1 month',
externalId: 'ORDER-' . uniqid(),
timeoutSeconds: 3600,
customerTelegramUserId: 123456789,
autoConversionCurrency: 'USDT',
returnUrl: 'https://t.me/YourBot/YourApp',
customData: json_encode(['user_id' => 42])
));
// $order->directPayLink is the URL to send to the user
// Use it as an Inline Button URL in your bot message
echo $order->directPayLink;
} catch (WalletPayException $e) {
error_log('Payment creation failed: ' . $e->getMessage());
}
The returned $order object is a typed DTO containing the order ID, status, payment link, and other metadata from the API response.
In Laravel
With the Facade, the code is even cleaner:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Tigusigalpa\TelegramWallet\Laravel\Facades\WalletPay;
use Tigusigalpa\TelegramWallet\DTO\CreateOrderRequest;
use Tigusigalpa\TelegramWallet\DTO\MoneyAmount;
use Tigusigalpa\TelegramWallet\Exceptions\WalletPayException;
class PaymentController extends Controller
{
public function createPayment(Request $request): JsonResponse
{
$user = $request->user();
try {
$order = WalletPay::createOrder(new CreateOrderRequest(
amount: new MoneyAmount('USD', '9.99'),
description: 'Premium subscription',
externalId: 'SUB-' . $user->id . '-' . time(),
timeoutSeconds: 3600,
customerTelegramUserId: $user->telegram_id,
autoConversionCurrency: 'USDT',
returnUrl: 'https://t.me/YourBot/YourApp',
customData: json_encode(['user_id' => $user->id])
));
// Persist the order to your database
\App\Models\Payment::create([
'user_id' => $user->id,
'wallet_pay_order_id' => $order->id,
'amount' => $order->amount->amount,
'status' => 'pending',
]);
return response()->json(['pay_url' => $order->directPayLink]);
} catch (WalletPayException $e) {
\Log::error('WalletPay order creation failed', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
return response()->json(['error' => 'Could not create payment'], 500);
}
}
}
Important: The
directPayLinkmust be opened correctly on the client side. In a Telegram Web App, useTelegram.WebApp.openTelegramLink(url). In a bot message, use it as an Inline Button URL. Do not useopenLink()orMenuButtonWebApp— the payment will fail.Also important: Telegram requires the payment button text to be either "👛 Wallet Pay" or "👛 Pay via Wallet". The purse emoji is mandatory per Wallet Pay's design guidelines 2.
Handling Webhooks: The Critical Path
Webhooks are how Wallet Pay notifies your server of payment outcomes. Getting this right is the most security-sensitive part of any payment integration.
How Signature Verification Works
When Wallet Pay sends a webhook, it includes two headers:
-
WalletPay-Timestamp: A nanosecond timestamp used in the signature. -
WalletPay-Signature: A Base64-encoded HMAC-SHA256 signature.
The signature is computed as follows:
stringToSign = HTTP_METHOD + "." + URI_PATH + "." + TIMESTAMP + "." + Base64(BODY)
signature = Base64(HmacSHA256(stringToSign, API_KEY))
The WebhookVerifier class handles all of this automatically. You never need to implement this cryptography yourself, which is a significant security benefit — manual HMAC implementations are a common source of subtle vulnerabilities.
Setting Up Webhooks in Laravel
First, register the middleware in your app/Http/Kernel.php:
protected $middlewareAliases = [
'walletpay.webhook' => \Tigusigalpa\TelegramWallet\Laravel\Http\Middleware\VerifyWalletPayWebhook::class,
];
Then define the route:
// routes/api.php
use Illuminate\Http\Request;
use Tigusigalpa\TelegramWallet\Webhook\WebhookVerifier;
use Tigusigalpa\TelegramWallet\Enums\WebhookEventType;
Route::post('/webhook/walletpay', function (Request $request, WebhookVerifier $verifier) {
$events = $verifier->parseWebhookEvents($request->getContent());
foreach ($events as $event) {
match ($event->type) {
WebhookEventType::ORDER_PAID => handleSuccessfulPayment($event),
WebhookEventType::ORDER_FAILED => handleFailedPayment($event),
};
}
return response('OK', 200);
})->middleware('walletpay.webhook');
Handling Webhook Events in a Full Controller
Here is a production-ready controller that handles both payment creation and webhook processing:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Tigusigalpa\TelegramWallet\Laravel\Facades\WalletPay;
use Tigusigalpa\TelegramWallet\DTO\CreateOrderRequest;
use Tigusigalpa\TelegramWallet\DTO\MoneyAmount;
use Tigusigalpa\TelegramWallet\Webhook\WebhookVerifier;
use Tigusigalpa\TelegramWallet\Enums\WebhookEventType;
use App\Models\Payment;
use App\Models\User;
class PaymentController extends Controller
{
public function createPayment(Request $request)
{
$user = $request->user();
$order = WalletPay::createOrder(new CreateOrderRequest(
amount: new MoneyAmount('USD', '9.99'),
description: 'Premium subscription',
externalId: 'SUB-' . $user->id . '-' . time(),
timeoutSeconds: 3600,
customerTelegramUserId: $user->telegram_id,
autoConversionCurrency: 'USDT',
returnUrl: 'https://t.me/YourBot/YourApp',
customData: json_encode(['user_id' => $user->id])
));
Payment::create([
'user_id' => $user->id,
'wallet_pay_order_id' => $order->id,
'amount' => $order->amount->amount,
'status' => 'pending',
]);
return response()->json(['pay_url' => $order->directPayLink]);
}
public function webhook(Request $request, WebhookVerifier $verifier)
{
// Signature already verified by middleware
$events = $verifier->parseWebhookEvents($request->getContent());
foreach ($events as $event) {
// Idempotency: skip already-processed events
if (\App\Models\ProcessedWebhookEvent::where('event_id', $event->eventId)->exists()) {
continue;
}
if ($event->type === WebhookEventType::ORDER_PAID) {
$customData = json_decode($event->payload->customData, true);
Payment::where('wallet_pay_order_id', $event->payload->id)
->update(['status' => 'paid']);
User::find($customData['user_id'])
->update(['is_premium' => true]);
}
// Mark event as processed to prevent duplicate handling
\App\Models\ProcessedWebhookEvent::create(['event_id' => $event->eventId]);
}
return response('OK', 200);
}
}
Webhook Infrastructure Requirements
The Wallet Pay API has specific requirements for the webhook endpoint 2:
- The endpoint must use HTTPS with a certificate from a trusted Certificate Authority (e.g., Let's Encrypt). Self-signed certificates are not accepted.
- Your server must respond with HTTP 200 to acknowledge receipt. If it does not, Wallet Pay will retry the request.
- Because retries can occur due to network issues, you must implement idempotency using the
eventIdfield, as shown in the controller above. - If you have a firewall, you must allowlist the following IP addresses:
188.42.38.156and172.255.249.124.
Error Handling: Typed Exceptions
One of the most developer-friendly aspects of telegram-wallet-php is its exception hierarchy. Rather than catching a generic \Exception and trying to parse an error message, you can handle each failure mode precisely:
use Tigusigalpa\TelegramWallet\Exceptions\InvalidRequestException;
use Tigusigalpa\TelegramWallet\Exceptions\InvalidApiKeyException;
use Tigusigalpa\TelegramWallet\Exceptions\OrderNotFoundException;
use Tigusigalpa\TelegramWallet\Exceptions\RateLimitException;
use Tigusigalpa\TelegramWallet\Exceptions\ServerException;
use Tigusigalpa\TelegramWallet\Exceptions\InvalidWebhookSignatureException;
use Tigusigalpa\TelegramWallet\Exceptions\WalletPayException;
try {
$order = $client->getOrderPreview('123456');
} catch (OrderNotFoundException $e) {
return response()->json(['error' => 'Order not found'], 404);
} catch (RateLimitException $e) {
// Implement exponential backoff and retry
return response()->json(['error' => 'Too many requests, try again later'], 429);
} catch (InvalidApiKeyException $e) {
\Log::critical('Invalid Wallet Pay API key configured!');
return response()->json(['error' => 'Payment service misconfigured'], 500);
} catch (WalletPayException $e) {
// Catch-all for any other Wallet Pay errors
\Log::error('Wallet Pay error: ' . $e->getMessage());
return response()->json(['error' => 'Payment service error'], 500);
}
The full exception hierarchy maps directly to the HTTP status codes defined in the Wallet Pay API documentation 2:
| Exception Class | HTTP Status | Meaning |
|---|---|---|
InvalidRequestException |
400 | Malformed request or invalid parameters |
InvalidApiKeyException |
401 | The provided API key is invalid |
OrderNotFoundException |
404 | The requested order does not exist |
RateLimitException |
429 | Too many requests; slow down |
ServerException |
500 | Unexpected error on Wallet Pay's side |
InvalidWebhookSignatureException |
— | Webhook signature verification failed |
Complete API Reference
The SDK exposes all four methods available in the Wallet Pay API:
createOrder(CreateOrderRequest $request): OrderPreview
Creates a new payment order. The returned OrderPreview object contains the directPayLink that you send to the user.
getOrderPreview(int|string $orderId): OrderPreview
Retrieves the current state of an existing order. Use this to poll for payment status if you prefer polling over webhooks, or to verify a webhook notification.
getOrderList(int $offset = 0, int $count = 100): OrderList
Returns a paginated list of all orders for your store. The maximum $count per request is 10,000. Useful for reconciliation and reporting.
getOrderAmount(): int
Returns the total number of orders created for your store. A lightweight endpoint for dashboard statistics.
Currency Support and Auto-Conversion
The Wallet Pay API supports a dual-currency model that is worth understanding:
Pricing currencies (what you charge in): USD, EUR
Settlement currencies (what you receive): TON, USDT, BTC, NOT
The autoConversionCurrency parameter bridges these two worlds. When set, Wallet Pay automatically converts the payment from the user's chosen crypto into your preferred settlement currency. This is convenient for merchants who want predictable fiat-equivalent pricing but receive crypto. However, be aware that this conversion incurs a 1% fee, and there are minimum order amounts: $1.30 for most currencies and $3.00 for BTC 2.
If autoConversionCurrency is not set, the user can choose which cryptocurrency to pay with from the available options.
Important Gotchas and Production Tips
Based on a thorough reading of the documentation and source code, here are several important considerations for production deployments:
One user per order. The customerTelegramUserId field is mandatory, and only that specific Telegram user can pay the order. This is a security feature that prevents payment link sharing, but it means you cannot create a generic "pay now" link — you must create a unique order for each user.
Idempotency with externalId. The externalId field serves as an idempotency key. If you create an order with the same externalId twice (e.g., due to a retry on a network timeout), the API will return the existing order rather than creating a duplicate. Build your externalId to be deterministic: 'ORDER-' . $userId . '-' . $productId is a good pattern.
48-hour withdrawal hold. Funds received through Wallet Pay are held for 48 hours before you can withdraw them. This is a Wallet Pay policy, not a limitation of the SDK, but it is important to communicate to your users and factor into your cash flow planning.
Webhook URI path matching. The Wallet Pay signature verification includes the exact URI path of the webhook endpoint. The path must match exactly what you configured in your store settings, including the presence or absence of a trailing slash. A mismatch will cause all webhook verifications to fail.
Testing the Library
The package ships with a PHPUnit test suite organized into unit and feature test suites. Running the tests is straightforward:
# Install dependencies
composer install
# Run all tests
vendor/bin/phpunit
# Run only unit tests
vendor/bin/phpunit --testsuite Unit
# Run only feature tests
vendor/bin/phpunit --testsuite Feature
# Generate HTML coverage report
vendor/bin/phpunit --coverage-html coverage
For your own application tests, the injectable $httpClient parameter in WalletPayClient's constructor makes it easy to mock HTTP responses:
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
$mock = new MockHandler([
new Response(200, [], json_encode([
'status' => 'SUCCESS',
'data' => [
'id' => 12345,
'status' => 'ACTIVE',
'directPayLink' => 'https://t.me/wallet/start?startapp=...',
// ...
]
])),
]);
$client = new WalletPayClient(
apiKey: 'test-key',
httpClient: new Client(['handler' => HandlerStack::create($mock)])
);
Comparison with Alternative Approaches
To appreciate what telegram-wallet-php provides, it is worth considering the alternatives:
Raw Guzzle/cURL. You could interact with the Wallet Pay API directly using Guzzle or cURL. This gives you maximum control but requires you to write all the boilerplate: request construction, response parsing, error handling, and webhook verification. This is error-prone and time-consuming.
Python's telegram-wallet-pay. There is an async Python client for the same API 4. If your stack is Python-based, that is a valid alternative. The PHP SDK offers comparable functionality with a synchronous interface better suited to traditional PHP web applications.
Generic payment gateways. Services like Stripe or PayPal have mature PHP SDKs, but they do not support crypto payments or the native Telegram payment experience. For Telegram-native applications, Wallet Pay is the most seamless option.
The telegram-wallet-php library occupies a clear niche: it is the right tool for PHP developers building Telegram-native applications who want a production-ready, well-structured integration without writing everything from scratch.
Future Roadmap
The README hints at an ambitious future for the library. The architecture is designed to separate payment functionality from potential future trading features, including spot trading, tokenized stocks, and perpetual futures. As Telegram's Wallet platform adds new API capabilities, the SDK is intended to grow alongside it without introducing breaking changes to existing code.
This forward-looking design is encouraging. It suggests the author is thinking about the library as a long-term project rather than a quick utility, which is an important consideration when choosing a dependency for production applications.
Conclusion
The telegram-wallet-php library is a well-crafted, production-ready SDK that significantly lowers the barrier to integrating cryptocurrency payments into PHP applications and Telegram bots. Its use of modern PHP 8.1 features — Enums, readonly properties, named arguments, and typed DTOs — results in code that is not only safer but also more readable and maintainable.
The first-class Laravel integration is a genuine differentiator. The combination of a Service Provider, Facade, and Middleware means that Laravel developers can add Telegram crypto payments to their applications with minimal boilerplate, following the conventions they already know. The built-in webhook signature verification, in particular, removes a common source of security vulnerabilities in payment integrations.
For developers building in the rapidly growing Telegram Mini App ecosystem, this library is an excellent foundation. The Telegram platform's 1 billion+ user base 1 and the seamless crypto payment experience offered by Wallet Pay represent a significant commercial opportunity, and telegram-wallet-php makes it accessible to the PHP community.
Get started:
- GitHub: https://github.com/tigusigalpa/telegram-wallet-php
- Packagist: https://packagist.org/packages/tigusigalpa/telegram-wallet-php
- Wallet Pay Docs: https://docs.wallet.tg/pay/
References
-
PropellerAds. "Report: The State of Telegram Mini App Advertising in 2025." https://propellerads.com/blog/adv-telegram-mini-app-advertising-report/ ↩
-
Telegram Wallet. "Wallet Pay API Documentation v1.2.0." https://docs.wallet.tg/pay/ ↩
-
FindMiniApp. "Telegram Mini Apps & Bots Report 2025." https://www.scribd.com/document/973291451/TMA-Report-FindMiniapp-Oct25 ↩
-
Olegt0rr. "TelegramWalletPay — Async Python client for Telegram Wallet Pay API." https://github.com/Olegt0rr/TelegramWalletPay ↩
Top comments (0)