How to Accept Crypto Payments in a Telegram Bot (Python Guide)

A working aiogram 3 example for Telegram bot crypto payments, with a signed invoice request, a payment link button and a verified webhook that unlocks the order.

23‏/10‏/2026قراءة 13 دقائق
Telegram-style bot chat with a 25 USDT invoice, a pay button and a paid reply, beside bot code

The simplest way to take crypto payments in a Telegram bot is to let a payment gateway do the blockchain work: your bot asks the gateway for an invoice, sends the customer a button with the payment link, and waits for a signed webhook that says the invoice is paid. The bot never touches private keys, never watches the chain and never has to guess which transfer belongs to which user.

This guide builds exactly that in Python with aiogram 3 and aiohttp. You will create an invoice with an HMAC-signed request, hand the customer a payment button, verify the webhook signature, and deliver the product only after the payment is confirmed. The code uses the Zypher API as the example, but the structure (signed request out, signed webhook in, idempotent order update) carries over to most gateways.

Before you write any code, read the next section. Telegram has its own rules about what can be paid for outside Telegram's built-in payment system, and they decide whether this approach fits your bot at all.

Telegram's rules come first

Telegram's Bot Payments API for digital goods says that payments for digital goods and services sold inside Telegram apps must use Telegram Stars. Physical goods and services delivered in the real world can use other currencies and providers. As of October 2026 that is how the page reads; it changes from time to time, so check it yourself before launch.

In practice, bots that take crypto through an external payment page are typically used for things like physical products, services that are delivered outside Telegram, B2B invoicing, top-ups for an external account, and donations. If your bot sells in-app digital content to mobile users, read Telegram's terms carefully and decide with your own counsel. Nothing here is legal advice.

Four ways to take crypto in a bot

ApproachHow it worksGood forTrade-offs
Telegram StarsNative sendInvoice with Stars as the currencyDigital goods inside TelegramNot crypto in your wallet; Telegram's own payout rules apply
Telegram-native crypto bots (Crypto Pay, Wallet Pay)You call the bot's API, the user pays inside TelegramUsers who already hold crypto in those walletsCoins, networks and fees depend on the provider; fees are not always published
Payment gateway with invoice linksYour bot creates an invoice via API and sends a link to a hosted payment pageMany coins and networks, unique address per order, webhooksYou need a small HTTPS endpoint for webhooks
Watching your own walletPoll a node or explorer and match incoming transfersSingle coin, full controlMatching payments to users, confirmations, underpayments and reorgs are all on you

The TON developer docs show the last approach for TON: the bot polls the wallet and remembers the last processed transaction. It works, but once you want USDT on several networks, Bitcoin and a hosted page with a QR code, the gateway approach saves a lot of code. The rest of this article uses it.

How the payment flow works

  1. The user presses Buy in your bot.

  2. Your server creates an order in its database and calls POST /api/v1/invoice with the price and your order ID.

  3. The gateway returns an invoice that includes a link to the hosted payment page.

  4. The bot sends that link as an inline button.

  5. The customer picks a coin and network on the page (for fiat-priced invoices), scans the QR code and pays to the invoice's unique deposit address.

  6. After the required confirmations, the gateway sends a signed invoice.paid webhook to your server.

  7. Your server verifies the signature, marks the order paid and the bot delivers the product.

The important design rule: the bot delivers on the webhook, never on a button the user can press. A "I've paid" button is fine as a way to check status, but the answer must come from the gateway.

What you need

  • A bot token from @BotFather.

  • Python 3.10+ with aiogram 3.x installed (aiohttp comes with it).

  • A store in Zypher with an API key and API secret. Opening a store needs no documents; sign-up takes a minute with an email code, Telegram or a passkey.

  • A public HTTPS URL for the webhook endpoint. A small VPS behind nginx or Caddy works; for local development, any HTTPS tunnel will do.

  • Somewhere to keep orders. The example uses a dict to stay short. Use SQLite, Postgres or Redis in production so a restart does not lose paid orders.

In your store settings, set the webhook URL to https://your-domain.example/zypher/webhook. If your store has an IP allowlist enabled, add the IP your bot server calls from.

Signing API requests

Every Zypher API request carries two headers:

  • X-API-Key: your store's API key.

  • X-API-Sign: hex HMAC-SHA256 of the JSON body, keyed with your API secret. For GET requests, sign the empty string.

Most "invalid signature" errors come from signing one string and sending another. Serialize the payload once, sign that exact string, and send those exact bytes. Use compact JSON, keep amounts as strings rather than floats, and do not let your HTTP client re-encode the body (in aiohttp, pass data=, not json=).

The full bot

# bot.py - aiogram 3 + aiohttp. Env vars: 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] = {}   # use a real database in production


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]:
    # Take the invoice ID and the payment page URL from the create response.
    # Field names are listed in https://zypher.exchange/docs - adjust if they differ.
    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",          # priced in USD; the customer picks coin and network
        "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="Pay $10 in crypto", url=pay_url)],
        [InlineKeyboardButton(text="Check payment", callback_data=f"check:{order_id}")],
    ])
    await message.answer(
        "Your order is ready. Pick a coin on the payment page and send the exact "
        "amount shown. You have 60 minutes to choose a coin.", 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("Order not found", show_alert=True)
        return
    if order["status"] == "paid":
        await cb.answer("Paid, thank you!", show_alert=True)
        return
    invoice = await zypher("GET", f"/v1/invoice/{order['invoice_id']}")
    await cb.answer(f"Invoice status: {invoice.get('status', 'unknown')}", show_alert=True)


async def zypher_webhook(request: web.Request) -> web.Response:
    raw = await request.read()                       # raw bytes, before any parsing
    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")))     # the orderId you sent; see /docs
    if order is None:
        return web.Response(text="unknown order")    # 200, so it is not retried forever
    if order["status"] == "paid":
        return web.Response(text="ok")               # duplicate delivery

    order["status"] = "paid"
    await bot.send_message(order["user_id"],
                           "Payment confirmed. Your Pro plan is active for 30 days.")
    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()  # put behind HTTPS
    await dp.start_polling(bot)


if __name__ == "__main__":
    asyncio.run(main())

Run it with python bot.py, send /buy to the bot, and pay a small amount to see the whole loop. There is no sandbox, so test with the cheapest network you plan to support (for example USDT on TRON or TON) and a low price.

What each part does

Creating the invoice

The request prices the order in USD with fiatAmount. On a fiat-priced invoice the customer chooses the coin and network on the hosted page, so one button covers USDT, USDC, BTC, TON and the rest of the coins you enabled. If you want to force a single asset instead, you can create the invoice in a specific coin and network. The request fields shown here match the API as of October 2026; the API docs have the full list.

The orderId is your own ID. It comes back in the webhook, which is how you find the right Telegram user. Do not reuse it across purchases.

The payment page has QR codes, a live countdown and clear underpaid, overpaid and expired states, and it is available in 11 languages, so you do not need to explain the mechanics in the chat. Keep the bot message short: price, what the user gets, time limit.

Verifying the webhook

The webhook is a POST with a JSON body {event, data, timestamp} and two headers: X-Webhook-Signature (hex HMAC-SHA256 of the raw body with your API secret) and X-Webhook-Event. Three details matter:

  1. Hash the raw bytes. Parsing and re-serializing JSON changes spacing and key order and breaks the comparison.

  2. Compare in constant time with hmac.compare_digest.

  3. Act only on invoice.paid with data.status equal to PAID or OVERPAID.

For extra safety you can do what many plugins do: after a valid webhook arrives, read the invoice once more with the signed GET request and use that status.

Idempotency and retries

A failed delivery is retried, up to 5 attempts in total. Your handler must therefore be safe to run twice: check whether the order is already paid before you grant access or send a message. Return a 2xx quickly, and do heavy work (issuing licences, provisioning servers) in a background task.

Edge cases you will meet

SituationWhat happensWhat the bot should do
User pays lateA coin invoice expires after 30 minutes. A fiat invoice gives 60 minutes to pick a coin, then 30 minutes to pay. A payment that arrives up to 30 minutes after expiry can still be matched.Tell the user the time limit up front. If an invoice expired unpaid, offer /buy again.
User sends slightly less98% or more of the amount counts as paid.Nothing; the webhook arrives as usual.
User sends too littleBelow 98% the invoice is UNDERPAID, and the customer can top up the remainder; partial payments add up.Point them back to the same payment page.
User sends moreAbove 102% the invoice is marked OVERPAID and the webhook still fires.Deliver, and decide your refund policy for large overpayments.
Payment is slowFunds are credited after the network's confirmations: TON 1, BTC 2, BSC 5, Ethereum 6, Solana 8, TRON 10.Say "usually a few minutes" in the message.
User picks the wrong network in their walletThe transfer does not reach the invoice address on the right chain.Name the network in your bot text and remind users to match it in their wallet.

Network choice matters for your users' costs. Our comparison of USDT on TRC20, BEP20, ERC20, TON and Solana covers fees and confirmation times per network.

Production checklist

  • Store orders in a database, keyed by orderId, with a unique constraint.

  • Keep the API secret in an environment variable, never in the repository or in the bot code you share.

  • Serve the webhook over HTTPS and return 2xx within a few seconds.

  • Log the raw webhook body and signature for failed verifications (but not the secret).

  • Turn on the store's IP allowlist if your server has a static IP.

  • Turn on TOTP 2FA and the withdrawal-address whitelist on the merchant account.

  • If you renew access monthly, send a new invoice each period; there is no automatic recurring charge, so a renewal reminder with a fresh payment button works best.

  • If you prefer to hold one asset, enable auto-convert so each payment is swapped (net of commission) into, for example, USDT.

What it costs

Zypher charges a commission per paid invoice, taken from the received amount: 1% on the Start tier, falling to 0.75%, 0.5%, 0.35% and 0.25% as your 30-day paid volume grows past $10k, $50k, $250k and $1M. There are no setup or monthly fees; withdrawals pay the network fee. The pricing page has the details. If you also sell on a website, the same API works there; see our guide to accepting USDT payments on a website.

For more patterns specific to bots, including channels and Mini Apps, see crypto payments for Telegram bots. When you are ready to test the code above with real keys, create a free store and paste the API key and secret into your environment.

FAQ

Can a Telegram bot accept crypto without a payment gateway?

Yes. A bot can watch its own wallet address through a node or an explorer API and match incoming transfers to orders. You then have to handle unique amounts or addresses per order, confirmations, underpayments and several networks yourself. A gateway gives each invoice its own deposit address and sends you one signed webhook when it is paid.

Is it allowed to sell digital goods in a Telegram bot for crypto?

Telegram's Bot Payments rules say digital goods and services sold inside Telegram apps must be paid with Telegram Stars, while physical goods and real-world services may use other providers. The rules change, so read the current version on core.telegram.org before launch. If in doubt, ask a lawyer who knows your market.

How do I know the payment really arrived?

Trust only a webhook whose X-Webhook-Signature you verified against the raw body with your API secret, or a status you fetched yourself with a signed API request. Redirects to a success page and "I paid" buttons can be triggered by anyone. Deliver the product from the webhook handler, not from the chat.

What happens if the webhook endpoint is down?

A failed delivery is retried, up to 5 attempts. If your server is down longer than that, your bot can still recover by reading the invoice status with GET /api/v1/invoice/:id. A periodic job that checks pending orders this way is a good safety net.

Which coin should my Telegram bot accept?

Price the invoice in USD and let the customer choose on the payment page; that covers most users with one button. USDT is a common choice in Telegram audiences, often on TRON or TON. Add BTC or TON if your users ask for them.

Can I use Node.js and grammY instead of Python?

Yes. The flow is identical: build the JSON body once, sign it with crypto.createHmac('sha256', secret), send it with the X-API-Key and X-API-Sign headers, and verify webhooks over the raw request body. The API docs describe the request format, which you can implement in any language.