DEV Community

Cover image for How to accept crypto payments in a Telegram bot (Node.js)
CigalPay
CigalPay

Posted on Originally published at cigalpay.com

How to accept crypto payments in a Telegram bot (Node.js)

Many shops sell through a Telegram bot: customers browse, order and get updates without leaving the chat. Adding crypto payments takes two pieces of code on your server: one that creates the payment when the customer orders, and one that confirms the order when the payment arrives.

In this post we build both with Node.js, grammY and CigalPay (disclosure: I'm part of the team building it). The complete bot, with tests, is on GitHub.

How it works

  1. The customer taps Buy in your bot and sends a delivery address.
  2. Your server creates a payment, priced in USD or EUR, and the bot answers with a Pay in crypto button.
  3. The checkout opens inside Telegram. The customer picks a coin (USDT, Bitcoin, Solana…) and sends the exact amount shown, locked for 30 minutes.
  4. When the network confirms the payment, your server receives a signed notification.
  5. Your server checks it, the bot confirms the order to the customer, and you get the paid order in your own chat.

What Telegram allows. Telegram's terms for bot developers let bots use any payment provider for physical goods and real-world services. Digital goods and services sold inside Telegram (channel access, files, courses, subscriptions) must be paid in Telegram Stars. This example sells a physical product.

Setup

npm install grammy cigalpay-js-sdk
Enter fullscreen mode Exit fullscreen mode

You need a bot token from @BotFather, and your CigalPay Account ID and notification signing secret (dashboard, Settings β†’ API).

1. Create the payment when the customer orders

Once the customer has sent their address, create a checkout with the order's price and your own order id, keep the checkout id with the order, and send the payment link as a button:

import { Bot, InlineKeyboard } from 'grammy';
import CigalPay from 'cigalpay-js-sdk';

const bot = new Bot(process.env.BOT_TOKEN);
const cigalpay = new CigalPay({ accountId: process.env.CIGALPAY_ACCOUNT_ID });

// After the customer sent their address:
const checkout = await cigalpay.createCheckout({
  amount: 24.9,
  currency: 'EUR',
  orderId: order.id,                  // sent back as extraId in the notification
  description: 'Coffee beans, 1 kg',
  returnUrl: `https://t.me/${bot.botInfo.username}`,
  notifyUrl: 'https://your-server.example.com/cigalpay/notify',
});
order.checkoutId = checkout.id;       // checked when the notification arrives

await ctx.reply('Tap to pay. The price is locked for 30 minutes.', {
  reply_markup: new InlineKeyboard().url('Pay in crypto', checkout.chargeLink),
});
Enter fullscreen mode Exit fullscreen mode

2. Confirm the order from the signed notification

The payment's status is posted to your notifyUrl each time it changes, signed with HMAC-SHA512 in the x-cigalpay-sig header. Verify the raw body first, then confirm only once it's paid:

import CigalPay, { isPaid } from 'cigalpay-js-sdk';

// raw: the request body exactly as received
if (!CigalPay.verifyWebhook(raw, req.headers['x-cigalpay-sig'], process.env.CIGALPAY_NOTIFICATION_SECRET)) {
  return res.writeHead(401).end();
}
const payment = JSON.parse(raw);
const order = orders.get(payment.extraId);

// Only the checkout this bot created, at this price.
if (!order || payment.checkoutId !== order.checkoutId) return res.writeHead(200).end();
if (payment.checkoutCurrency !== order.currency || Math.abs(Number(payment.checkoutAmount) - order.amount) > 0.01) {
  return res.writeHead(200).end();
}

if (isPaid(payment.status) && order.status !== 'paid') {
  order.status = 'paid';              // notifications can repeat: confirm once
  await bot.api.sendMessage(order.telegramUserId, 'βœ… Payment confirmed, your order is on its way.');
  await bot.api.sendMessage(process.env.MERCHANT_CHAT_ID, `πŸ›’ New paid order ${order.id}, ship to: ${order.address}`);
}
res.writeHead(200).end();
Enter fullscreen mode Exit fullscreen mode

The three checks that protect your orders

Check Why
The signature Without it, anyone could post a fake "paid" status to your server.
The checkout id Your Account ID isn't secret: someone could create their own checkout for your order number, pay $1 and get a genuine signed notification. Only the checkout you created counts.
The price The checkout amount and currency must still match the order.

Never confirm an order because the customer came back to the chat: only the signed notification proves the payment.

Payments that aren't simple

  • CONFIRMING: on the network, not final. Tell the customer you're waiting; don't ship yet.
  • PARTIALLY_PAID: less arrived, often because an exchange took its fee. The customer can send the rest to the same address.
  • EXPIRED: the 30 minutes passed. Offer a new Buy button: a new checkout gets a fresh rate.

The complete bot

The example on GitHub puts it all together in about 250 lines: product, delivery address, checkout, the notification checks, the customer's confirmation and the order sent to your chat, in English and French. Its tests run the whole purchase against a fake Telegram and a fake payment API. To sell a service instead, ask for a date or a table instead of an address.

Full guide on the CigalPay blog: How to Accept Crypto Payments in a Telegram Bot.

Questions about confirmations or partial payments? Ask in the comments.

Top comments (0)