Telegram bot
A Telegram bot that sells something has one awkward moment: the buyer leaves the chat to pay and has to come back. This recipe closes that loop with no public URL and no polling loop: the checkout page returns the buyer into the bot, the bot confirms with one API call, and the webhook stays the guarantee for buyers who never come back.
The flow, in three calls:
- Create the payment with
success_urlset to a deep link back into your bot. - On
/start paid_<order>, read the payment once withGET /v1/payments/{id}and deliver if it is confirmed. - Receive the webhook for everyone who paid and closed Telegram - that is the production path.
Every step is shown as a plain HTTP call first and as the Node and Python SDK forms beside it; use whichever fits your bot.
1. Create the payment with a deep link back
Telegram's start parameter allows only A-Za-z0-9_- and at most 64 characters, so use an order id that fits - a short id or a UUID without braces - both as external_id and in the link.
curl -X POST https://api.cryptunnel.io/v1/payments/widget \
-H 'content-type: application/json' \
-H 'x-merchant-id: <merchant id>' \
-H 'x-api-key: <api key>' \
-d '{
"amount": 10,
"currency": "USD",
"external_id": "order-1042",
"success_url": "https://t.me/my_shop_bot?start=paid_order-1042",
"callback_url": "https://shop.example.com/cryptunnel",
"is_test": true
}'const payment = await cryptunnel.createWidgetPayment({
amount: 10,
currency: 'USD',
externalId: order.id,
successUrl: `https://t.me/my_shop_bot?start=paid_${order.id}`,
callbackUrl: 'https://shop.example.com/cryptunnel',
})
await orders.setPaymentId(order.id, payment.id)
await ctx.reply('Pay here:', Markup.inlineKeyboard([Markup.button.url('Pay 10 USD', payment.url)]))payment = await cryptunnel.create_widget_payment(
10, "USD", order.id,
success_url=f"https://t.me/my_shop_bot?start=paid_{order.id}",
callback_url="https://shop.example.com/cryptunnel",
)
await orders.set_payment_id(order.id, payment["id"])
keyboard = InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text="Pay 10 USD", url=payment["url"])]])
await message.answer("Pay here:", reply_markup=keyboard)Store the returned id against your order: the bot needs it in step 2, and the webhook carries it in step 3. Creating a payment is idempotent on external_id, so a retried create returns the same payment instead of a second one.
The response's url is the checkout page. The buyer picks a coin, pays, and after confirmation the page redirects to your success_url - which opens the bot with /start paid_order-1042. See Payment statuses, balance and widget URL for exactly when the redirect fires.
2. Confirm on /start paid_<order>
/start paid_<order>One read, no loop. The redirect is a convenience for the buyer, not proof of payment - a buyer can type the deep link by hand - so the bot always checks with the API before delivering.
curl https://api.cryptunnel.io/v1/payments/n9cdFaTccYbXecVekHKW8Q \
-H 'x-merchant-id: <merchant id>' \
-H 'x-api-key: <api key>'bot.start(async (ctx) => {
const [, orderId] = ctx.startPayload.match(/^paid_(.+)$/) ?? []
if (!orderId) return ctx.reply('Welcome!')
const order = await orders.get(orderId)
const payment = await cryptunnel.getPayment(order.paymentId)
if (payment.status === 'confirmed' || payment.status === 'confirmed_manual') {
await deliver(order)
return ctx.reply('Paid - here is your order.')
}
return ctx.reply('Payment not confirmed yet - you will get your order the moment it is.')
})@router.message(CommandStart(deep_link=True))
async def on_start(message: Message, command: CommandObject):
if not command.args or not command.args.startswith("paid_"):
return await message.answer("Welcome!")
order = await orders.get(command.args.removeprefix("paid_"))
payment = await cryptunnel.get_payment(order.payment_id)
if payment["status"] in ("confirmed", "confirmed_manual"):
await deliver(order)
return await message.answer("Paid - here is your order.")
await message.answer("Payment not confirmed yet - you will get your order the moment it is.")Make deliver safe to call twice: step 3 may deliver the same order first.
3. The webhook is the guarantee
The buyer who pays and closes Telegram never triggers step 2. The webhook does: Cryptunnel calls your callback_url on every terminal status and retries a failed delivery 60 times, once a minute, for an hour. Verify the signature, deduplicate on (id, status), deliver on confirmed or confirmed_manual.
# what Cryptunnel sends you - verify x-webhook-signature over "<x-webhook-timestamp>.<raw body>"
POST https://shop.example.com/cryptunnel
x-webhook-timestamp: 1758945600
x-webhook-signature: 8f2a...c41e
content-type: application/json
{"id":"n9cdFaTccYbXecVekHKW8Q","external_id":"order-1042","status":"confirmed", ...}app.post('/cryptunnel', express.raw({ type: 'application/json' }), async (req, res) => {
if (!verifyWebhook(WEBHOOK_SECRET, req.headers, req.body)) return res.sendStatus(401)
const payment = JSON.parse(req.body.toString())
if (payment.status === 'confirmed' || payment.status === 'confirmed_manual') {
await deliver(await orders.get(payment.external_id))
}
res.sendStatus(200)
})async def callback(request: web.Request):
raw_body = await request.read()
if not verify_webhook(WEBHOOK_SECRET, request.headers, raw_body):
return web.Response(status=401)
payment = json.loads(raw_body)
if payment["status"] in ("confirmed", "confirmed_manual"):
await deliver(await orders.get(payment["external_id"]))
return web.Response(status=200)A bot on long polling has no public URL for the webhook - that is fine: the webhook receiver is a tiny separate web process, or a route on the web app you already run. The bot itself never needs to be reachable from the internet.
The full contract - every payload field, retries, deduplication, raw body per framework - is on the webhooks page.
Polling is for development. The SDKs shipwait_for_payment/waitForPaymentfor scripts and for trying the flow locally. Do not build a bot on it: a process that dies mid-wait loses the payment, and the webhook does not.
Try it in the sandbox
Create the payment with is_test: true (or a sandbox client): the checkout page offers testnet coins, the fee is not charged, and the whole loop - link, deep link back, webhook - runs exactly as in production. Sandbox and faucets has the wallet and the coins.
Updated 13 days ago
