Как принимать оплату криптой в Telegram-боте на Python
Пошаговый пример на aiogram 3. Бот создаёт криптосчёт через API, присылает кнопку оплаты и выдаёт товар только после проверенного вебхука.

Оплату криптой в Telegram-боте проще всего принимать через платёжный шлюз: бот запрашивает у шлюза счёт, отправляет пользователю кнопку со ссылкой на оплату и ждёт подписанный вебхук о том, что счёт оплачен. Боту не нужны приватные ключи, не нужно следить за блокчейном и угадывать, чей перевод пришёл.
Ниже — рабочий пример на Python с aiogram 3 и aiohttp. Бот создаёт счёт запросом с HMAC-подписью, показывает кнопку оплаты, проверяет подпись вебхука и выдаёт товар только после подтверждения платежа. В качестве API используется Zypher, но схема «подписанный запрос — подписанный вебхук — идемпотентное обновление заказа» одинакова почти у всех сервисов криптоэквайринга.
Сначала короткий, но важный раздел о правилах Telegram: от них зависит, подходит ли такая схема вашему боту.
Сначала правила Telegram
В правилах Bot Payments для цифровых товаров сказано, что цифровые товары и услуги, которые продаются внутри приложений Telegram, оплачиваются только в Telegram Stars. Для физических товаров и услуг в реальном мире можно использовать другие валюты и платёжных провайдеров. Так страница выглядит по состоянию на октябрь 2026 года; Telegram периодически её обновляет, поэтому перечитайте её перед запуском.
На практике оплату криптой через внешнюю платёжную страницу обычно подключают для физических товаров, услуг вне Telegram, счетов для бизнеса, пополнения баланса во внешнем сервисе и донатов. Если бот продаёт цифровой контент мобильным пользователям внутри Telegram, внимательно изучите условия платформы и при необходимости проконсультируйтесь с юристом. Эта статья не юридическая консультация.
Какие есть варианты
| Вариант | Как работает | Кому подходит | Ограничения |
|---|---|---|---|
| Telegram Stars | Нативный sendInvoice в валюте Stars | Цифровые товары внутри Telegram | Это не крипта на вашем кошельке, выплаты по правилам Telegram |
| Крипто-боты внутри Telegram (Crypto Pay, Wallet Pay) | Вы вызываете API бота, пользователь платит не выходя из Telegram | Аудитория, которая уже держит крипту в этих кошельках | Набор монет, сетей и комиссии зависят от провайдера; комиссии публикуются не всегда |
| Платёжный шлюз со ссылкой на счёт | Бот создаёт счёт по API и присылает ссылку на страницу оплаты | Много монет и сетей, отдельный адрес на каждый заказ, вебхуки | Нужен небольшой HTTPS-эндпоинт для вебхуков |
| Свой кошелёк и мониторинг | Бот опрашивает ноду или эксплорер и сопоставляет переводы | Одна монета и полный контроль | Сопоставление платежей, подтверждения, недоплаты — всё на вас |
Последний вариант для TON описан в документации TON: бот опрашивает кошелёк и запоминает последнюю обработанную транзакцию. Это работает, пока у вас одна монета. Когда нужны USDT в нескольких сетях, биткоин и страница с QR-кодом, шлюз экономит много кода. Дальше — именно этот вариант.
Как устроен процесс оплаты
Пользователь нажимает «Купить».
Сервер создаёт заказ в базе и отправляет
POST /api/v1/invoiceс ценой и номером заказа.Шлюз возвращает счёт со ссылкой на страницу оплаты.
Бот присылает эту ссылку inline-кнопкой.
Покупатель выбирает монету и сеть (если счёт выставлен в долларах), сканирует QR-код и платит на уникальный адрес этого счёта.
После нужного числа подтверждений шлюз отправляет на сервер подписанный вебхук
invoice.paid.Сервер проверяет подпись, отмечает заказ оплаченным, бот выдаёт товар.
Главное правило: товар выдаётся по вебхуку, а не по кнопке, которую может нажать пользователь. Кнопка «Я оплатил» допустима, но только как способ проверить статус у шлюза.
Что понадобится
Токен бота от @BotFather.
Python 3.10+ и
aiogram3.x (aiohttp ставится вместе с ним).Магазин в Zypher с API-ключом и секретом. Для открытия магазина документы не нужны, регистрация занимает минуту: код на почту, Telegram или passkey.
Публичный HTTPS-адрес для вебхука: VPS за nginx или Caddy, а для разработки — любой HTTPS-туннель.
Хранилище заказов. В примере для краткости словарь в памяти; в продакшене берите SQLite, Postgres или Redis, иначе перезапуск бота потеряет оплаченные заказы.
В настройках магазина укажите адрес вебхука, например https://your-domain.example/zypher/webhook. Если включён список разрешённых IP, добавьте в него адрес сервера, с которого бот обращается к API.
Подпись запросов к API
Каждый запрос к API Zypher содержит два заголовка:
X-API-Key— API-ключ магазина;X-API-Sign— HMAC-SHA256 от JSON-тела в hex, ключ — API-секрет. Для GET-запросов подписывается пустая строка.
Почти все ошибки «invalid signature» возникают, когда подписали одну строку, а отправили другую. Сериализуйте тело один раз, подпишите именно эту строку и отправьте именно эти байты. Используйте компактный JSON, передавайте суммы строками, а не float, и не давайте HTTP-клиенту перекодировать тело (в aiohttp — data=, а не json=).
Код бота целиком
# bot.py — aiogram 3 + aiohttp. Переменные окружения: BOT_TOKEN, ZYPHER_API_KEY, ZYPHER_API_SECRET
import asyncio
import hashlib
import hmac
import json
import os
import time
from aiohttp import ClientSession, web
from aiogram import Bot, Dispatcher, F
from aiogram.filters import Command
from aiogram.types import (CallbackQuery, InlineKeyboardButton,
InlineKeyboardMarkup, Message)
BOT_TOKEN = os.environ["BOT_TOKEN"]
API_KEY = os.environ["ZYPHER_API_KEY"]
API_SECRET = os.environ["ZYPHER_API_SECRET"].encode()
API_BASE = "https://zypher.exchange/api"
SITE = "https://zypher.exchange"
bot = Bot(BOT_TOKEN)
dp = Dispatcher()
# order_id -> {"user_id": int, "invoice_id": str, "status": "pending" | "paid"}
ORDERS: dict[str, dict] = {} # в продакшене — настоящая база данных
def sign(body: str) -> str:
return hmac.new(API_SECRET, body.encode(), hashlib.sha256).hexdigest()
async def zypher(method: str, path: str, payload: dict | None = None) -> dict:
body = "" if payload is None else json.dumps(
payload, separators=(",", ":"), ensure_ascii=False)
headers = {"X-API-Key": API_KEY, "X-API-Sign": sign(body)}
if payload is not None:
headers["Content-Type"] = "application/json"
async with ClientSession() as s:
async with s.request(method, API_BASE + path, headers=headers,
data=body.encode() if payload is not None else None) as r:
data = await r.json(content_type=None)
if r.status >= 300:
raise RuntimeError(f"Zypher API {r.status}: {data}")
return data
def invoice_fields(invoice: dict) -> tuple[str, str]:
# ID счёта и ссылка на страницу оплаты из ответа на создание счёта.
# Точные имена полей — в https://zypher.exchange/docs, при расхождении поправьте здесь.
invoice_id = str(invoice["id"])
url = str(invoice["paymentUrl"])
return invoice_id, (url if url.startswith("http") else SITE + url)
@dp.message(Command("buy"))
async def buy(message: Message) -> None:
order_id = f"tg-{message.from_user.id}-{int(time.time())}"
invoice = await zypher("POST", "/v1/invoice", {
"fiatAmount": "10.00", # цена в USD, монету и сеть выберет покупатель
"fiatCurrency": "USD",
"orderId": order_id,
"metadata": {"telegramUserId": message.from_user.id, "plan": "pro-30d"},
})
invoice_id, pay_url = invoice_fields(invoice)
ORDERS[order_id] = {"user_id": message.chat.id,
"invoice_id": invoice_id, "status": "pending"}
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="Оплатить $10 криптой", url=pay_url)],
[InlineKeyboardButton(text="Проверить оплату", callback_data=f"check:{order_id}")],
])
await message.answer(
"Заказ создан. Выберите монету на странице оплаты и отправьте ровно ту сумму, "
"которая там указана. На выбор монеты есть 60 минут.", reply_markup=kb)
@dp.callback_query(F.data.startswith("check:"))
async def check(cb: CallbackQuery) -> None:
order = ORDERS.get(cb.data.split(":", 1)[1])
if not order:
await cb.answer("Заказ не найден", show_alert=True)
return
if order["status"] == "paid":
await cb.answer("Оплачено, спасибо!", show_alert=True)
return
invoice = await zypher("GET", f"/v1/invoice/{order['invoice_id']}")
await cb.answer(f"Статус счёта: {invoice.get('status', 'unknown')}", show_alert=True)
async def zypher_webhook(request: web.Request) -> web.Response:
raw = await request.read() # сырые байты, до любого парсинга
received = request.headers.get("X-Webhook-Signature", "").strip().lower()
expected = hmac.new(API_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
return web.Response(status=401, text="bad signature")
event = json.loads(raw)
data = event.get("data") or {}
if event.get("event") != "invoice.paid" or data.get("status") not in ("PAID", "OVERPAID"):
return web.Response(text="ignored")
order = ORDERS.get(str(data.get("orderId"))) # ваш orderId; см. /docs
if order is None:
return web.Response(text="unknown order") # 200, чтобы не было бесконечных повторов
if order["status"] == "paid":
return web.Response(text="ok") # повторная доставка
order["status"] = "paid"
await bot.send_message(order["user_id"],
"Оплата подтверждена. Тариф Pro активен на 30 дней.")
return web.Response(text="ok")
async def main() -> None:
app = web.Application()
app.router.add_post("/zypher/webhook", zypher_webhook)
runner = web.AppRunner(app)
await runner.setup()
await web.TCPSite(runner, "0.0.0.0", 8080).start() # снаружи — только HTTPS
await dp.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())Запустите python bot.py, отправьте боту /buy и оплатите небольшую сумму, чтобы пройти весь цикл. Тестового режима нет, поэтому проверяйте на самой дешёвой сети из тех, что планируете принимать (например, USDT в TRON или TON), и с минимальной ценой.
Разбор по шагам
Создание счёта
Цена задаётся в долларах через fiatAmount. В таком счёте покупатель сам выбирает монету и сеть на странице оплаты, и одна кнопка закрывает USDT, USDC, BTC, TON и остальные монеты. Если нужна строго одна монета, счёт можно выставить сразу в ней и в конкретной сети. Поля запроса указаны по состоянию на октябрь 2026 года, полный список — в документации API.
orderId — ваш собственный номер заказа. Он возвращается в вебхуке, и по нему вы находите нужного пользователя Telegram. Не используйте один и тот же номер для разных покупок.
Ссылка на оплату
На странице оплаты есть QR-коды, обратный отсчёт и понятные состояния для недоплаты, переплаты и истёкшего счёта, а сама страница переведена на 11 языков. Поэтому в чате механику объяснять не нужно: достаточно цены, того, что получит пользователь, и срока на оплату.
Проверка вебхука
Вебхук приходит POST-запросом с JSON {event, data, timestamp} и двумя заголовками: X-Webhook-Signature (HMAC-SHA256 от сырого тела в hex, ключ — API-секрет) и X-Webhook-Event. Важны три вещи:
Хешируйте сырые байты. Если распарсить JSON и собрать заново, изменятся пробелы и порядок ключей, и подпись не сойдётся.
Сравнивайте за постоянное время через
hmac.compare_digest.Реагируйте только на
invoice.paidсо статусомPAIDилиOVERPAIDвdata.status.
Для надёжности можно поступить как многие плагины: после валидного вебхука ещё раз запросить счёт подписанным GET и опираться на этот статус.
Повторы и идемпотентность
Если доставка не удалась, вебхук отправляется повторно — всего до 5 попыток. Значит, обработчик должен спокойно переживать повторный вызов: перед выдачей доступа проверяйте, не оплачен ли заказ уже. Отвечайте 2xx быстро, а тяжёлую работу (выдачу лицензий, создание серверов) выносите в фоновую задачу.
Нестандартные ситуации
| Ситуация | Что происходит | Что делать боту |
|---|---|---|
| Пользователь оплатил поздно | Счёт в монете живёт 30 минут. В долларовом счёте 60 минут на выбор монеты, затем 30 минут на оплату. Платёж, пришедший в течение 30 минут после истечения, всё ещё может быть сопоставлен со счётом. | Сразу сообщайте срок. Если счёт истёк без оплаты, предложите /buy заново. |
| Прислал чуть меньше | 98% суммы и больше считается оплатой. | Ничего, вебхук придёт как обычно. |
| Прислал заметно меньше | Ниже 98% счёт получает статус UNDERPAID, и покупатель может доплатить остаток: частичные платежи суммируются. | Отправить пользователя на ту же страницу оплаты. |
| Прислал больше | Выше 102% счёт помечается OVERPAID, вебхук всё равно приходит. | Выдать товар и заранее решить, как вы поступаете с крупными переплатами. |
| Платёж идёт долго | Зачисление после подтверждений сети: TON 1, BTC 2, BSC 5, Ethereum 6, Solana 8, TRON 10. | Писать «обычно несколько минут». |
| Выбрал не ту сеть в кошельке | Перевод не попадает на адрес счёта в нужной сети. | Называть сеть в тексте бота и просить сверить её в кошельке. |
От выбора сети зависят комиссии покупателя. Подробное сравнение — в статье TRC20, BEP20 или ERC20: какую сеть USDT принимать.
Чек-лист перед запуском
Заказы в базе данных с уникальным ключом по
orderId.API-секрет в переменной окружения, а не в репозитории.
Вебхук по HTTPS, ответ 2xx за несколько секунд.
Логирование сырого тела и подписи при неудачной проверке (без секрета).
Список разрешённых IP для магазина, если у сервера статический адрес.
TOTP 2FA и режим белого списка адресов вывода в аккаунте мерчанта.
Для ежемесячного доступа — новый счёт каждый период: автоматических списаний нет, поэтому лучше всего работает напоминание с новой кнопкой оплаты.
Если хотите держать выручку в одной монете, включите автоконвертацию: каждый платёж (за вычетом комиссии) будет обменян, например, в USDT.
Сколько это стоит
Zypher берёт комиссию с каждого оплаченного счёта, она удерживается из полученной суммы: 1% на тарифе Start, затем 0,75%, 0,5%, 0,35% и 0,25% при обороте за 30 дней от $10 тыс., $50 тыс., $250 тыс. и $1 млн. Платы за подключение и абонентской платы нет; при выводе оплачивается комиссия сети. Подробности — на странице тарифов. Если у вас есть ещё и сайт, тот же API работает и там: см. инструкцию по приёму USDT на сайте.
Больше сценариев для ботов, каналов и Mini App — на странице криптоплатежи для Telegram-ботов, а общий обзор возможностей — в разделе криптопроцессинг для бизнеса. Чтобы запустить код выше с настоящими ключами, создайте магазин и пропишите ключ и секрет в переменные окружения.
Частые вопросы
- Можно ли принимать крипту в боте без платёжного шлюза?
Можно. Бот может отслеживать свой адрес через ноду или API эксплорера и сопоставлять входящие переводы с заказами. Но тогда уникальные суммы или адреса, подтверждения, недоплаты и несколько сетей вы реализуете сами. Шлюз выдаёт каждому счёту отдельный адрес и присылает один подписанный вебхук после оплаты.
- Разрешено ли продавать цифровые товары в Telegram-боте за криптовалюту?
Правила Bot Payments требуют, чтобы цифровые товары и услуги внутри приложений Telegram оплачивались в Telegram Stars, а для физических товаров и услуг в реальном мире допускаются другие провайдеры. Правила меняются, поэтому перед запуском прочитайте актуальную версию на core.telegram.org. В спорных случаях посоветуйтесь с юристом, который знает ваш рынок.
- Как убедиться, что оплата действительно пришла?
Доверяйте только вебхуку, у которого вы проверили
X-Webhook-Signatureпо сырому телу и API-секрету, или статусу, полученному вами через подписанный запрос к API. Редирект на страницу успеха и кнопку «Я оплатил» может вызвать кто угодно. Выдавайте товар из обработчика вебхука, а не из чата.- Что будет, если сервер с вебхуком недоступен?
Неудачная доставка повторяется, всего до 5 попыток. Если сервер лежал дольше, бот может восстановиться, запросив статус счёта через
GET /api/v1/invoice/:id. Хорошая страховка — периодическая задача, которая так проверяет все заказы в ожидании.- Какую монету принимать в Telegram-боте?
Выставляйте счёт в долларах и дайте покупателю выбрать монету на странице оплаты: одна кнопка закрывает большинство пользователей. В телеграм-аудитории часто платят в USDT, обычно в сетях TRON или TON. BTC или TON добавляйте, если о них просят.
- Можно ли написать то же самое на Node.js и grammY?
Да, схема та же: один раз собрать JSON, подписать его через
crypto.createHmac('sha256', secret), отправить с заголовкамиX-API-KeyиX-API-Signи проверять вебхуки по сырому телу запроса. Формат запросов описан в документации API, его можно реализовать на любом языке.