DEV Community

Cover image for Selling digital goods from a Telegram bot: a test-first payment integration
Buhgalter
Buhgalter

Posted on

Selling digital goods from a Telegram bot: a test-first payment integration

Most payment tutorials start with the happy path. Create a payment, get paid, ship the product. Then production happens: a webhook arrives twice, the bot restarts mid-delivery, a buyer pays an expired link. This post walks through how I would wire up payments for a bot that sells game keys or in-game currency, starting from tests instead of from the checkout button.

The examples target the Buhgalter payments API. Field names are simplified; check the public docs for exact schemas.

The shape of the flow

  1. The buyer taps Buy in the bot.
  2. The bot calls POST /api/payments with the amount in rubles and its own order id as the reference.
  3. The API returns a payment link or a ready QR (SBP or NSPK QR).
  4. The buyer pays in their banking app.
  5. A signed webhook hits your server, you verify it and deliver the key.

Five steps, and every one of them can fail independently.

Start in test mode

Send the X-Buhgalter-Test header and you get test payments, up to 200 per day. That is enough to run an integration suite on every push. My suite has four cases:

  • paid once, key delivered once;
  • webhook delivered twice, key still delivered once;
  • link expired, nothing delivered, stock released;
  • refund issued, key marked revoked.

If those four pass, the rest is mostly UI.

Idempotency on the way in

Buyers double tap. Bots retry on timeouts. Use the order id for both the reference and the Idempotency-Key header:

def create_payment(order):
    return session.post(
        f"{BASE}/api/payments",
        json={"amount": order.rub, "reference": order.id},
        headers={"Idempotency-Key": f"order-{order.id}"},
        timeout=65,
    )
Enter fullscreen mode Exit fullscreen mode

The same key returns the same payment, so the buyer never sees two QR codes for one order.

Idempotency on the way out

Webhooks are retried up to 8 times over roughly 13 hours, and you can resend manually from the delivery log in the dashboard. So the handler must be safe to run twice:

def on_paid(event):
    with db.transaction():
        order = db.orders.lock(event["reference"])
        if order.status == "delivered":
            return
        key = db.keys.reserve(order.sku)
        order.mark_delivered(key)
    bot.send(order.chat_id, f"Your key: {key}")
Enter fullscreen mode Exit fullscreen mode

Lock the row, check the status, then deliver. Sending the message after commit means a crash never leaves a delivered key without a record.

Verify the signature on the raw body

Each webhook carries X-Buhgalter-Signature, an HMAC-SHA256 of the body. Compute it over the raw bytes before any JSON parsing, and compare with a constant-time function. Skipping this step lets anyone forge a paid event and drain your key stock.

Expiry and stock

Payment links live 30 minutes by default, configurable from one minute to a day. For limited keys I reserve stock when the payment is created and release it on expiry. A shorter link lifetime keeps popular items from being locked by people who walked away.

Refunds

Refunds can be full or partial and carry no platform fee. In a key shop the rule I use is simple: unactivated key, full refund; activated key, case by case. Whatever you choose, write it in the bot description before the first sale.

Getting paid out

The balance defaults to USDT, with the rate locked when the payment is created, or you can keep it in rubles. Withdrawals go to a bank account, a card, or a USDT TRC-20 wallet. If your supplier sells keys for crypto, auto payout to a wallet removes a manual step from restocking.

Wrap up

Write the four tests first, then build the button. It is a boring order of work and it saves a lot of late night debugging. The docs, with samples in cURL, PHP, Python, Node.js and Java, are at https://buhgalter.app.

Alena Kotenkova, Buhgalter team

Top comments (0)