CoinsFlow is a crypto payment gateway with an API a Telegram bot can call. Shops, subscriptions and services run through a bot take Litecoin, USDT, Bitcoin and the other coins we take: your bot makes an invoice and sends a Pay button. A signed webhook tells it, within seconds, that a payment is on its way, and on Litecoin about 2.5 minutes later that it is paid. The buyer needs no account.
A shop or a service, sold by your bot
- A buyer sends your
/buycommand, or picks a product from your bot's menu. Your bot makes an invoice withPOST /v1/invoices: the price in dollars, the coin, and the chat and user ids inmetadata, which the buyer never sees. - The bot answers with an inline button to the invoice's
paymentUrl. The payment page shows the address, the exact amount, a QR code and a countdown. - Seconds after the buyer sends Litecoin or Bitcoin,
invoice.detectedreaches your webhook: tell the buyer the payment is seen. Tick this event when you add the endpoint: it is not one of the defaults. - At the first confirmation, about 2.5 minutes on Litecoin,
invoice.paidarrives. Deliver the product or switch the service on, once per invoice. - Withdraw to your own wallet when you like: by hand, automatically, or from the bot with
POST /v1/withdrawals.
A $30 Litecoin order, in numbers
| Price | $30.00, asked in LTC at the current price, rounded up to 6 decimals |
|---|---|
| Payment seen | seconds after the buyer sends it: invoice.detected |
| Paid | at 1 confirmation, about 2.5 minutes: invoice.paid |
| Our fee | $0.15 (0.5%), taken in LTC when it is credited |
| Buyer’s network fee | usually a fraction of a cent, paid by the buyer on top |
| In your balance | about $29.85 worth of LTC, withdrawable at once |
The /buy command
import { Bot, InlineKeyboard } from "grammy";
const bot = new Bot(process.env.BOT_TOKEN);
bot.command("buy", async (ctx) => {
const res = await fetch("https://api.coinsflow.app/v1/invoices", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.COINSFLOW_API_KEY}`,
"Idempotency-Key": `tg-${ctx.chat.id}-${ctx.msg.message_id}`, // a retry never makes a second invoice
"Content-Type": "application/json",
},
body: JSON.stringify({
chain: "litecoin", asset: "LTC", priceAmount: "30.00",
description: "Premium, 1 month",
metadata: { chatId: String(ctx.chat.id), userId: String(ctx.from.id) },
}),
});
if (!res.ok) return ctx.reply("Payments are not available right now. Try again in a minute.");
const invoice = await res.json();
await ctx.reply("Your invoice for Premium. The page shows the address, the amount and a QR code.", {
reply_markup: new InlineKeyboard().url("Pay in Litecoin", invoice.paymentUrl),
});
});
bot.start(); // long polling; or webhookCallback(bot, "express") in the Express app belowThe amount follows the Litecoin price once an hour until it is paid, so the bot sends the link, not a number: the page always shows what is due. The Idempotency-Key makes a retried command safe: the same key gets the same invoice back. Every field is in the API docs.
The webhook: seen, then paid
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.COINSFLOW_WEBHOOK_SECRET; // whsec_...
const app = express();
function verified(header, body) {
try {
const parts = Object.fromEntries(String(header ?? "").split(",").map((p) => p.split("=", 2)));
const t = String(parts.t ?? "");
if (!/^\d{1,12}$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const given = Buffer.from(String(parts.v1 ?? ""), "hex");
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(body).digest();
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
} catch {
return false;
}
}
app.post("/webhooks/coinsflow", express.raw({ type: "application/json" }), async (req, res) => {
if (!Buffer.isBuffer(req.body) || !verified(req.get("CoinsFlow-Signature"), req.body)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString("utf8"));
if (!event.type.startsWith("invoice.")) return res.sendStatus(200);
if (event.test && !process.env.TESTING) return res.sendStatus(200); // test events only while you test
const { id, metadata } = event.data;
if (!metadata?.chatId) return res.sendStatus(200); // not one of the bot's invoices
try {
if (event.type === "invoice.detected") {
await bot.api.sendMessage(metadata.chatId, "Payment seen. It counts at the first confirmation.");
}
if (event.type === "invoice.paid" && !(await delivered(id))) { // your database: once per invoice
await grantPremium(metadata.userId); // your bot's own records
await markDelivered(id);
await bot.api.sendMessage(metadata.chatId, "Paid. Premium is on.");
}
res.sendStatus(200);
} catch {
res.sendStatus(500); // CoinsFlow sends it again later
}
});
app.listen(3000);Add the endpoint in the dashboard, under Developers, with invoice.detected and invoice.paid ticked. It must be an https:// address on the public internet, which a bot on long polling does not otherwise need: the Node example puts it in the bot's own Express app, the Python one runs as a small server beside the bot and sends its messages through the Bot API. A bot that cannot take requests at all can ask GET /v1/invoices/{id} for the status instead. How the signature works is in the docs.
A middleman flow, run by your bot
If your bot holds a trade until both sides are done, your CoinsFlow balance can be where the money waits.
- Your bot makes an invoice for the trade, and the buyer pays it. The money is credited to your balance when the invoice is paid: on Litecoin, at the first confirmation.
- When the trade is done, your bot asks for a quote with
GET /v1/withdrawals/quote, then pays the seller withPOST /v1/withdrawals, with the quoted fee asmaxFeeand anIdempotency-Key. - The seller receives exactly the amount sent; the fee comes from your balance on top.
withdrawal.confirmedtells your bot the payout is confirmed on chain.
A buyer who holds the same coin in a CoinsFlow balance can pay from it with Pay with CoinsFlow: the invoice is paid and final at once, with no block to wait for.
This is your bot and your service: the money is in your balance, and your bot decides when it moves. Letting other sellers take payments through your bot as their own payment service needs our written agreement first (Terms, section 6).
Test it first
Make the invoice with "test": true, then press the buttons on its payment page, or call POST /v1/invoices/{id}/simulate. Your bot gets the same webhooks, marked "test": true, and no money moves. The webhook code above acts on them only while TESTING is set, so a test never switches Premium on for a real buyer. Follow Telegram's own rules for bots as well as ours.