Cropto — SMS Notifications & In-App (Visual) Sync
Audience: product owner / operator configuring MSG91 templates. Generated: 2026-06-28. Source of truth:
apps/api/src/workers/smsWorker.tsand the services that enqueue SMS jobs. If the code changes, re-verify.
1. How notifications work (architecture)
Cropto never calls MSG91 synchronously from an API request. Every SMS goes through a queue:
User action (API) ──► enqueue BullMQ job ──► return 200 to the client
(queue: sms-notifications)
SMS Worker (separate) ──► dequeue job ──► ① write in-app Notification (DB)
② send SMS via MSG91Two consequences that matter for this document:
- Two channels per event. Almost every event produces (a) an in-app notification row (
prisma.notification.create) that shows on the web, and (b) an SMS (when a template is configured). - The SMS is the optional channel. The in-app notification is the authoritative record. SMS is a convenience reach-out; several templates are designed to silently skip if not configured.
All SMS dispatch is centralised in smsWorker.ts — it is the only module that imports the MSG91 client (lib/msg91.ts). There is no code path that sends an SMS without going through the worker. This is what lets us guarantee the in-app/SMS pairing below.
2. Are SMS events in sync with visual (web) notifications?
Yes. Every event that triggers an SMS also creates an in-app notification that appears on the web app, so a user can always verify the event on screen — even if the SMS never reaches their phone (no signal, DND, carrier delay, wrong number, etc.). The SMS and the in-app notification are produced in the same worker job from the same event data.
Where the user sees it on the web:
| Surface | What it shows |
|---|---|
| 🔔 Notification bell (top bar) | Red count badge + dropdown list — reads the Notification table via GET /api/v1/notifications and /unread-count. Every SMS event lands here. |
| Notifications page | Full paginated history of the same rows. |
| Menu badges (sidebar) | Blue per-leaf "new" count on My Offers / My Bids / My Sales / My Purchases / Inquiries. |
| Row tint (teal) | The specific deal / engagement / lead-card / inquiry row is tinted teal ("new/unread") until opened. |
Colour note: teal = "new / unread", and clears when you open the item. The bell's red badge is the global "you have notifications" counter. Amber is reserved for caution/pending (shortlist, pending status, quota warning) — it is not an unread signal.
2.1 The user's specific concern — handset delivery
"If the SMS is not delivered or there's a mobile-network issue, can the user still verify the event on the web?"
Fully covered. The in-app notification is written server-side, before/ independent of the handset receiving anything. Whether the user's phone has signal has no bearing on the web notification. When they next open the app, the bell, the menu badge, and the teal row tint all reflect the event.
2.2 Reliability hardening (implemented)
Every worker handler now runs in this order:
createNotification(…) // ① in-app FIRST (authoritative)
trySendSms(envVar, …) // ② best-effort SMS — NEVER throwstrySendSms resolves the template from env and, if it's unset or the MSG91 call fails, logs and returns without throwing. Consequences:
- The in-app (visual) notification is always written first, so a missing template or an MSG91 outage degrades only the SMS channel — the user can still verify every event on the web.
- Because the SMS step never throws, the job never retries after the in-app write, so there are no duplicate bell entries from retries.
- Boot-time validation still applies: the API refuses to start in production if a mandatory template (group B in §3.2) is missing — so misconfiguration is caught at deploy, not silently at runtime.
This covers both the user's scenario (their phone not receiving the SMS) and the rarer server-side case (a misconfigured/unreachable MSG91).
3. SMS events — full catalogue
Legend:
Recipient — who receives the SMS + in-app notification.
Variables — values the platform injects into your MSG91 template, in order (
VAR1,VAR2, …). You write the wording around them in MSG91 (DLT).In-app? — always Yes (the visual notification described in §2).
SMS requirement:
- Mandatory — the API refuses to boot in production if the env var is missing (validated at startup). At runtime the SMS is still best-effort.
- Per-event — configure it so the event actually texts the user. If missing, the SMS is skipped (logged) and the in-app notification still fires — it is not boot-validated, so a missing one fails silently at the SMS layer only.
- Optional — intended to be left unset until you want it; SMS skipped silently, in-app notification still fires.
In all three cases the SMS is best-effort (never fails the job — see §2.2). "Mandatory" only changes the boot check, not runtime behaviour.
3.1 Engagement events (offers & bids)
| # | Event (trigger) | Recipient | Env var | Variables | SMS req. | In-app? |
|---|---|---|---|---|---|---|
| 1 | A trader/buyer makes an offer on your sell lead | Sell-lead owner | MSG91_TEMPLATE_ID_OFFER_ON_SELL_LEAD | VAR1 = product, VAR2 = offerId | Per-event | Yes |
| 2 | Someone places a bid on your buy lead | Buy-lead owner | MSG91_TEMPLATE_ID_BID_ON_BUY_LEAD | VAR1 = product, VAR2 = bidId | Per-event | Yes |
| 3 | Owner counters your offer | Offeror | MSG91_TEMPLATE_ID_OFFER_COUNTERED | VAR1 = product, VAR2 = offerId | Per-event | Yes |
| 4 | Owner counters your bid | Bidder | MSG91_TEMPLATE_ID_BID_COUNTERED | VAR1 = product, VAR2 = bidId | Per-event | Yes |
| 5 | Offeror rejects your counter | Lead owner (seller) | MSG91_TEMPLATE_ID_OFFER_COUNTER_REJECTED | VAR1 = product, VAR2 = offerId | Per-event | Yes |
| 6 | Bidder rejects your counter | Lead owner (buyer) | MSG91_TEMPLATE_ID_BID_COUNTER_REJECTED | VAR1 = product, VAR2 = bidId | Per-event | Yes |
| 7 | Owner rejects your offer | Offeror | MSG91_TEMPLATE_ID_OFFER_REJECTED | VAR1 = product, VAR2 = offerId | Per-event | Yes |
| 8 | Owner rejects your bid | Bidder | MSG91_TEMPLATE_ID_BID_REJECTED | VAR1 = product, VAR2 = bidId | Per-event | Yes |
3.2 Deal confirmation + contact share — MANDATORY
When an offer/bid is accepted, the deal is done and the two parties' phone numbers are shared. Both parties get the SMS + in-app notification.
| # | Event | Recipient | Env var | Variables | SMS req. | In-app? |
|---|---|---|---|---|---|---|
| 9 | Offer accepted (deal confirmed) | Both parties | MSG91_TEMPLATE_ID_OFFER_ACCEPTED | VAR1 = product, VAR2 = counterparty name, VAR3 = counterparty phone | Mandatory | Yes |
| 10 | Bid accepted (deal confirmed) | Both parties | MSG91_TEMPLATE_ID_BID_ACCEPTED | VAR1 = product, VAR2 = counterparty name, VAR3 = counterparty phone | Mandatory | Yes |
Privacy note: the counterparty's phone number is intentionally shared here — this is the contact-share moment. Phone numbers are masked in server logs.
3.3 Lead lifecycle
| # | Event | Recipient | Env var | Variables | SMS req. | In-app? |
|---|---|---|---|---|---|---|
| 11 | Owner closes a sell lead | Each pending offeror | MSG91_TEMPLATE_ID_SELL_LEAD_CLOSED | VAR1 = product | Per-event | Yes |
| 12 | Owner closes a buy lead | Each pending bidder | MSG91_TEMPLATE_ID_BUY_LEAD_CLOSED | VAR1 = product | Per-event | Yes |
| 13 | Your offer auto-rejected — lead expired | Offeror | MSG91_TEMPLATE_ID_OFFER_AUTO_REJECTED_LEAD_EXPIRED | VAR1 = product | Mandatory | Yes |
| 14 | Your bid auto-rejected — lead expired | Bidder | MSG91_TEMPLATE_ID_BID_AUTO_REJECTED_LEAD_EXPIRED | VAR1 = product | Mandatory | Yes |
3.4 Optional (SMS skipped silently until the template is set — in-app always fires)
| # | Event | Recipient | Env var | Variables | SMS req. | In-app? |
|---|---|---|---|---|---|---|
| 15 | Order placed (after accept / Buy Now) | Both parties | MSG91_TEMPLATE_ID_ORDER_PLACED | VAR1 = orderId, VAR2 = total (₹), VAR3 = qty (kg), VAR4 = product, VAR5 = deal URL | Optional | Yes |
| 16 | Dispatch needed (seller, after a bid accept) | Seller | MSG91_TEMPLATE_ID_DISPATCH_NEEDED | VAR1 = product, VAR2 = buyer name | Optional | Yes |
| 17 | Stock over-commitment alert | Seller | MSG91_TEMPLATE_ID_STOCK_OVERCOMMITMENT | VAR1 = product, VAR2 = committed kg, VAR3 = available kg | Optional | Yes |
| 18 | Order dispatched (seller marked dispatch) | Buyer | MSG91_TEMPLATE_ID_ORDER_DISPATCHED | VAR1 = orderId, VAR2 = product | Optional | Yes |
| 19 | Stale-receipt prompt (past 14-day window) | Seller + Buyer (admins in-app only) | MSG91_TEMPLATE_ID_RECEIPT_EXPIRY_PROMPT | VAR1 = orderId, VAR2 = qty (kg) | Optional | Yes |
| 20 | Grievance raised on a deal | Affected party | MSG91_TEMPLATE_ID_GRIEVANCE_RAISED | VAR1 = grievanceId | Optional | Yes |
| 21 | Grievance updated | Affected party | MSG91_TEMPLATE_ID_GRIEVANCE_UPDATED | VAR1 = grievanceId | Optional | Yes |
| 22 | Grievance resolved | Affected party | MSG91_TEMPLATE_ID_GRIEVANCE_RESOLVED | VAR1 = grievanceId | Optional | Yes |
3.5 Buyer-receipt lifecycle (deduct-on-receipt, CHG-029/CHG-020)
The buyer-affirms-receipt flow. In-app is created by orderReceipt.service; the worker adds the optional SMS (silently skipped until the template is set).
| # | Event | Recipient | Env var | Variables | SMS req. | In-app? |
|---|---|---|---|---|---|---|
| 23 | Buyer confirmed receipt (deal complete; stock moved) | Seller | MSG91_TEMPLATE_ID_RECEIPT_CONFIRMED | VAR1 = orderId, VAR2 = qty (kg) | Optional | Yes |
| 24 | Deal cancelled by buyer (with evidence) | Seller | MSG91_TEMPLATE_ID_DEAL_CANCELLED | VAR1 = orderId, VAR2 = qty (kg) | Optional | Yes |
| 25 | Deal auto-reversed (seller reversed a stale, unconfirmed deal) | Buyer | MSG91_TEMPLATE_ID_DEAL_AUTO_REVERSED | VAR1 = orderId, VAR2 = qty (kg) | Optional | Yes |
| 26 | Deal settled off-platform (seller closed it outside Cropto) | Buyer | MSG91_TEMPLATE_ID_DEAL_SETTLED_OFF_PLATFORM | VAR1 = orderId, VAR2 = qty (kg) | Optional | Yes |
| 27 | Profile nudge (daily 08:00 IST run only; honours the per-type SMS switch in Settings; max one per user per IST day) | The user | MSG91_TEMPLATE_ID_PROFILE_NUDGE | VAR1 = nudge title (e.g. "Add your KYC details") | Optional | Separately — in-app follows its own switch |
These four had a regression where the jobs were enqueued but had no worker handler (silent "No handler for job type") — fixed (commit
2ede793). They now route to a handler + the four env vars above (documented in.env.example).
4. Events that are in-app only (no SMS — no template needed)
These create a visual notification but deliberately do not send an SMS (low-urgency / advisory / high-frequency). No MSG91 configuration required.
- Offer withdrawn by the offeror → sell-lead owner
- Bid withdrawn by the bidder → buy-lead owner
- Offer/bid auto-rejected because the lead was fulfilled (quantity matched)
- Offer/bid auto-rejected because the lead was closed by the owner
- Counter expired (48h timer lapsed) → both parties
- New deal message (chat on an accepted deal) → the other party
- Profile-completion nudges (max 1/24h, snooze-aware)
- Quota warnings (1 left / exhausted)
Order dispatched, receipt-expiry prompt, and grievance raised/updated/resolved were previously in-app only — they now ALSO send an optional SMS (#18–22 in §3.4). Their in-app notifications are created by the originating service; the worker adds the SMS channel best-effort.
5. Configuration checklist (MSG91)
Configure 26 templates. Group them by urgency:
Must configure before production (boot-validated):
- [ ]
MSG91_TEMPLATE_ID_OFFER_ACCEPTED - [ ]
MSG91_TEMPLATE_ID_BID_ACCEPTED - [ ]
MSG91_TEMPLATE_ID_OFFER_AUTO_REJECTED_LEAD_EXPIRED - [ ]
MSG91_TEMPLATE_ID_BID_AUTO_REJECTED_LEAD_EXPIRED
Configure so these events actually text the user (in-app still fires if unset):
- [ ]
MSG91_TEMPLATE_ID_OFFER_ON_SELL_LEAD - [ ]
MSG91_TEMPLATE_ID_BID_ON_BUY_LEAD - [ ]
MSG91_TEMPLATE_ID_OFFER_COUNTERED - [ ]
MSG91_TEMPLATE_ID_BID_COUNTERED - [ ]
MSG91_TEMPLATE_ID_OFFER_COUNTER_REJECTED - [ ]
MSG91_TEMPLATE_ID_BID_COUNTER_REJECTED - [ ]
MSG91_TEMPLATE_ID_OFFER_REJECTED - [ ]
MSG91_TEMPLATE_ID_BID_REJECTED - [ ]
MSG91_TEMPLATE_ID_SELL_LEAD_CLOSED - [ ]
MSG91_TEMPLATE_ID_BUY_LEAD_CLOSED
Optional (safe to leave unset — SMS just won't send; in-app still works):
- [ ]
MSG91_TEMPLATE_ID_ORDER_PLACED - [ ]
MSG91_TEMPLATE_ID_DISPATCH_NEEDED - [ ]
MSG91_TEMPLATE_ID_STOCK_OVERCOMMITMENT - [ ]
MSG91_TEMPLATE_ID_PROFILE_NUDGE - [ ]
MSG91_TEMPLATE_ID_ORDER_DISPATCHED - [ ]
MSG91_TEMPLATE_ID_RECEIPT_EXPIRY_PROMPT - [ ]
MSG91_TEMPLATE_ID_GRIEVANCE_RAISED - [ ]
MSG91_TEMPLATE_ID_GRIEVANCE_UPDATED - [ ]
MSG91_TEMPLATE_ID_GRIEVANCE_RESOLVED - [ ]
MSG91_TEMPLATE_ID_RECEIPT_CONFIRMED - [ ]
MSG91_TEMPLATE_ID_DEAL_CANCELLED - [ ]
MSG91_TEMPLATE_ID_DEAL_AUTO_REVERSED - [ ]
MSG91_TEMPLATE_ID_DEAL_SETTLED_OFF_PLATFORM
Also set the MSG91 account vars (already in .env): MSG91_API_KEY, MSG91_SENDER_ID.
The repo-root
.env.exampleSMS section is kept in sync with this list (grouped Mandatory / Engagement / Optional, with per-template variable comments). The legacy E-Mandi templates (BID_PLACED,LISTING_CLOSED,NEW_DEMAND) have been removed — that model no longer exists.
6. Suggested message wording
You author the final DLT-approved text in MSG91; these are starting points (keep them short, ≤ 2 lines, English readable at Class-8 level). {VARn} markers map to the variables in §3.
| Env var | Suggested SMS text |
|---|---|
OFFER_ON_SELL_LEAD | New offer received on your {VAR1} listing. Login to Cropto to review. |
BID_ON_BUY_LEAD | New bid received on your {VAR1} requirement. Login to Cropto to review. |
OFFER_COUNTERED | The seller countered your offer on {VAR1}. Login to Cropto to respond. |
BID_COUNTERED | The buyer countered your bid on {VAR1}. Login to Cropto to respond. |
OFFER_COUNTER_REJECTED | Your counter on the {VAR1} sell lead was declined. Login to Cropto. |
BID_COUNTER_REJECTED | Your counter on the {VAR1} buy lead was declined. Login to Cropto. |
OFFER_REJECTED | Your offer on {VAR1} was not accepted. You can revise and offer again on Cropto. |
BID_REJECTED | Your bid on {VAR1} was not accepted. You can revise and bid again on Cropto. |
OFFER_ACCEPTED | Deal confirmed on {VAR1}. Contact {VAR2}: {VAR3}. Continue the trade directly. |
BID_ACCEPTED | Deal confirmed on {VAR1}. Contact {VAR2}: {VAR3}. Continue the trade directly. |
SELL_LEAD_CLOSED | The {VAR1} sell lead you offered on has been closed by the owner. |
BUY_LEAD_CLOSED | The {VAR1} buy lead you bid on has been closed by the owner. |
OFFER_AUTO_REJECTED_LEAD_EXPIRED | Your offer on {VAR1} expired as the lead is no longer active. |
BID_AUTO_REJECTED_LEAD_EXPIRED | Your bid on {VAR1} expired as the lead is no longer active. |
ORDER_PLACED | Order {VAR1} confirmed: {VAR2} for {VAR3} kg of {VAR4}. Details: |
DISPATCH_NEEDED | Action needed: mark dispatch for your {VAR1} deal with {VAR2}. Login to Cropto. |
STOCK_OVERCOMMITMENT | Stock alert: your {VAR1} listing of {VAR2} kg exceeds available stock ({VAR3} kg). Resolve on Cropto to protect your trust score. |
PROFILE_NUDGE | Cropto: {VAR1}. Complete your profile to build trust with buyers and sellers. |
ORDER_DISPATCHED | Order {VAR1} ({VAR2}) has been dispatched by the seller. Confirm receipt on Cropto once it arrives. |
RECEIPT_EXPIRY_PROMPT | Order {VAR1} ({VAR2} kg) is past the 14-day window. Login to Cropto to confirm, cancel, or settle it. |
GRIEVANCE_RAISED | A grievance ({VAR1}) was raised on your deal. Our team will review it. Login to Cropto. |
GRIEVANCE_UPDATED | Your grievance {VAR1} has an update. Login to Cropto to view it. |
GRIEVANCE_RESOLVED | Your grievance {VAR1} has been resolved. See the resolution on Cropto. |
7. Summary
- 22 SMS templates total: 4 mandatory, 10 per-event, 8 optional.
- Every SMS event also writes an in-app notification → users can always verify on the web (bell + menu badge + teal row tint), independent of whether the SMS reached their handset.
- Many events are in-app only (no SMS) by design.
- Hardened: the in-app notification is written before the (best-effort) SMS in every handler, so the visual is guaranteed even under a missing template or a server-side MSG91 outage (see §2.2).
