DEV Community

Cover image for telegram-wallet-php: The Complete PHP SDK for Accepting Crypto Payments in Telegram
Igor
Igor

Posted on

telegram-wallet-php: The Complete PHP SDK for Accepting Crypto Payments in Telegram

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

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

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

You then add the corresponding values to your .env file:

WALLETPAY_API_KEY=your_store_api_key_here
WALLETPAY_WEBHOOK_PATH=/webhook/walletpay
Enter fullscreen mode Exit fullscreen mode

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

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

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

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) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

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

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

Important: The directPayLink must be opened correctly on the client side. In a Telegram Web App, use Telegram.WebApp.openTelegramLink(url). In a bot message, use it as an Inline Button URL. Do not use openLink() or MenuButtonWebApp — 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))
Enter fullscreen mode Exit fullscreen mode

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

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

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

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 eventId field, as shown in the controller above.
  • If you have a firewall, you must allowlist the following IP addresses: 188.42.38.156 and 172.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);
}
Enter fullscreen mode Exit fullscreen mode

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

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

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:


References


  1. PropellerAds. "Report: The State of Telegram Mini App Advertising in 2025." https://propellerads.com/blog/adv-telegram-mini-app-advertising-report/ 

  2. Telegram Wallet. "Wallet Pay API Documentation v1.2.0." https://docs.wallet.tg/pay/ 

  3. FindMiniApp. "Telegram Mini Apps & Bots Report 2025." https://www.scribd.com/document/973291451/TMA-Report-FindMiniapp-Oct25 

  4. Olegt0rr. "TelegramWalletPay — Async Python client for Telegram Wallet Pay API." https://github.com/Olegt0rr/TelegramWalletPay 

Top comments (0)