TL;DR
Telegram is the distribution channel; the wallet is the product. This guide builds a Telegram bot where every user gets their own on-chain wallet the moment they type /start — no app install, no seed phrase, no gas. The bot maps each Telegram user ID to an Openfort backend wallet and signs transactions server-side, with gas sponsored by a policy. You can run the whole flow locally without a bot token, then go live with one BotFather command. A final section covers the client-side alternative: non-custodial embedded wallets inside a Telegram Mini-App.

Telegram reaches around a billion people who already know how to chat. That makes it one of the shortest paths from "no wallet" to "on-chain": no app store, no extension, no onboarding screen — just a bot that answers /start.
There are two ways to put wallets inside Telegram:
- Bot-first (this guide) — the wallet lives server-side. Users chat commands; your bot creates wallets, checks balances, and sends transactions on their behalf. Trading bots, payment bots, and agent-style assistants all work this way.
- App-first — a Telegram Mini-App built with React, where each user gets a non-custodial embedded wallet. Covered at the end.
We'll build the bot-first version with Openfort backend wallets: three commands, one wallet per user, all gas sponsored.
The full recipe is in the Openfort Recipe Hub:
_10pnpx gitpick openfort-xyz/recipes-hub/tree/main/telegram-bot openfort-telegram-bot_10cd openfort-telegram-bot && pnpm install
What you'll build
A bot with three commands:
/start— creates a wallet for the Telegram user, keyed by their Telegram user ID/balance— ETH and USDC balances on Base Sepolia/send <address> <amount>— sends USDC, gas-free
The architecture is one mapping and one signer:
_10Telegram user ──/send──▶ bot (grammY) ──▶ Openfort backend wallet ──▶ Base Sepolia_10 │_10 telegram_user_id → account_id
Openfort generates and stores the encrypted keys; your server authorizes each signature with a project-scoped wallet secret. Because signing is server-side, the UX is pure chat, and because a gas sponsorship policy covers fees, a wallet works the second it's created, holding nothing.
This pattern is custodial by design, which is what makes it instant. Be upfront about it with users, cap what wallets can do with signing policies, and remember users can always export their key and leave. If you want the user to hold the keys, jump to the Mini-App section.
Step 1: Create the bot
Message @BotFather, run /newbot, and follow the prompts. You'll get a token like 123456:ABC-DEF.... Keep it for later — you won't need it until the bot goes live.
Step 2: Configure Openfort
Copy .env.example to .env and fill in three values from the Openfort dashboard:
_10OPENFORT_SECRET_KEY=sk_test_... # API keys_10OPENFORT_WALLET_SECRET=... # Backend wallets → Setup_10OPENFORT_GAS_POLICY_ID=pol_... # Gas sponsorship (Base Sepolia)_10TELEGRAM_BOT_TOKEN= # optional until you go live
Initialize the SDK once and reuse it:
_10import Openfort from '@openfort/openfort-node'_10_10export const openfort = new Openfort(process.env.OPENFORT_SECRET_KEY!, {_10 walletSecret: process.env.OPENFORT_WALLET_SECRET!,_10})
Step 3: One wallet per Telegram user
The first time a user types /start, create a backend wallet and store the mapping. The recipe uses a JSON file; in production this is one column on your users table.
_10export async function getOrCreateWallet(telegramUserId: number) {_10 const stored = getStoredWallet(telegramUserId)_10 if (stored) return openfort.accounts.evm.backend.get({ id: stored.accountId })_10_10 const account = await openfort.accounts.evm.backend.create()_10 saveStoredWallet(telegramUserId, { accountId: account.id, address: account.address })_10 return account_10}
Wire it to the command with grammY:
_10import { Bot } from 'grammy'_10_10const bot = new Bot(process.env.TELEGRAM_BOT_TOKEN!)_10_10bot.command('start', async (ctx) => {_10 if (!ctx.from) return_10 const account = await getOrCreateWallet(ctx.from.id)_10 await ctx.reply(`Wallet created! All gas is sponsored — you never need ETH.\n\nAddress: ${account.address}`)_10})
That's the whole onboarding. No seed phrase, no download, no funding step.
Step 4: Check balances
Reads don't need Openfort at all. Query the chain directly with viem:
_14const publicClient = createPublicClient({ chain: baseSepolia, transport: http() })_14_14export async function getBalances(address: `0x${string}`) {_14 const [wei, usdcUnits] = await Promise.all([_14 publicClient.getBalance({ address }),_14 publicClient.readContract({_14 address: USDC_ADDRESS,_14 abi: erc20Abi,_14 functionName: 'balanceOf',_14 args: [address],_14 }),_14 ])_14 return { eth: formatEther(wei), usdc: formatUnits(usdcUnits, 6) }_14}
Step 5: Send USDC, gas-free
sendTransaction does the heavy lifting in one call: it upgrades the wallet to an EIP-7702 delegated account if needed, builds the intent, signs it server-side, and submits it through the sponsor.
_17export async function sendUsdc(telegramUserId: number, to: `0x${string}`, amount: string) {_17 const account = await getOrCreateWallet(telegramUserId)_17 const data = encodeFunctionData({_17 abi: erc20Abi,_17 functionName: 'transfer',_17 args: [to, parseUnits(amount, 6)],_17 })_17_17 const result = await openfort.accounts.evm.backend.sendTransaction({_17 account,_17 chainId: 84532, // Base Sepolia_17 interactions: [{ to: USDC_ADDRESS, data }],_17 policy: process.env.OPENFORT_GAS_POLICY_ID,_17 })_17_17 return result.response?.transactionHash_17}
The user's wallet holds zero ETH, and the transaction still lands — the sponsor relayer pays. Fund test wallets with USDC from Circle's faucet.
Step 6: Try it without Telegram, then go live
The recipe keeps command handlers as plain functions, so pnpm demo drives the exact flow — /start → /balance → /send — with a fake Telegram user and prints explorer links for each transaction. No bot token required.
When it looks right, add TELEGRAM_BOT_TOKEN to .env and run pnpm start. grammY long-polls Telegram, so there's nothing to deploy or expose. It runs from a laptop or any server with outbound internet.
App-first: non-custodial wallets in a Mini-App
The bot-first pattern trades custody for zero-friction chat. If your product needs users to hold their own keys — or a real UI — build a Telegram Mini-App instead: a React app running inside Telegram, using Openfort embedded wallets.
The flow: BotFather's /newapp links a web app to your bot, Telegram hands the app a signed initData object identifying the user, your backend validates it and exchanges it for an Openfort session via custom auth — and the user gets a non-custodial wallet with the same no-seed-phrase onboarding.
Building a game? The Unity WebGL guide covers the same flow for game engines.
Both patterns can share one Openfort project: bot-created backend wallets for instant chat UX, embedded wallets for users who graduate to self-custody.
Production checklist
- Swap the JSON store for your database. Store only the account
idand address — Openfort holds the keys, you hold references. - Constrain the wallets. Signing policies cap contracts, methods, and amounts per wallet — defense-in-depth if your bot server is ever compromised.
- Guard commands with rate limits. Telegram user IDs are stable and spoofing-resistant inside the Bot API, but your bot is still a public endpoint.
- Plan the exit. Key export means "custodial" never means "locked in" — make it a command or a support flow.
Related reading
- How to build an agent wallet -- the same server-side signing pattern, applied to autonomous agents instead of chat users
- Embedded wallets explained -- how the non-custodial wallets in the Mini-App path work under the hood
- How to build a Telegram Mini-App with Unity WebGL -- the app-first flow for games
- Stablecoin payments guide -- turning the /send command into a real payments product
- Onboard users instantly with guest accounts -- the equivalent zero-friction onboarding pattern for web apps
