Skip to content
For Discord bots

Crypto payments for your Discord bot

One API call makes the invoice. A signed webhook tells your bot it is paid. 0.5% per payment.

Shop, autobuy and ticket bots can take Litecoin, USDT, Bitcoin and more with CoinsFlow. Your bot makes an invoice and posts 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, autobuy or ticket flow

  1. A buyer runs your /buy command or opens a ticket. Your bot makes an invoice with POST /v1/invoices: the price in dollars, the coin, and the buyer's Discord ids in metadata, which the buyer never sees.
  2. The bot answers with a link button to the invoice's paymentUrl. The payment page shows the address, the exact amount, a QR code and a countdown.
  3. Seconds after the buyer sends Litecoin or Bitcoin, invoice.detected reaches your webhook: post “payment seen” in the ticket. Tick this event when you add the endpoint: it is not one of the defaults.
  4. At the first confirmation, about 2.5 minutes on Litecoin, invoice.paid arrives. Deliver the product or grant the role, once per invoice.
  5. Withdraw to your own wallet when you like: by hand, automatically, or from the bot with POST /v1/withdrawals.

A $20 Litecoin order, in numbers

Price$20.00, asked in LTC at the current price, rounded up to 6 decimals
Payment seenseconds after the buyer sends it: invoice.detected
Paidat 1 confirmation, about 2.5 minutes: invoice.paid
Our fee$0.10 (0.5%), taken in LTC when it is credited
Buyer’s network feeusually a fraction of a cent, paid by the buyer on top
In your balanceabout $19.90 worth of LTC, withdrawable at once

The /buy command

POST /v1/invoices
import { ActionRowBuilder, ButtonBuilder, ButtonStyle, MessageFlags } from "discord.js";

client.on("interactionCreate", async (interaction) => {
  if (!interaction.isChatInputCommand() || interaction.commandName !== "buy") return;
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });
  const res = await fetch("https://api.coinsflow.app/v1/invoices", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.COINSFLOW_API_KEY}`,
      "Idempotency-Key": interaction.id, // a retry never makes a second invoice
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      chain: "litecoin", asset: "LTC", priceAmount: "20.00",
      description: "VIP role",
      metadata: { userId: interaction.user.id, guildId: interaction.guildId, channelId: interaction.channelId },
    }),
  });
  if (!res.ok) return interaction.editReply("Payments are not available right now. Try again in a minute.");
  const invoice = await res.json();
  const pay = new ButtonBuilder().setStyle(ButtonStyle.Link).setLabel("Pay in Litecoin").setURL(invoice.paymentUrl);
  await interaction.editReply({
    content: "Your invoice for the VIP role. The page shows the address, the amount and a QR code.",
    components: [new ActionRowBuilder().addComponents(pay)],
  });
});

The 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. Every field is in the API docs.

The webhook: seen, then paid

POST /webhooks/coinsflow
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?.channelId) return res.sendStatus(200); // not one of the bot's invoices
  try {
    const channel = await client.channels.fetch(metadata.channelId);
    if (event.type === "invoice.detected") {
      await channel.send(`<@${metadata.userId}> Payment seen. It counts at the first confirmation.`);
    }
    if (event.type === "invoice.paid" && !(await delivered(id))) { // your database: once per invoice
      const guild = await client.guilds.fetch(metadata.guildId);
      const member = await guild.members.fetch(metadata.userId);
      await member.roles.add(process.env.VIP_ROLE_ID);
      await markDelivered(id);
      await channel.send(`<@${metadata.userId}> Paid. Your role 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. A bot that cannot take requests 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.

  1. 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.
  2. When the trade is done, your bot asks for a quote with GET /v1/withdrawals/quote, then pays the seller with POST /v1/withdrawals, with the quoted fee as maxFee and an Idempotency-Key.
  3. The seller receives exactly the amount sent; the fee comes from your balance on top. withdrawal.confirmed tells 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 grants a role on your live bot. Follow Discord's own rules for bots as well as ours.

Questions

Does the buyer need a CoinsFlow account?

No. They open the link and pay from any wallet or exchange that can send the coin. A buyer who already holds the coin in a CoinsFlow balance can also pay from it, with Pay with CoinsFlow.

What if the buyer sends the wrong amount?

Within 0.5% it counts as paid. If less arrives, invoice.underpaid tells you how much is missing (amountRemaining): the buyer can send the rest to the same address, or you accept what came with POST /v1/invoices/{id}/accept. If more arrives, all of it is credited to you: invoice.paid, then invoice.overpaid.

Can the buyer pick the coin?

Yes. Leave out chain and asset and give priceAmount in dollars: the payment page lets the buyer pick Litecoin, Bitcoin, Ether, BNB, USDT or USDC. The webhook then names the coin they chose.

Can my bot pay people out?

Yes. Any API key can withdraw: ask for a quote with GET /v1/withdrawals/quote, then POST /v1/withdrawals with the quoted fee as maxFee and an Idempotency-Key, so a retry never sends twice. The smallest Litecoin withdrawal is 0.0001 LTC.

How many invoices can my bot make?

120 a minute for one merchant and 240 for one account, with up to 10,000 waiting to be paid at once. An invoice stays open for at least 3 days, so cancel the ones from closed tickets with POST /v1/invoices/{id}/cancel, while nothing has been sent to them.

Can I test it without money?

Yes. Add "test": true when you make the invoice and simulate the payment, from the API or with the buttons on its payment page. The same signed webhooks arrive, marked "test": true, and no money moves. A merchant can make 500 test invoices a day.

Where do I keep the API key?

In your bot’s server settings, such as an environment variable, never in code you share. The key can withdraw your balance. If it may have leaked, rotate it in the dashboard: the old one stops working at once.