Hi! I'm a mobile developer (Kotlin by day), and a couple of weeks ago I started a side project with a simple, slightly audacious idea: a chat where sending a message costs money — and the recipient gets paid. You write "hi", you pay. Someone writes to you, you earn.
This is the story of how "what if attention literally had a price" became a working Telegram Mini App with real on-chain transactions — and the rakes I stepped on along the way, from app store rules to the anatomy of TON gas. An LLM wrote the code with me, we argued about architecture together, but the debugging bills were mine — literally, since debugging happened on real money.
Free messages are worthless — in both senses. Spam, "hey what's up" from strangers, group chats with 10,000 unread messages. What if we flip it: every character costs money?
The economics get interesting fast:
I called it Centence. Between the idea and the product stood two walls.
A mobile developer's first reflex is a native app. It's also the last one: on iOS and Android any payment for "digital content" must go through in-app purchases — 30% commission and rules that treat P2P money transfers between users as a minefield, and crypto as a minefield in fog.
Option two: a Telegram Mini App. But there's a rule there too — digital goods and services inside mini apps must be sold for Telegram Stars. Paying for "a message" with Stars, with the platform taking a cut, killed the whole point: money must go to the recipient, directly.
Then came the pleasant realization: the Stars rule covers buying digital goods from the app. If the app sells nothing and never touches money — if it's just an interface through which one person transfers money from their wallet to another person's wallet — that's not a purchase. That's a transfer.
The moment a service accepts users' money into its own account (even "for a second", even "just to forward it"), it becomes a money operator. That means licenses, KYC/AML, a legal entity — everything that kills a side project on day one.
Hence the architectural principle that everything else obeys:
The platform never touches user funds. No app balance, no deposits, no withdrawals, no escrow, no refunds. The server holds no private keys and is physically incapable of moving anyone's money.
A payment goes from the sender's wallet straight to the recipient's wallet via TON Connect. My fee (10% on direct messages) is a separate output of the same transaction, to my own wallet. If the server gets hacked — there's no money in it. None.
Legal bonus: since we're not a money operator, no licenses and no legal entity needed. Taxes on the fee are ordinary personal income.
The central technical problem of a non-custodial design: the server must figure out that a specific on-chain transaction pays for a specific message — while the transaction is signed by the user in their own wallet, and until the network confirms it, no "payment" exists.
The scheme:
1. Client sends the text to the server → server computes the price,
stores {uuid, text, amounts, status: pending}
2. Client builds the transaction: transfer to the recipient (90%) +
transfer to the platform (10%), both carrying the uuid as a comment
3. User signs it in their wallet (TON Connect)
4. An indexer polls toncenter for incoming transfers to the platform
wallet: comment == uuid AND amount == exact expected fee → confirmed
5. Only after "confirmed" can the recipient open the text
A few invariants I will die defending in code review:
The stack is boring, and that's a compliment: Bun + Fastify + bun:sqlite on the server, React + Vite + TON Connect UI on the client, TypeScript everywhere with shared types, deployed on Railway. No Postgres, no WebSocket, no Redis: SQLite on a volume and 7-second polling cover an MVP with room to spare. Every technology you don't adopt is a whole category of bugs you don't have.
Debugging a payment pipeline has a particular property: unit tests are green, and the truth only comes out on real transfers.
Rake 1: toncenter returns comment = null. My uuid traveled in the jetton transfer's forward_payload, unit tests passed — and the production indexer just couldn't see the comment: the v3 API returned comment: null. Turns out you decode it yourself from the base64 BOC: open the cell, check op-code 0x0, read the string tail. Thirty minutes of "the money left and the message didn't confirm" panic — and a 15-line decoder.
Rake 2: raw vs friendly addresses. toncenter returns raw form (0:hex), wallets in TON Connect accept only friendly ( UQ…). "Wrong address format in message at index 0" — on a live transfer, naturally. One Address.parse().toString() later it works, but you only learn this when a user (me) can't pay.
Rake 3: the webview caches your bundle forever. Telegram's webview loves serving a stale build after a deploy. A user reports a bug you fixed three versions ago. Fix: a version.json next to the bundle, client-side polling, and an "update available" banner.
Rake 4, my favorite: "insufficient funds, 0.11 TON required". Initially everything ran on USDT (a stablecoin — users think in dollars, seemed logical). Newcomers got 0.07 TON as a gas gift from a hot wallet so they could reply. Then a friend receives a message, money, the gift — taps "reply" — and the wallet demands 0.11 TON.
The anatomy: a jetton transfer isn't "send tokens", it's a chain of contract calls, and the wallet requires the gas budget upfront, per transfer. A direct message = two transfers (recipient + fee) = 2 × 0.05 TON of prepaid budget. Only ~0.01 actually burns, the rest returns — but all of it must sit on the balance at send time. The 0.07 gift mathematically couldn't cover a reply.
I bumped the gift, trimmed the budget… then realized I was treating symptoms. The disease was two-currency onboarding: users needed USDT for messages AND TON for gas. Normal for a crypto product. A funeral for a chat.
The fix looked like heresy: drop the stablecoin, price everything in native TON. The price is a constant 0.04 TON per character; dollars in the UI are just a reference line at the current rate.
What happened after the migration:
Volatility? Accepted deliberately: tickets are small, and if the rate moves several-fold, the per-character price is one constant to change. Fun fact: the original plan used the native coin; then I "improved" it to USDT; then reality reverted my improvement.
Threads in the public chat. I wanted replies — but how should they count? If replies boost the parent's rating ×2, nobody writes originals anymore, everyone parasites on hot roots. If they count for nothing, why reply? The answer: a reply is a payment to the author. 90% of a reply's price goes to the person you're replying to. Write something so good people pay to respond — your message literally earns. Ratings stay untouched: everyone pays for themselves, the crown belongs to a single message.
The newcomer gift. The first incoming direct message brings the recipient a bit of TON from the platform's hot wallet (limits: once per user, daily cap, total cap — so the wallet can't be drained). It's our money, not users' — non-custodial stays non-custodial. Why: an invited person can reply immediately without figuring out top-ups.
The profile as a billboard. If attention costs money, it can be resold: a user's profile has a "card" — a title, a line of text, a link. Write an expensive message → people open your profile → they see your ad. Links are https/t.me only, pre-moderated, reportable, and open behind a confirmation — so phishing can't ride on the bot's reputation.
Three days from idea to an MVP with live transactions, another week of iterations with early users. 63 unit tests on everything that touches money. Zero custody of user funds, zero legal entities, ~$10/month of infrastructure.
The first message in the product's history sold for $3: "Talk is cheap. This wasn't."
If you want to poke it: @centence_bot — early users get a TON welcome gift, and I personally send paid messages to interesting newcomers because I'm testing push notifications.
And a question for the room — an argument I keep having with myself: the indexer polls toncenter v3 every 7 seconds and at my volume it's flawless. Is there any point in webhooks/event streaming at small scale, or will polling forgive me for a long time yet? How do you confirm incoming TON payments in production?