DEV Community

Serhii
Serhii

Posted on Originally published at botservice.biz

Prevent Spoofed Orders in Next.js Telegram Mini Apps using Server-Validated MainButton Checkout

Shipping an e-commerce storefront inside a Telegram Mini App using Next.js App Router seems straightforward until untrusted client state compromises your checkout process. Front-end developers frequently rely on window.Telegram.WebApp.initDataUnsafe to identify the user and send cart totals directly from the client to an API route. Because initDataUnsafe can be mutated by anyone opening your app in a standard browser or using browser developer tools, accepting unverified user IDs or total prices from the front end allows users to modify invoice amounts, impersonate other customer accounts, or execute unverified orders.

Building a robust Telegram Mini App shop requires verifying the raw initData payload using an HMAC-SHA256 signature on the server, calculating cart prices strictly from stored backend database values, controlling Telegram's native MainButton across React render lifecycles, and accounting for renderer discrepancies between iOS WebViews and Telegram Desktop.

Validating Telegram initData in Next.js Route Handlers

When a client triggers a checkout flow, the application must pass the raw window.Telegram.WebApp.initData query string to a Next.js App Router Route Handler (app/api/checkout/route.ts). Never pass pre-parsed JSON objects from initDataUnsafe. The raw string contains key-value pairs sorted alphabetically and signed by Telegram using a secret key derived from your Bot Token.

If your API route receives a list of product IDs and quantities, it must recalculate the total price on the server side using your application's product catalog. Once verified, the route calls Telegram's createInvoiceLink Bot API endpoint to return a native payment link back to the front end.

Here is how to validate initData and generate an invoice link securely in a Next.js Route Handler:

// app/api/checkout/route.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

interface CartItem {
  id: string;
  quantity: number;
}

const PRODUCT_CATALOG: Record<string, { name: string; priceInCents: number }> = {
  'item_1': { name: 'Developer Mug', priceInCents: 1500 },
  'item_2': { name: 'Mechanical Keyboard', priceInCents: 12000 },
};

function verifyTelegramInitData(initDataRaw: string, botToken: string) {
  const params = new URLSearchParams(initDataRaw);
  const hash = params.get('hash');
  if (!hash) return { isValid: false, user: null };

  params.delete('hash');

  const dataCheckArray: string[] = [];
  for (const [key, value] of params.entries()) {
    dataCheckArray.push(`${key}=${value}`);
  }
  dataCheckArray.sort();
  const dataCheckString = dataCheckArray.join('\n');

  const secretKey = crypto
    .createHmac('sha256', 'WebAppData')
    .update(botToken)
    .digest();

  const calculatedHash = crypto
    .createHmac('sha256', secretKey)
    .update(dataCheckString)
    .digest('hex');

  if (calculatedHash !== hash) {
    return { isValid: false, user: null };
  }

  const authDate = parseInt(params.get('auth_date') || '0', 10);
  const now = Math.floor(Date.now() / 1000);
  if (now - authDate > 86400) {
    return { isValid: false, user: null }; // Reject auth_date older than 24 hours
  }

  const userRaw = params.get('user');
  const user = userRaw ? JSON.parse(userRaw) : null;

  return { isValid: true, user };
}

export async function POST(request: Request) {
  try {
    const body = await request.json();
    const { initData, items } = body as { initData: string; items: CartItem[] };

    const botToken = process.env.TELEGRAM_BOT_TOKEN;
    if (!botToken) {
      return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
    }

    const { isValid, user } = verifyTelegramInitData(initData, botToken);
    if (!isValid || !user) {
      return NextResponse.json({ error: 'Invalid signature or expired session' }, { status: 401 });
    }

    // Recalculate cart total on server
    let totalAmount = 0;
    const invoicePrices = [];

    for (const item of items) {
      const product = PRODUCT_CATALOG[item.id];
      if (!product) {
        return NextResponse.json({ error: `Invalid product ID: ${item.id}` }, { status: 400 });
      }
      const itemTotal = product.priceInCents * item.quantity;
      totalAmount += itemTotal;
      invoicePrices.push({
        label: `${product.name} x${item.quantity}`,
        amount: itemTotal,
      });
    }

    if (totalAmount === 0) {
      return NextResponse.json({ error: 'Cart is empty' }, { status: 400 });
    }

    // Request invoice link from Telegram Bot API
    const response = await fetch(`https://api.telegram.org/bot${botToken}/createInvoiceLink`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        title: 'Order Checkout',
        description: `Order for Telegram User ID ${user.id}`,
        payload: JSON.stringify({ userId: user.id, timestamp: Date.now() }),
        provider_token: process.env.PAYMENT_PROVIDER_TOKEN || '',
        currency: 'USD',
        prices: invoicePrices,
      }),
    });

    const data = await response.json();
    if (!data.ok) {
      return NextResponse.json({ error: data.description || 'Failed to create invoice' }, { status: 500 });
    }

    return NextResponse.json({ invoiceUrl: data.result });
  } catch (err) {
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}
Enter fullscreen mode Exit fullscreen mode

If an attacker manipulates the cart payload in the browser or sends a fake HTTP request, the route fails at the signature check stage or re-computes the price from server records. The front end never sends prices; it only sends product identifiers and quantities.

Syncing the MainButton and Cart State in React

Telegram provides a native UI button pinned to the bottom of the Mini App container via Telegram.WebApp.MainButton. Integrating MainButton inside a React dynamic application often introduces event listener memory leaks, duplicate payment requests, or visual desynchronization when cart items change.

A common implementation flaw occurs when developers bind Telegram.WebApp.MainButton.onClick inside a component without unbinding previous listeners on state changes. Every re-render attaches another listener callback, causing a single tap on the button to execute multiple fetch calls to /api/checkout simultaneously.

To prevent this, sync MainButton state inside a dedicated React useEffect block, managing visibility, text, and event listener cleanup explicitly:

// components/CartCheckout.tsx
'use client';

import { useEffect, useState } from 'react';

interface CartItem {
  id: string;
  name: string;
  priceInCents: number;
  quantity: number;
}

export default function CartCheckout({ items }: { items: CartItem[] }) {
  const [isLoading, setIsLoading] = useState(false);

  const totalCents = items.reduce((sum, item) => sum + item.priceInCents * item.quantity, 0);

  useEffect(() => {
    const tg = window.Telegram?.WebApp;
    if (!tg || !tg.MainButton) return;

    if (items.length === 0) {
      tg.MainButton.hide();
      return;
    }

    tg.MainButton.setText(`CHECKOUT ($${(totalCents / 100).toFixed(2)})`);
    tg.MainButton.show();

    if (isLoading) {
      tg.MainButton.showProgress(false);
      tg.MainButton.disable();
    } else {
      tg.MainButton.hideProgress();
      tg.MainButton.enable();
    }

    const handleMainButtonClick = async () => {
      setIsLoading(true);
      try {
        const response = await fetch('/api/checkout', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            initData: tg.initData,
            items: items.map(i => ({ id: i.id, quantity: i.quantity })),
          }),
        });

        const result = await response.json();
        if (!response.ok) {
          tg.showAlert(result.error || 'Checkout failed');
          return;
        }

        // Open native payment modal via invoice link
        tg.openInvoice(result.invoiceUrl, (status) => {
          if (status === 'paid') {
            tg.showAlert('Payment successful!');
            // Clear cart or redirect user
          } else if (status === 'failed') {
            tg.showAlert('Payment failed or canceled.');
          }
        });
      } catch (err) {
        tg.showAlert('Network error occurred during checkout.');
      } finally {
        setIsLoading(false);
      }
    };

    tg.MainButton.onClick(handleMainButtonClick);

    return () => {
      tg.MainButton.offClick(handleMainButtonClick);
    };
  }, [items, totalCents, isLoading]);

  return (
    <div className="p-4">
      <h2 className="text-lg font-bold">Your Cart</h2>
      {items.length === 0 ? (
        <p>Cart is empty.</p>
      ) : (
        <ul className="divide-y">
          {items.map((item) => (
            <li key={item.id} className="py-2 flex justify-between">
              <span>{item.name} x{item.quantity}</span>
              <span>${((item.priceInCents * item.quantity) / 100).toFixed(2)}</span>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Calling tg.MainButton.offClick(handleMainButtonClick) inside the clean-up return statement guarantees that old event listeners are removed whenever items or isLoading changes.

Fixing iOS and Desktop Telegram WebView Render Quirks

Developing Mini Apps in modern browsers does not prepare your interface for Telegram's underlying WebViews. Android, iOS, and Telegram Desktop interpret viewports, color themes, and navigation stack updates in radically different ways.

1. Viewport Height Truncation on iOS

Standard CSS rules like height: 100vh break inside iOS Telegram WebViews. iOS WebViews calculate 100vh based on the full screen size including obscured areas behind native top bars and bottom gesture controls. This causes the bottom portion of your Next.js application—often the cart action area—to render below the visible fold.

Telegram provides window.Telegram.WebApp.viewportStableHeight and CSS variables injected directly into the document root. Set up your layout using dynamic CSS custom properties rather than hardcoded 100vh rules.

2. BackButton Routing Synchronization

Next.js App Router relies on next/navigation to manage client routing. However, Telegram native apps expect the top-left BackButton to open and close depending on view depth. If the user navigates from the product catalog into a detail page, your React code must explicitly call Telegram.WebApp.BackButton.show() and register a click listener that calls router.back().

3. Desktop Theme Mismatches

Telegram Desktop allows users to set custom light, dark, and high-contrast themes. If your application relies exclusively on standard Tailwind dark modes or prefers-color-scheme, the Mini App interface will clash with Telegram's surrounding window chrome. Telegram passes current color tokens through window.Telegram.WebApp.themeParams.

Here is a dedicated Client Component setup that resolves viewport height calculations, binds BackButton to Next.js navigation, and syncs color themes:

// components/TelegramInitializer.tsx
'use client';

import { useEffect } from 'react';
import { useRouter, usePathname } from 'next/navigation';

declare global {
  interface Window {
    Telegram?: {
      WebApp?: any;
    };
  }
}

export default function TelegramInitializer() {
  const router = useRouter();
  const pathname = usePathname();

  useEffect(() => {
    const tg = window.Telegram?.WebApp;
    if (!tg) return;

    // Tell Telegram client the Mini App is initialized
    tg.ready();
    tg.expand();

    // Fix iOS viewport height bug using native dynamic viewport variables
    const updateViewport = () => {
      if (tg.viewportStableHeight) {
        document.documentElement.style.setProperty(
          '--tg-viewport-height',
          `${tg.viewportStableHeight}px`
        );
      }
    };

    tg.onEvent('viewportChanged', updateViewport);
    updateViewport();

    return () => {
      tg.offEvent('viewportChanged', updateViewport);
    };
  }, []);

  // Handle Telegram native BackButton sync with Next.js router
  useEffect(() => {
    const tg = window.Telegram?.WebApp;
    if (!tg || !tg.BackButton) return;

    if (pathname !== '/') {
      tg.BackButton.show();
      const handleBack = () => {
        router.back();
      };
      tg.BackButton.onClick(handleBack);

      return () => {
        tg.BackButton.offClick(handleBack);
      };
    } else {
      tg.BackButton.hide();
    }
  }, [pathname, router]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

In your root layout (app/layout.tsx), apply the dynamic CSS variable to your master wrapper container to protect against layout clipping on iOS devices:

// app/layout.tsx
import TelegramInitializer from '@/components/TelegramInitializer';
import Script from 'next/script';
import './globals.css';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script
          src="https://telegram.org/js/telegram-web-app.js"
          strategy="beforeInteractive"
        />
      </head>
      <body
        className="bg-[var(--tg-theme-bg-color,#fff)] text-[var(--tg-theme-text-color,#000)]"
        style={{ minHeight: 'var(--tg-viewport-height, 100vh)' }}
      >
        <TelegramInitializer />
        {children}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Server Verification of Telegram Invoice Webhooks

After a user completes payment inside the Telegram payment UI via openInvoice, Telegram sends asynchronous webhooks (pre_checkout_query and successful_payment) to your bot backend to confirm settlement. Never fulfill physical goods or mark orders as paid solely based on the front-end openInvoice status callback, as network disconnections or client-side tampering can spoof front-end callbacks.

Your bot's webhook endpoint must handle pre_checkout_query updates by verifying stock availability and responding to Telegram within 10 seconds. When Telegram confirms payment capture, it posts a successful_payment update containing the payment charge ID.

Secure your Next.js webhook Route Handler using the X-Telegram-Bot-Api-Secret-Token header to guarantee that incoming updates originate from Telegram's servers:

// app/api/webhook/route.ts
import { NextResponse } from 'next/server';

export async function POST(request: Request) {
  const secretToken = request.headers.get('x-telegram-bot-api-secret-token');
  if (secretToken !== process.env.TELEGRAM_WEBHOOK_SECRET) {
    return NextResponse.json({ error: 'Unauthorized webhook request' }, { status: 401 });
  }

  const update = await request.json();
  const botToken = process.env.TELEGRAM_BOT_TOKEN;

  // Step 1: Approve or decline pre-checkout query
  if (update.pre_checkout_query) {
    const queryId = update.pre_checkout_query.id;
    const payload = JSON.parse(update.pre_checkout_query.payload);

    // Check database to ensure inventory exists for payload.userId
    const isItemInStock = true;

    await fetch(`https://api.telegram.org/bot${botToken}/answerPreCheckoutQuery`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        pre_checkout_query_id: queryId,
        ok: isItemInStock,
        error_message: isItemInStock ? undefined : 'Item went out of stock.',
      }),
    });

    return NextResponse.json({ ok: true });
  }

  // Step 2: Confirm final payment success
  if (update.message?.successful_payment) {
    const payment = update.message.successful_payment;
    const payload = JSON.parse(payment.invoice_payload);
    const telegramPaymentChargeId = payment.telegram_payment_charge_id;

    // Mark order as paid in database using idempotency on telegramPaymentChargeId
    console.log(`Order completed for user ${payload.userId}. Charge ID: ${telegramPaymentChargeId}`);

    return NextResponse.json({ ok: true });
  }

  return NextResponse.json({ ok: true });
}
Enter fullscreen mode Exit fullscreen mode

Rejecting untrusted secret tokens, calculating invoice pricing strictly on Next.js server handlers, and synchronizing MainButton and BackButton events inside React guarantees your store runs securely across iOS, Android, and Desktop Telegram WebViews.

When shipping complex Mini Apps for high-volume storefronts or custom enterprise Telegram workflows, consider partnering with BotCreator — a specialized studio that builds and deploys high-scale Telegram bots and Mini Apps. Developers looking to dive deeper into custom payload handling and bot endpoint architectures can consult the official documentation at

Top comments (0)