Documentation sections

Documentation/Recipes

Payments in a Telegram bot

Ready-made bot flow: payment, button, webhook, delivery

A typical TabPay integration scenario is selling in a Telegram bot: digital goods, subscriptions, access. The telegramId field removes the need to keep your own order-to-user mapping table: the buyer's identifier is returned in the payment notification.

You will need the API key of an active shop (see the Authentication section) and a Webhook URL saved in the shop settings.

Step 1. The Buy button creates a payment

The bot creates a payment and sends a button with a link to payUrl; on the payment page the buyer chooses SBP or a card.

import aiohttp
from aiogram import Bot, Dispatcher, F
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup

API_KEY = "tp_your_key"  # Shop API key from the dashboard
bot = Bot("BOT_TOKEN")
dp = Dispatcher()


@dp.callback_query(F.data == "buy")
async def buy(callback: CallbackQuery):
    # Create a payment and pass the buyer's telegramId -
    # it comes back in the webhook, so the bot knows right away who gets the product
    async with aiohttp.ClientSession() as session:
        response = await session.post(
            "https://tabpay.org/api/v1/payments",
            headers={"X-Api-Key": API_KEY},
            json={
                "amountKopecks": 19900,
                "orderId": f"tg-{callback.id}",
                "description": "Monthly subscription",
                "telegramId": callback.from_user.id,
            },
        )
        payment = await response.json()

    keyboard = InlineKeyboardMarkup(inline_keyboard=[[
        InlineKeyboardButton(text="Pay 199.00 ₽", url=payment["payUrl"])
    ]])
    await callback.message.answer("Your invoice is ready - pay via the button:", reply_markup=keyboard)
    await callback.answer()

The full field reference is in Create a payment. orderId must be unique within the shop - the examples use the callback identifier. If the bot sells more than one product, pass the context in metadata (for example, the product id and tariff) - the object is echoed back in the webhook, so the bot knows exactly what to deliver.

Step 2. The webhook delivers the product

After the payment TabPay sends a POST request to the shop's Webhook URL; the notification body contains the buyer's telegramId:

Webhook body
{
  "id": "6b9d2c88-4f1a-4c0e-9b7d-2f8e5a1c3d90",
  "orderId": "tg-4382920412471814230",
  "status": "SUCCESS",
  "amountKopecks": 19900,
  "telegramId": "987654321",
  "metadata": null,
  "test": false
}

The handler verifies the X-Signature-V2 signature (HMAC-SHA256 of the "timestamp.body" string over the raw bytes with the shop secret; X-Timestamp freshness within a 5-minute window; see Signature verification for details) and delivers the product:

import hashlib
import hmac
import json
import time

from aiohttp import web

WEBHOOK_SECRET = "signing_secret_from_dashboard"


async def tabpay_webhook(request: web.Request):
    # The v2 signature is computed over the "timestamp.body" string - raw bytes, before parsing JSON
    raw = await request.read()
    timestamp = request.headers.get("X-Timestamp", "")
    received = request.headers.get("X-Signature-V2", "")
    signed = timestamp.encode() + b"." + raw
    expected = hmac.new(WEBHOOK_SECRET.encode(), signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, received):
        return web.Response(status=400)

    # A stale timestamp means a replay of an intercepted notification
    if abs(time.time() - int(timestamp)) > 300:
        return web.Response(status=401)

    event = json.loads(raw)

    # Test notification from the dashboard: acknowledge it, but do not deliver the product
    if event.get("test"):
        return web.Response(status=200)

    if event["status"] == "SUCCESS" and event["telegramId"]:
        # Idempotency: mark event["id"] as processed,
        # for a repeated webhook with the same id just respond 200
        await bot.send_message(
            int(event["telegramId"]),
            "Payment received - delivering the product.",
        )
    return web.Response(status=200)


app = web.Application()
app.router.add_post("/tabpay/webhook", tabpay_webhook)
web.run_app(app, port=8080)

Test the integration without a payment

The Webhook tab in the shop settings has a Send test webhook button: it sends a notification to your URL, built exactly like a live one with a valid signature, but with test: true (live notifications carry false). The handler must respond 200 and must not deliver the product - as in the examples above. This verifies the signature and notification delivery without a real payment.

Things to keep in mind

  • Deliver the product only on status: "SUCCESS" - any other status (reference) means the money has not been received.
  • A webhook can arrive more than once (without a 2xx response delivery is retried, up to 7 attempts with increasing delays) - mark the payment as processed by id and never deliver the product twice.
  • telegramId is returned as a string - cast it to a number if needed before sending the message.
  • If the notification was not received (for example, the bot server was down), verify the payment status with a Retrieve a payment request.
  • To bring the buyer back to the bot chat after payment, pass successUrl with an https://t.me/your_bot link when creating the payment - the payment page will redirect the buyer there after a successful payment.

If an AI assistant is doing the integration, hand it the materials from the AI and tools page - the ready-made prompt and llms.txt include this recipe.