payments

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

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
await bot.api.call("sendInvoice", {
  chat_id: ctx.chat!.id,
  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.

pre-checkout.ts
bot.on("pre_checkout_query", async (ctx) => {
  const query = ctx.update.pre_checkout_query!;

  const ok = await isOrderValid(query.invoice_payload, query.from.id);

  await ctx.api.call("answerPreCheckoutQuery", {
    pre_checkout_query_id: query.id,
    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
bot.on("shipping_query", async (ctx) => {
  const query = ctx.update.shipping_query!;

  await ctx.api.call("answerShippingQuery", {
    shipping_query_id: query.id,
    ok: 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
bot.on("message", async (ctx) => {
  const payment = ctx.message?.successful_payment;
  if (!payment) return;

  await grantAccess(payment.invoice_payload, ctx.from!.id);
  await ctx.reply("payment received");
});

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.
  • test the flow with @yaebal/test before using a real provider.