TONTONDocs
Payment integration

How to send payments using TON Connect

Use TON Connect native sendTransaction method when:

  • The application manages wallet connection UI and transaction flow directly.
  • TON Connect is already used elsewhere in the application.
  • Access to TON Connect-specific APIs is required, such as status change listeners or custom wallet adapters.

Integration overview

Direct TON Connect integration follows these steps:

  1. Configure the TON Connect UI provider with the application manifest.
  2. Manage wallet connection state and provide UI for users to connect the wallets.
  3. Create a transaction message on the client or backend using createTonPayTransfer.
  4. Send the transaction using TON Connect's sendTransaction method.
  5. Observe connection state changes and transaction status updates.

React implementation

Set up application

Wrap the application with the TON Connect UI provider:

import { TonConnectUIProvider } from "@tonconnect/ui-react";

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <TonConnectUIProvider manifestUrl="https://<APP_URL>/tonconnect-manifest.json">
      {children}
    </TonConnectUIProvider>
  );
}

The manifest URL must be publicly accessible and served over HTTPS in production. Replace <APP_URL> with the public HTTPS origin that serves tonconnect-manifest.json. The manifest file provides application metadata required for wallet identification.

Payment component

import {
  useTonAddress,
  useTonConnectModal,
  useTonConnectUI,
} from "@tonconnect/ui-react";
import { createTonPayTransfer } from "@ton-pay/api";
import { useState } from "react";

export function PaymentComponent({ orderId, amount }: { orderId: string; amount: number }) {
  const address = useTonAddress(true); // Get user-friendly address format
  const { open } = useTonConnectModal();
  const [tonConnectUI] = useTonConnectUI();
  const [loading, setLoading] = useState(false);

  const handlePayment = async () => {
    // Check if wallet is connected
    if (!address) {
      open();
      return;
    }

    setLoading(true);

    try {
      // Create the transaction message
      // Note: For production, consider moving this to a server endpoint
      const { message, reference, bodyBase64Hash } = await createTonPayTransfer(
        {
          amount,
          asset: "TON",
          recipientAddr: "<RECIPIENT_WALLET_ADDRESS>",
          senderAddr: address,
          commentToSender: `Payment for Order ${orderId}`,
          commentToRecipient: `Order ${orderId}`,
        },
        {
          chain: "testnet",
          // Optional API key can be used client-side
          apiKey: "<TONPAY_API_KEY>", // optional
        }
      );

      // Store tracking identifiers
      await fetch("/api/store-payment", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ reference, bodyBase64Hash, orderId, amount }),
      });

      // Send transaction through TonConnect
      const result = await tonConnectUI.sendTransaction({
        messages: [message],
        validUntil: Math.floor(Date.now() / 1000) + 300, // 5 minutes
        from: address,
      });

      console.log("Transaction sent:", result.boc);

      // Handle success
      window.location.href = `/orders/${orderId}/success`;
    } catch (error) {
      console.error("Payment failed:", error);
      alert("Payment failed. Please try again.");
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handlePayment} disabled={loading}>
      {loading ? "Processing..." : address ? `Pay ${amount} TON` : "Connect Wallet"}
    </button>
  );
}

Manage connection state

Listen for wallet connection status changes using the TON Connect UI API:

import { useEffect } from "react";
import { useTonConnectUI } from "@tonconnect/ui-react";

export function WalletStatus() {
  const [tonConnectUI] = useTonConnectUI();
  const [walletInfo, setWalletInfo] = useState(null);

  useEffect(() => {
    // Listen for connection status changes
    const unsubscribe = tonConnectUI.onStatusChange((wallet) => {
      if (wallet) {
        console.log("Wallet connected:", wallet.account.address);
        setWalletInfo({
          address: wallet.account.address,
          chain: wallet.account.chain,
          walletName: wallet.device.appName,
        });
      } else {
        console.log("Wallet disconnected");
        setWalletInfo(null);
      }
    });

    return () => {
      unsubscribe();
    };
  }, [tonConnectUI]);

  if (!walletInfo) {
    return <div>No wallet connected</div>;
  }

  return (
    <div>
      <p>Connected: {walletInfo.walletName}</p>
      <p>Address: {walletInfo.address}</p>
    </div>
  );
}

Server-side message building

For production applications, build transaction messages on the server to centralize tracking and validation.

Backend endpoint

import { createTonPayTransfer } from "@ton-pay/api";
import { validateWalletAddress } from "./utils/validation";

app.post("/api/create-transaction", async (req, res) => {
  const { orderId, senderAddr } = req.body;

  try {
    // Validate inputs
    if (!validateWalletAddress(senderAddr)) {
      return res.status(400).json({ error: "Invalid wallet address" });
    }

    // Fetch order details from database
    const order = await db.orders.findById(orderId);
    if (!order) {
      return res.status(404).json({ error: "Order not found" });
    }

    if (order.status !== "pending") {
      return res.status(400).json({ error: "Order already processed" });
    }

    // Create transaction message
    const { message, reference, bodyBase64Hash } = await createTonPayTransfer(
      {
        amount: order.amount,
        asset: order.currency || "TON",
        recipientAddr: "<RECIPIENT_WALLET_ADDRESS>",
        senderAddr,
        commentToSender: `Payment for Order ${order.id}`,
        commentToRecipient: `Order ${order.id} - ${order.description}`,
      },
      {
        chain: "testnet",
        apiKey: "TONPAY_API_KEY", // optional
      }
    );

    // Store tracking identifiers
    await db.payments.create({
      orderId: order.id,
      reference,
      bodyBase64Hash,
      amount: order.amount,
      asset: order.currency || "TON",
      senderAddr,
      status: "pending",
      createdAt: new Date(),
    });

    // Return message to client
    res.json({ message });
  } catch (error) {
    console.error("Failed to create transaction:", error);
    res.status(500).json({ error: "Failed to create transaction" });
  }
});

Frontend implementation

export function ServerManagedPayment({ orderId }: { orderId: string }) {
  const address = useTonAddress(true);
  const { open } = useTonConnectModal();
  const [tonConnectUI] = useTonConnectUI();
  const [loading, setLoading] = useState(false);

  const handlePayment = async () => {
    if (!address) {
      open();
      return;
    }

    setLoading(true);

    try {
      // Request transaction message from server
      const response = await fetch("/api/create-transaction", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ orderId, senderAddr: address }),
      });

      if (!response.ok) {
        const error = await response.json();
        throw new Error(error.error || "Failed to create transaction");
      }

      const { message } = await response.json();

      // Send transaction
      const result = await tonConnectUI.sendTransaction({
        messages: [message],
        validUntil: Math.floor(Date.now() / 1000) + 300,
        from: address,
      });

      console.log("Transaction completed:", result.boc);

      // Navigate to success page
      window.location.href = `/orders/${orderId}/success`;
    } catch (error) {
      console.error("Payment error:", error);
      alert(error.message || "Payment failed");
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handlePayment} disabled={loading}>
      {loading ? "Processing..." : "Complete Payment"}
    </button>
  );
}

Vanilla JavaScript implementation

For non-React applications, use the TON Connect SDK directly:

import TonConnectUI from "@tonconnect/ui";
import { createTonPayTransfer } from "@ton-pay/api";

const tonConnectUI = new TonConnectUI({
  manifestUrl: "https://yourdomain.com/tonconnect-manifest.json",
});

// Connect wallet
async function connectWallet() {
  await tonConnectUI.connectWallet();
}

// Send payment
async function sendPayment(amount, orderId) {
  const wallet = tonConnectUI.wallet;

  if (!wallet) {
    await connectWallet();
    return;
  }

  try {
    // Create transaction message
    const { message, reference, bodyBase64Hash } = await createTonPayTransfer(
      {
        amount,
        asset: "TON",
        recipientAddr: "<RECIPIENT_WALLET_ADDRESS>",
        senderAddr: wallet.account.address,
        commentToSender: `Order ${orderId}`,
      },
      { chain: "testnet" }
    );

    // Store tracking data
    await fetch("/api/store-payment", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ reference, bodyBase64Hash, orderId }),
    });

    // Send transaction
    const result = await tonConnectUI.sendTransaction({
      messages: [message],
      validUntil: Math.floor(Date.now() / 1000) + 300,
      from: wallet.account.address,
    });

    console.log("Payment successful:", result.boc);
  } catch (error) {
    console.error("Payment failed:", error);
  }
}

// Listen for connection changes
tonConnectUI.onStatusChange((wallet) => {
  if (wallet) {
    console.log("Wallet connected:", wallet.account.address);
    document.getElementById("wallet-address").textContent = wallet.account.address;
  } else {
    console.log("Wallet disconnected");
    document.getElementById("wallet-address").textContent = "Not connected";
  }
});

Transaction parameters

Message structure

The message object passed to sendTransaction must include these fields:

addressstringrequired

Recipient wallet address in user-friendly format.

amountstringrequired

Amount to send in nanotons.

payloadstringrequired

Base64-encoded message payload containing transfer details and tracking information.

Transaction options

validUntilnumberrequired

Unix timestamp indicating when the transaction expires. Typically is 5 minutes.

validUntil: Math.floor(Date.now() / 1000) + 300
fromstringrequired

Sender's wallet address. Must match the connected wallet address.

networkstring

Network identifier. Usually omitted as it is inferred from the connected wallet.

Error handling

async function handleTransaction() {
  try {
    const result = await tonConnectUI.sendTransaction({
      messages: [message],
      validUntil: Math.floor(Date.now() / 1000) + 300,
      from: address,
    });

    return result;
  } catch (error) {
    // User rejected the transaction
    if (error.message?.includes("rejected")) {
      console.log("User cancelled the transaction");
      return null;
    }

    // Wallet not connected
    if (error.message?.includes("Wallet is not connected")) {
      console.log("Connect the wallet first");
      tonConnectUI.connectWallet();
      return null;
    }

    // Transaction expired
    if (error.message?.includes("expired")) {
      console.log("Transaction expired, please try again");
      return null;
    }

    // Network or other errors
    console.error("Transaction failed:", error);
    throw error;
  }
}

Best practices

  • Check wallet connection status before attempting to send transactions. Provide clear UI feedback for connection state.

    const wallet = tonConnectUI.wallet;
    
    if (!wallet) {
        // Show connect button
        return;
    }
    
    // Proceed with transaction
  • Use a reasonable validUntil value, typically 5 minutes, to prevent stale transactions while allowing enough time for user confirmation.

    const validUntil = Math.floor(Date.now() / 1000) + 300; // 5 minutes
  • Ensure the sender address from TON Connect matches the format expected by the backend. Use the user-friendly format consistently.

    const address = useTonAddress(true); // true = user-friendly format
  • Always persist reference and bodyBase64Hash before sending the transaction. This allows payment reconciliation through webhooks even if the client flow fails after submission.

    // Good: Store first, then send
    await storePaymentTracking(reference, bodyBase64Hash);
    await tonConnectUI.sendTransaction(...);
    
    // Bad: Send first, then store
    await tonConnectUI.sendTransaction(...);
    await storePaymentTracking(reference, bodyBase64Hash); // Might not execute
  • Implement connection state listeners to update the UI and handle wallet disconnections.

    useEffect(() => {
        const unsubscribe = tonConnectUI.onStatusChange((wallet) => {
            if (wallet) {
                setConnectedWallet(wallet.account.address);
            } else {
                setConnectedWallet(null);
            }
        });
    
        return unsubscribe;
    }, [tonConnectUI]);
  • Handle transaction rejection explicitly. Treat user rejection as a normal cancellation, not as an error.

    try {
      await tonConnectUI.sendTransaction(transaction);
    } catch (error) {
      if (error.message?.includes("rejected")) {
        // Don't show error - user intentionally cancelled
        console.log("Transaction cancelled by user");
      } else {
        // Show error for unexpected failures
        showErrorMessage("Transaction failed");
      }
    }

Troubleshooting

Optional API key configuration

When using TON Connect with server-side message building, the optional API key can be included in the backend:

import { createTonPayTransfer } from "@ton-pay/api";

app.post("/api/create-transaction", async (req, res) => {
  const { orderId, senderAddr } = req.body;
  const order = await db.orders.findById(orderId);

  const { message, reference, bodyBase64Hash } = await createTonPayTransfer(
    {
      amount: order.amount,
      asset: "TON",
      recipientAddr: "<RECIPIENT_WALLET_ADDRESS>",
      senderAddr: "<SENDER_WALLET_ADDRESS>",
    },
    {
      chain: "testnet",
      apiKey: "TONPAY_API_KEY", // optional
    }
  );

  await db.payments.create({ orderId, reference, bodyBase64Hash });
  res.json({ message });
});

Testnet configuration

Funds at risk

Running tests on mainnet can result in irreversible loss of real TON. Always use chain: "testnet" and testnet wallet addresses during development. Verify the network before switching to mainnet.

Set up testnet

Configure the environment to use testnet:

# .env.development
TON_CHAIN=testnet
MERCHANT_WALLET_ADDRESS=TESTNET_ADDRESS
import { useTonAddress, useTonConnectUI } from "@tonconnect/ui-react";
import { createTonPayTransfer } from "@ton-pay/api";

export function TestnetPayment({ amount }: { amount: number }) {
  const address = useTonAddress(true);
  const [tonConnectUI] = useTonConnectUI();

  const handlePayment = async () => {
    if (!address) {
      tonConnectUI.connectWallet();
      return;
    }

    const { message } = await createTonPayTransfer(
      {
        amount,
        asset: "TON",
        recipientAddr: "<RECIPIENT_WALLET_ADDRESS>",
        senderAddr: "<SENDER_WALLET_ADDRESS>",
      },
      { chain: "testnet" } // Use testnet
    );

    const result = await tonConnectUI.sendTransaction({
      messages: [message],
      validUntil: Math.floor(Date.now() / 1000) + 300,
      from: address,
    });

    console.log("Testnet transaction:", result.boc);
  };

  return <button onClick={handlePayment}>Test Payment</button>;
}

Next steps

Last updated on

On this page