Notifications
How a customer hears about their own order. The console has its own live feed over Socket.IO; this is the other audience, and the two never share a path.
Order event
├── Socket.IO ──▶ the shop's console
└── Channels ──▶ the customer
Telegram is the first channel. Email, SMS, web push and WhatsApp are the same shape, and adding one does not touch the order code.
The architecture
One event, published once, delivered by whoever is listening.
Checkout / console
│
▼
NotificationsService.publish(OrderEvent)
│
├── TelegramNotificationService ← today
├── EmailNotificationService ← later
└── SmsNotificationService ← later
OrderEvent (order-event.ts) is the whole contract:
the store's slug and name, the customer's id, name, email and phone, and the
order's number, status, step, total and item count. It is deliberately not an
entity — a channel should not know how an order is stored, and the order code
should not know anybody is listening.
NotificationChannel is three members: a key, enabledFor(event) and
send(event). A channel must never throw; the service logs and carries on, so
one broken channel cannot take a checkout down with it.
Nothing is awaited. publish returns immediately. An order that saved and
then failed to notify is an order; an order that failed to save because a chat
app timed out is a lost sale.
To add a channel: write the class, add it to the array in
NotificationsModule. That is the whole change.
Telegram
One bot for the whole platform
A merchant never creates a bot, never holds a token and never configures a webhook. My Store runs a single bot, and a shop turns order updates on under Settings → Notifications → Telegram.
My Store Telegram bot
│
┌──────────────┼──────────────┐
Store A Store B Store C
customers customers customers
How a customer connects
Customer opens their order
↓
"Track on Telegram" (only when the shop has it on)
↓
API mints a one-time token (POST /orders/:number/telegram-link)
↓
https://t.me/<bot>?start=<token>
↓
Customer presses Start
↓
Telegram webhook (POST /telegram/webhook)
↓
Token verified, store + customer + order resolved
↓
Chat id saved, first message sent
The token is 32 random bytes. Only its hash is stored, so a copy of the table cannot be used to follow anybody's orders. It is single use and expires after 24 hours.
What gets sent
| Stage in the console | What the customer reads |
|---|---|
| Placed | Order confirmed |
| Paid | Payment received |
| Packed | Preparing your order |
| Shipped | Out for delivery |
| Delivered | Delivered |
| Cancelled | Order cancelled, with the shop's reason |
What a customer can say back
/status — where their latest order is, per shop they follow.
/stop — stop the messages. The connection is muted rather than deleted, so
pressing Track on Telegram again is what turns it back on.
Isolation
The rule is that nothing is trusted from the client or from Telegram. Not the store id, not the customer id, not the order id.
Telegram chat
→ connection (store_id + telegram_user_id, in the control database)
→ customer (looked up in THAT store's own database)
→ order (must belong to that customer, in that store)
Every hop is checked against the store's own database rather than against anything the caller said:
- Issuing a link reads the order from the shop's database and requires it to belong to the customer in the session. Another customer's order number is a 404 — not a hint that it exists elsewhere.
- Redeeming a token re-checks all of it. The token names a store, a customer and an order, but naming is not proof: the customer is loaded from that store's database, and the order must still be theirs.
- Sending looks chats up by
(store_id, customer_id). A chat belonging to another shop is not reachable from that query even with the same Telegram account.
One person, several shops. Connections are unique on
(store_id, telegram_user_id), so the same Telegram account can follow orders
from any number of shops. Each is its own row and says nothing about the
others: a status change at one shop messages that shop's chat only.
Why the control database
A Telegram update carries a chat id and nothing else — no subdomain, no path,
no header. Every other request to this API names its store, which is what lets
each shop live in its own ms_<slug> database. A chat cannot, so the lookup
from chat to store has to happen somewhere that knows every store. That is the
only reason these two tables carry a store_id at all.
telegram_connections store_id · customer_id · telegram_user_id
telegram_chat_id · display_name · muted_at
telegram_link_tokens store_id · customer_id · order_id · order_number
token_hash · expires_at · used_at
Setting it up
Three variables, on the API only:
TELEGRAM_BOT_TOKEN= # from @BotFather
TELEGRAM_BOT_USERNAME= # the bot's @name, without the @
TELEGRAM_WEBHOOK_SECRET= # openssl rand -hex 32
Leave them blank and the feature reports itself as off: no button on an order, no messages, nothing for a merchant to see.
Then point the bot at the deployment, once, from the platform console:
POST /platform/admin/telegram/webhook
{ "url": "https://api.my-store.shop/telegram/webhook" }
GET /platform/admin/telegram says whether the bot is configured and whether
Telegram can be reached.
A bot has exactly one webhook. That is why registering it is a decision somebody makes rather than something a deploy does — a staging machine that registered itself on boot would quietly take every shop's updates away from production.
Every update is checked against the secret, which Telegram echoes in the
X-Telegram-Bot-Api-Secret-Token header. An update without it is refused with
a 403 before anything is read.