payments

build telegram payments, stars invoices, shipping checks, pre-checkout validation, and paid media flows with yaebal.

reach for @yaebal/payments first. it wraps the raw protocol below in a fluent, provider-agnostic 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.
payments-plugin.ts
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+):

subscription.ts
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.

invoice.ts
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.

pre-checkout.ts
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.

shipping.ts
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.

success.ts
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");
});
try it — stars flow
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_query fast; 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 checking currency === "XTR" by hand.
  • test the flow with @yaebal/test before using a real provider.