Приём оплаты Stars в mini-app

Сгенерируешь invoice link на бэкенде и откроешь оплату Stars из Mini App через WebApp.openInvoice.

Оплата Stars из Mini App

В Mini App нет прямого «списать Stars» с клиента. Клиент только открывает нативный платёжный UI: бэкенд создаёт ссылку через createInvoiceLink, фронт вызывает WebApp.openInvoice(url). Подтверждение оплаты и выдача товара — на стороне бота (pre_checkout_query / successful_payment).


Бэкенд: ссылка + фронт: openInvoice

typescript
// Telegram Bot API 8.0+ / WebApp API 6.1+ (openInvoice)
// Бэкенд: Node 20+, фронт: страница внутри Telegram Mini App

const BOT_TOKEN = process.env.BOT_TOKEN!;
const API = `https://api.telegram.org/bot${BOT_TOKEN}`;

type InvoiceRequest = {
  userId: number;
  sku: "pro_pack";
  initData: string; // обязательно валидируй HMAC на сервере
};

export async function createStarsInvoiceLink(req: InvoiceRequest): Promise<string> {
  // validateInitData(req.initData) — см. статью про initData
  const payload = JSON.stringify({
    sku: req.sku,
    userId: req.userId,
    nonce: crypto.randomUUID(),
  });

  const res = await fetch(`${API}/createInvoiceLink`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      title: "Pro Pack",
      description: "Набор Pro-функций в Mini App",
      payload, // до 128 байт
      provider_token: "",
      currency: "XTR",
      prices: [{ label: "Pro Pack", amount: 100 }],
    }),
  });

  const data = (await res.json()) as { ok: boolean; result: string; description?: string };
  if (!data.ok) throw new Error(data.description ?? "createInvoiceLink failed");
  return data.result; // https://t.me/$... или invoice-ссылка Telegram
}

// --- фронт Mini App ---

declare global {
  interface Window {
    Telegram: {
      WebApp: {
        openInvoice: (
          url: string,
          callback?: (status: "paid" | "cancelled" | "failed" | "pending") => void,
        ) => void;
        showAlert: (message: string) => void;
        initData: string;
        initDataUnsafe: { user?: { id: number } };
      };
    };
  }
}

export async function payProPack(): Promise<void> {
  const tg = window.Telegram.WebApp;
  const userId = tg.initDataUnsafe.user?.id;
  if (!userId) throw new Error("Нет user.id в initDataUnsafe");

  const res = await fetch("/api/stars/invoice", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      userId,
      sku: "pro_pack",
      initData: tg.initData,
    }),
  });
  if (!res.ok) throw new Error("Не удалось создать инвойс");

  const { invoiceLink } = (await res.json()) as { invoiceLink: string };

  tg.openInvoice(invoiceLink, (status) => {
    if (status === "paid") {
      // UX-сигнал. Источник истины — webhook successful_payment на боте
      tg.showAlert("Оплата прошла. Обновляем доступ…");
      void refreshEntitlements();
      return;
    }
    if (status === "cancelled") {
      tg.showAlert("Оплата отменена");
      return;
    }
    if (status === "failed") {
      tg.showAlert("Оплата не удалась");
    }
  });
}

declare function refreshEntitlements(): Promise<void>;

На боте параллельно обрабатывай updates:

typescript
// Фрагмент обработчика updates (тот же бот, что создал ссылку)

if (update.pre_checkout_query) {
  const q = update.pre_checkout_query;
  const ok = q.currency === "XTR" && q.total_amount === 100;
  await fetch(`${API}/answerPreCheckoutQuery`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      pre_checkout_query_id: q.id,
      ok,
      error_message: ok ? undefined : "Некорректная сумма",
    }),
  });
}

if (update.message?.successful_payment) {
  const p = update.message.successful_payment;
  await persistPayment({
    chargeId: p.telegram_payment_charge_id,
    payload: p.invoice_payload,
    stars: p.total_amount,
  });
  await grantSkuFromPayload(p.invoice_payload);
}

declare function persistPayment(row: {
  chargeId: string;
  payload: string;
  stars: number;
}): Promise<void>;
declare function grantSkuFromPayload(payload: string): Promise<void>;

Как это работает

  • createInvoiceLink — серверный метод; токен бота на клиент не выноси.
  • openInvoice — открывает нативное окно оплаты; callback даёт paid | cancelled | failed | pending.
  • Callback paid удобен для UI, но доступ выдавай только после successful_payment (или своей идемпотентной записи в БД по telegram_payment_charge_id).
  • Ссылка переиспользуема: каждый платёж даёт свой pre_checkout_query и свой charge_id.
  • initData валидируй на бэкенде до создания инвойса — иначе любой сможет генерировать ссылки от чужого userId.

Частые ошибки

Кладёшь BOT_TOKEN во фронт → токен скомпрометирован. Только сервер вызывает Bot API.

Доверяешь только status === "paid" → клиентский callback можно обойти логически (гонка, повторный refresh). Сверяйся с update бота / записью в БД.

Путаешь Stars-оплату с TON Connect → в Mini App перевод Gram/jetton — через TON Connect; Stars — через openInvoice + Bot Payments. Это разные потоки.


Что дальше

Материалы gramdocs.tech носят образовательный характер и не являются финансовой, юридической или инвестиционной рекомендацией. Работа с блокчейном TON и токеном Gram связана с рисками потери средств. Правовая информация