DEV Community

Cover image for Accepting ruble payments in a Telegram bot: webhooks, idempotency and signature checks
Buhgalter
Buhgalter

Posted on

Accepting ruble payments in a Telegram bot: webhooks, idempotency and signature checks

I am on the team at Buhgalter (buhgalter.app), a payment API for Russian instant payments (SBP, QR codes, card transfers) with payouts and USDT settlement. This post is a practical walkthrough for developers who want to wire payments into a bot or a small web app. Most of it applies to any payment API.

Create a payment

One call: POST /api/payments. Amount is in RUB, from 1 to 100,000,000. You pass your own reference (your order id) and optional return URLs.

You choose between sync and async. Sync keeps the connection open until a payment detail or QR is ready, up to 60 seconds. Async returns immediately and delivers the result to your webhook. For a bot, sync is simpler. For anything with a job queue, go async.

Idempotency done right

Send an Idempotency-Key header. A retry with the same key returns the existing payment instead of a new one. We also dedupe on reference.

Derive the key from your order id. If you generate a fresh UUID on every attempt, a timeout plus a retry still produces two invoices.

Verify the webhook

Every webhook carries X-Buhgalter-Signature, an HMAC-SHA256 of the raw body. In Node.js:

const crypto = require("crypto");
function valid(rawBody, header, secret) {
  const sig = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(header));
}
Enter fullscreen mode Exit fullscreen mode

Use the raw bytes. Express with express.json() will hand you a parsed object, and re-serializing it breaks the signature. Use express.raw() on this route.

Expect duplicates and downtime

Delivery is retried up to 8 times over about 13 hours. The dashboard shows a delivery log and lets you resend any event. Your handler must be idempotent: mark the order paid once, return 200 on repeats.

Grant access only on the paid status. Not on creation, not on redirect back from the payment page.

Test mode

Add the X-Buhgalter-Test header to run in sandbox, up to 200 test payments a day. Run success, cancel and expiry. Payment links live 30 minutes by default, configurable from 1 minute to 24 hours.

Payouts and FX

POST /api/payouts sends money to a card, phone or account. GET /api/fx returns the current USDT/RUB rate. The rate for each payment is fixed at creation, and your balance can be held in USDT and auto-withdrawn to a TRC-20 wallet.

Docs with cURL, PHP, Python, Node.js and Java examples: https://buhgalter.app/docs . A first QR takes about ten minutes, the full setup with webhook verification about an hour.

Questions welcome in the comments.

Alena Kotenkova, Buhgalter team

Top comments (1)

Collapse
 
fintechrailsdigest profile image
Fintech Rails Digest •

The express.raw() note deserves more airtime than it gets. Most signature failures I have seen were not weak HMACs, they were frameworks that re-serialized the body before verification, so the digest was computed over different bytes than the ones that arrived. Keying the idempotency value off the order id rather than a fresh UUID per attempt is the other quiet win: a timeout-then-retry pair looks identical to the gateway and you get the original payment object back instead of a second capture. Granting access only on the paid webhook status, never the redirect, is the rule everyone learns once, expensively.