payments
build telegram payments, stars invoices, shipping checks, pre-checkout validation, and paid media flows with yaebal.
InvoiceBuilder plus onPreCheckout/onSuccessfulPayment options on one payments() plugin, and adds Stars Subscription API helpers. this guide still covers the underlying protocol
directly for shipping/paid-media cases the plugin doesn't wrap.import { createBot } from "yaebal";
import { cancelSubscription, invoice, isStarsPayment, payments } from "@yaebal/payments";
const grantAccess = async (payload: string, userId: number) => {};
const isOrderValid = async (payload: string, userId: number) => true;
export const bot = createBot(process.env.BOT_TOKEN!)
.install(
payments({
onPreCheckout: (ctx, query) => isOrderValid(query.invoice_payload, query.from.id),
// return true/undefined to approve, false or a string to decline — see
// PreCheckoutDecision for the full { ok, errorMessage } form.
onSuccessfulPayment: async (ctx, payment) => {
await grantAccess(payment.invoice_payload, ctx.from!.id);
await ctx.reply(isStarsPayment(payment) ? "thanks for the stars!" : "payment received");
},
}),
)
.command("upgrade", (ctx) =>
ctx.sendInvoice(
invoice("pro plan", "one month of access", "pro_monthly").stars().price("pro", 250).build(),
),
);invoice(title, description, payload) starts the fluent builder — .stars() (the default) or .provider(token, currency) for an external
processor, .price(label, amount) per line item, then .build() for ctx.sendInvoice() or .toCreateInvoiceLinkParams() for a shareable link
instead of an in-chat message. .subscription() turns it into a recurring Stars
subscription (Bot API 7.6+):
import { cancelSubscription, invoice } from "@yaebal/payments";
declare const api: Parameters<typeof cancelSubscription>[0];
// billed again every 30 days until cancelled — forces Stars, subscriptions
// aren't available through external providers.
const subscriptionInvoice = invoice("pro plan", "monthly, cancel anytime", "pro_sub")
.subscription()
.build();
// stays active until the current period ends, matching Telegram's own
// editUserStarSubscription(is_canceled: true).
await cancelSubscription(api, { userId: 12345, telegramPaymentChargeId: "..." });the flow
telegram payments are a four-step protocol: send an invoice, optionally answer a shipping query, answer the pre-checkout query within 10 seconds, then handle the successful payment service event.
send a stars invoice
stars use currency: "XTR". for stars, the provider token is omitted or empty and the
prices array usually has one item.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("upgrade", (ctx) =>
ctx.sendInvoice({
title: "pro plan",
description: "one month of access",
payload: "pro_monthly",
currency: "XTR",
prices: [{ label: "pro", amount: 250 }],
}),
);answer pre-checkout
this is mandatory. if you do not answer, telegram cancels the payment. validate stock, plan id,
user eligibility, and price-derived payloads here. ctx.answer() accepts either a
positional (ok, extra?) for the common case, or a single params object when you need
every field of AnswerPreCheckoutQueryParams at once.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
const isOrderValid = async (payload: string, userId: number) => true;
bot.on("pre_checkout_query", async (ctx) => {
const ok = await isOrderValid(ctx.invoice_payload, ctx.from.id);
await ctx.answer(ok, {
error_message: ok ? undefined : "order is no longer available",
});
});shipping query
if the invoice needs shipping, telegram asks for available shipping options before checkout.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.on("shipping_query", async (ctx) => {
await ctx.answer(true, {
shipping_options: [
{
id: "standard",
title: "standard",
prices: [{ label: "shipping", amount: 0 }],
},
],
});
});successful payment
grant access only after the successful_payment service event arrives. the invoice
payload comes back unchanged, so use it as your internal order key.
import { createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
const grantAccess = async (payload: string, userId: number) => {};
// the "message:successful_payment" filter query narrows ctx.message.successful_payment
// to a plain SuccessfulPayment (media/service fields nest under ctx.message — unlike
// "message:text", which narrows a flat ctx.text) — no manual "if (!payment) return".
bot.on("message:successful_payment", async (ctx) => {
await grantAccess(ctx.message.successful_payment.invoice_payload, ctx.from!.id);
await ctx.reply("payment received");
});import { InlineKeyboard, createBot } from "yaebal";
const bot = createBot(process.env.BOT_TOKEN!);
bot.command("buy", async (ctx) => {
await ctx.sendInvoice({
title: "coffee",
description: "support the bot with stars",
payload: "coffee",
currency: "XTR",
prices: [{ label: "coffee", amount: 1 }],
reply_markup: new InlineKeyboard().pay("pay 1 star").build(),
});
});
bot.on("pre_checkout_query", async (ctx) => {
if (ctx.invoice_payload !== "coffee") {
await ctx.answer(false, { error_message: "unknown order" });
return;
}
await ctx.answer(true);
});
bot.start();production rules
- never trust only the client-side button press; provision after
successful_payment. - make invoice payloads unique enough to map back to your order.
- answer
pre_checkout_queryfast; do not run slow external workflows there. - store processed payment ids to avoid double provisioning after retries.
- use
isStarsPayment(payment)to branch stars vs. external-provider fulfillment instead of checkingcurrency === "XTR"by hand. - test the flow with @yaebal/test before using a real provider.