@yaebal/payments
typed invoice building and payment hooks for the Telegram Bot API. one fluent builder for both
Telegram Stars and external payment providers, onPreCheckout/onSuccessfulPayment hooks that answer pre_checkout_query for you, and small helpers for the Stars
Subscription API (Bot API 7.6+).
install
pnpm add @yaebal/paymentsusage
payments() only installs the handlers you configure — leave onPreCheckout unset and no pre_checkout_query listener is registered at all.
import { invoice, payments } from "@yaebal/payments";
const bot = new Bot(process.env.BOT_TOKEN!)
.install(
payments({
onPreCheckout: (ctx, query) => {
// return true/nothing to approve, false or a string to decline
return query.invoice_payload.startsWith("plan:");
},
onSuccessfulPayment: async (ctx, payment) => {
await grantAccess(ctx.from!.id, payment.invoice_payload);
await ctx.reply("thanks! access granted.");
},
}),
)
.command("buy", (ctx) => {
const params = invoice("pro plan", "1 month of pro access", "plan:pro")
.price("pro plan", 100)
.toSendInvoiceParams(ctx.chat!.id);
return ctx.api.sendInvoice(params);
});the invoice builder
invoice(title, description, payload) returns a fluent, provider-agnostic InvoiceBuilder. it defaults to Telegram Stars; call .provider(token, currency) to route through an external processor (Stripe, YooKassa, …) instead — the same builder, hooks
and handler code work either way.
// stars (default)
invoice("tip", "buy me a coffee", "tip:1").price("tip", 25).toSendInvoiceParams(chatId);// external provider
invoice("t-shirt", "black, medium", "shirt:1")
.provider(process.env.PROVIDER_TOKEN!, "USD")
.price("shirt", 1999)
.flexible() // final price depends on shipping
.needShippingAddress()
.toSendInvoiceParams(chatId);// stars subscription (Subscription API, Bot API 7.6+) — forces stars
invoice("pro", "monthly pro access", "sub:pro")
.price("pro", 100)
.subscription() // every 30 days — the only period Telegram currently accepts
.toCreateInvoiceLinkParams();.toSendInvoiceParams(chatId, extra?) builds params for api.sendInvoice; .toCreateInvoiceLinkParams(extra?) builds params for api.createInvoiceLink (a shareable link, no chat required). both accept an extra object for one-off fields
(reply_markup, start_parameter, …) without a dedicated builder method.
hooks
onPreCheckout's return value decides the answer Telegram gets:
| return | effect |
|---|---|
true / nothing | { ok: true } |
false | { ok: false } (generic decline) |
a string | { ok: false, error_message: <string> } |
{ ok, errorMessage? } | full control |
Telegram requires an answer within 10 seconds of pre_checkout_query, so keep the hook
fast (defer anything slow to onSuccessfulPayment, which runs after the payment
already went through). set autoAnswerPreCheckout: false to call ctx.api.answerPreCheckoutQuery yourself instead.
onSuccessfulPayment runs on message:successful_payment; payment.telegram_payment_charge_id is your key for refunds and subscription edits.
subscriptions (Subscription API, Bot API 7.6+)
import { cancelSubscription, isSubscriptionPayment, reactivateSubscription } from "@yaebal/payments";
if (isSubscriptionPayment(payment)) {
await cancelSubscription(bot.api, {
userId: ctx.from!.id,
telegramPaymentChargeId: payment.telegram_payment_charge_id,
});
}cancelSubscription/reactivateSubscription wrap editUserStarSubscription — cancelling stops future billing but leaves the subscription active until the current period
ends, matching Telegram's own semantics. isStarsPayment/isSubscriptionPayment are small guards over SuccessfulPayment for branching on currency/recurrence.
refunding a Stars payment (subscription or one-off) and listing the bot's Star ledger are already
typed on Api directly — no wrapper needed: api.refundStarPayment(...), api.getStarTransactions(...).
api
| export | signature | description |
|---|---|---|
invoice | (title, description, payload) => InvoiceBuilder | starts a fluent, provider-agnostic invoice |
payments | (options?: PaymentsOptions) => Plugin<Context, Record<never, never>> | wires onPreCheckout/onSuccessfulPayment onto the bot |
cancelSubscription / reactivateSubscription | (api: Api, params) => Promise<boolean> | toggles editUserStarSubscription's is_canceled flag |
isStarsPayment / isSubscriptionPayment | (payment) => boolean | guards over SuccessfulPayment |
STARS_CURRENCY / SUBSCRIPTION_PERIOD_SECONDS | "XTR" / 2_592_000 | constants used internally, exported for your own params |
testing
@yaebal/test's UserActor already
models the payment flow: sendPreCheckoutQuery() emits the query, and sendSuccessfulPayment() runs it end-to-end — it throws if the bot never answered ok: true, exactly like real Telegram never firing successful_payment after a decline.
import { Composer, type Context } from "@yaebal/core";
import { createTestEnv } from "@yaebal/test";
import { payments } from "@yaebal/payments";
const bot = new Composer<Context>().install(payments({ onPreCheckout: () => true }));
const env = createTestEnv(bot);
await env.createUser().sendSuccessfulPayment({ invoice_payload: "plan:pro" });