Subscribe to Tickets — Daily Missed-Call WhatsApp Alerts¶
Each weekday at 18:00 IST, customer orgs that opted in receive a WhatsApp summary of the day's missed inbound calls. Sent from our Astradial-owned MSG91 WhatsApp account (separate from any per-org MSG91 integration), to phone numbers the org's owner added on their Tickets page.
Why this exists¶
Hotel and small-clinic customers regularly tell us "a caller tried us, no one picked up, and we didn't realise until weeks later when they posted a Google review." The missed call is already in call_records with status='no_answer' — it just doesn't get in front of a decision-maker.
This feature closes that loop:
- The org owner adds their phone + their receptionist's phone + maybe their manager's phone.
- At 18:00 IST, if the day had at least one missed inbound call, those subscribers each get a WhatsApp:
Missed Call Report 13 May 2026 Hello Hari,
Today, 3 calls went unanswered.
At Astradial, we believe every single call is important, each one is someone who needs you. We urge you to take action!
No alert if the day was 100% answered — we don't spam zero-count days.
Architecture (one paragraph)¶
The four moving parts:
- Per-org toggle + subscriber list lives in two tables —
ticket_alert_subscribers(org_id, country_code, phone, name) and aticket_alerts_enabledboolean onorganizations. - Admin singleton config in
admin_whatsapp_configholds the MSG91 integrated number, namespace, and chosen template name. The MSG91 auth key lives inMSG91_ADMIN_AUTH_KEYenv (never in DB, never on UI). - Scheduler is a
node-cronjob inside the astrapbx process, fired at0 18 * * *Asia/Kolkata. Loops orgs with the toggle on, counts today's missed inbound calls (direction='inbound'ANDstatus IN ('no_answer','busy','failed','cancelled')ANDstarted_atin today-IST window), and sends one MSG91 bulk template request per org with per-recipient personalised variables. - UI is a Get Alerts side-Sheet on the Tickets page (org-scoped) and a WhatsApp side-Sheet on the admin dashboard (singleton-scoped).
Setup — one-time, by Astradial ops¶
After the feature lands on prod (CI deploy includes the three migrations and the scheduler):
-
Auth key in prod env (only required on first install or when rotating the MSG91 admin auth key):
ssh root@89.116.31.109 'cp /opt/astrapbx/.env /opt/astrapbx/.env.bak-$(date +%Y%m%d-%H%M%S)' ssh root@89.116.31.109 'echo "MSG91_ADMIN_AUTH_KEY=<value>" >> /opt/astrapbx/.env' ssh root@89.116.31.109 'pm2 restart astrapbx --update-env' ssh root@89.116.31.109 'pm2 logs astrapbx --lines 50 --nostream | grep "Ticket-alert"'Expected last line:
✓ Ticket-alert scheduler armed (0 18 * * * Asia/Kolkata). -
Admin singleton via the UI:
- Sign in as admin at
editor.astradial.com/dashboard. - Click the new WhatsApp button (between Flow Editor and Logout).
- Confirm Auth key (env) says
presentand Ready to send iscomplete the fields below. - Fill in:
- Integrated Number: the MSG91 integrated number (e.g.
15558897024) - Template Namespace: MSG91 underscore-UUID, e.g.
ab7728b6_9e3c_4160_b51e_958e57f151e0 - Template: pick from the dropdown (populated from MSG91's approved-templates API). Choose
missed_calls_alert. - Language:
en
- Integrated Number: the MSG91 integrated number (e.g.
- Click Save Configuration. The readiness summary should flip to
yes.
- Sign in as admin at
-
Smoke test with the Test Send button:
- In the same side-Sheet, scroll to Test Send.
- Phone:
91XXXXXXXXXX(E.164 without+, your own number) - Sample Name:
Hari, Sample Count:3 - Click Send Test Message. Toast should say
Test message queued by MSG91. WhatsApp arrives within a few seconds.
Per-org enablement — by the customer's owner/admin¶
- Sign in as the org's owner or admin.
- Navigate to Tickets.
- Click Get Alerts (next to the existing WhatsApp button).
- Toggle Enable Daily Alerts to ON.
- Add subscribers:
+91prefix is fixed (non-editable in v1).- Phone: 10-digit Indian mobile starting with 6-9 (TRAI numbering rules).
- Name: 1-120 chars, used as
{{1}}in the WhatsApp body (Hello {{1}}). - Click the + icon. Row appears below.
- Repeat for each person who should be alerted.
- Done — the next 18:00 IST tick will deliver if the day had ≥1 missed call.
To unsubscribe a number, click the 🗑 icon next to the row. To pause all alerts for the org, toggle Enable Daily Alerts OFF — subscribers are preserved.
Sender API (for reference)¶
POST https://api.msg91.com/api/v5/whatsapp/whatsapp-outbound-message/bulk/
authkey: <MSG91_ADMIN_AUTH_KEY>
Content-Type: application/json
{
"integrated_number": "15558897024",
"content_type": "template",
"payload": {
"messaging_product": "whatsapp",
"type": "template",
"template": {
"name": "missed_calls_alert",
"language": { "code": "en", "policy": "deterministic" },
"namespace": "ab7728b6_9e3c_4160_b51e_958e57f151e0",
"to_and_components": [
{
"to": ["919944421125"],
"components": {
"header_1": { "type": "text", "value": "13 May 2026" },
"body_1": { "type": "text", "value": "Hari" },
"body_2": { "type": "text", "value": "3" }
}
}
// …one entry per subscriber, personalised
]
}
}
}
The scheduler builds one of these per org per run.
Template authoring¶
The message template missed_calls_alert is hand-managed in the MSG91 dashboard. Its current shape:
Header: Missed Call Report {{1}}
Body: Hello {{1}},
Today, {{2}}calls went unanswered.
At Astradial, we believe every single call is important, each
one is someone who needs you. We urge you to take action!
Button: View details
Variable mapping the scheduler injects:
| Position | Source |
|---|---|
header_1 {{1}} | Today's date as DD MMM YYYY in IST (e.g. 13 May 2026) |
body_1 {{1}} | The subscriber's name column |
body_2 {{2}} | The org's missed-call count for the day |
If you change the template (more variables, different wording), update api/src/jobs/ticketAlertScheduler.js's recipients.map(...) block to match the new variable positions.
What counts as a "missed call"¶
For the daily count:
org_id = <this org>direction = 'inbound'— excludes outbound and internal ext-to-ext calls.status IN ('no_answer', 'busy', 'failed', 'cancelled')— anything that didn't end with a human picking up.started_atbetween today 00:00 IST and tomorrow 00:00 IST.
A call that rings 1006 → no answer → falls over to 1007 → answered counts as one connected call, zero missed (we look at the final disposition, not the per-leg ring outcomes). A call that rings 1006 → 1007 → both fail → MOH timeout counts as one missed call.
Troubleshooting¶
My subscribers didn't get the message at 18:00 IST¶
Check the audit_log table:
SELECT created_at, action, org_id, details
FROM audit_log
WHERE action LIKE 'ticket_alert%'
ORDER BY created_at DESC LIMIT 20;
Expected actions:
| Action | Meaning |
|---|---|
ticket_alert.run.abort | The whole sweep refused to start — usually MSG91_ADMIN_AUTH_KEY missing or admin singleton incomplete. Read details.reason. |
ticket_alert.skip | An org was skipped. details.reason is zero_missed (no calls today) or no_subscribers (toggle on but list empty). |
ticket_alert.send.ok | One org's batch went out. details.recipient_count says how many subscribers were targeted. |
ticket_alert.send.fail | MSG91 rejected the call OR an unexpected error. details.error has the upstream message. |
If you see no ticket_alert.* entries at all on the day after a 18:00 IST window, the scheduler isn't running:
ssh root@89.116.31.109 'pm2 logs astrapbx --lines 200 --nostream | grep -i "ticket-alert\|scheduler"'
# Expected: "✓ Ticket-alert scheduler armed (0 18 * * * Asia/Kolkata)"
If that line is missing, the astrapbx process restarted without re-arming → pm2 restart astrapbx --update-env.
Test Send returns HTTP 502 "msg91 send failed"¶
The MSG91 API responded with an error. Read the detail field in the response — common causes:
- Template not in
APPROVEDstatus (MSG91 will only send approved templates) integrated_numberdoesn't match an active number on the MSG91 accountnamespacetypo (underscore-UUID format — letters/digits separated by_, NOT-)- Auth key revoked or rotated — update
MSG91_ADMIN_AUTH_KEYenv +pm2 restart astrapbx
A subscriber unsubscribed on WhatsApp; do we still send?¶
Yes — MSG91 honours the recipient's opt-out at their layer (delivery just silently fails for that number). The org's subscriber list isn't pruned. If a subscriber consistently doesn't receive messages, the org admin should remove them from the list manually.
Can a customer org configure their own MSG91 account for these alerts?¶
Not in v1. The alert is from Astradial to the org's owner, branded as Astradial. If you want per-org MSG91 here, it'd be a non-trivial schema change (admin singleton → per-org config) and a UX rethink.
Related¶
- Hotel Call Automation — different feature, but also a missed-call response mechanism.
- Troubleshooting Error 56 / 57 / 58 — recent audio-pipeline incidents whose fixes (mu-law + a-law siblings) ensure that missed-call MOH plays correctly, which is upstream of "did the call actually miss".
Engineering handoff (for future agents and humans)¶
Everything below is for someone extending or debugging this feature later. Skip it if you're an operator using the feature day-to-day.
Repo layout¶
Two repos are involved:
astradial-platform/ (api + editor monorepo)
├── api/
│ ├── database/migrations/
│ │ ├── 20260513200000-create-ticket-alert-subscribers.js
│ │ ├── 20260513200100-add-ticket-alerts-enabled-to-orgs.js
│ │ └── 20260513200200-create-admin-whatsapp-config.js
│ ├── src/models/
│ │ ├── TicketAlertSubscriber.js (per-org subscriber row)
│ │ ├── AdminWhatsappConfig.js (singleton helpers)
│ │ ├── Organization.js (+ ticket_alerts_enabled column)
│ │ └── index.js (associations + exports)
│ ├── src/routes/
│ │ ├── ticket-alerts.js (org-scoped CRUD)
│ │ └── admin-whatsapp.js (admin singleton + test-send)
│ ├── src/services/
│ │ └── msg91Service.js (listTemplates + sendBulkTemplate)
│ ├── src/jobs/
│ │ └── ticketAlertScheduler.js (the cron + runOnce)
│ └── src/server.js (route mounts + scheduler start/stop)
│
└── editor/
├── app/api/admin/whatsapp/
│ ├── route.ts (GET/PATCH proxy)
│ ├── templates/route.ts (GET proxy)
│ └── test-send/route.ts (POST proxy)
├── app/dashboard/
│ ├── page.tsx (admin panel WhatsApp Sheet)
│ └── [orgId]/tickets/page.tsx (Get Alerts Sheet)
└── lib/pbx/client.ts (ticketAlerts.* + adminWhatsapp.*)
PR history (chronological)¶
| PR | Branch | Base | What |
|---|---|---|---|
| #167 | feat/ticket-alerts-db | staging | Schema + models |
| #168 | feat/ticket-alerts-api | staging | API routes + MSG91 client |
| #169 | feat/ticket-alerts-scheduler | staging | node-cron job |
| #170 | feat/ticket-alerts-frontend | staging | UI (org + admin) |
| #171 | staging | main | Promotion of all four to prod |
Auth model — read this before touching admin routes¶
The PBX admin routes (api/v1/admin/whatsapp/*) accept either:
Authorization: Bearer ${INTERNAL_API_KEY}— the shared-key path. Used by the editor's server-side proxy.Authorization: Bearer ${jwt}wherejwt.isAdmin === true— JWT path. Used by ops scripts.
The browser never holds INTERNAL_API_KEY. The flow is:
browser editor (Next.js) astrapbx (Express)
───── ──────────────── ──────────────────
gateway_admin_key
in localStorage
│
│ Authorization: Bearer <gateway_admin_key>
▼
/api/admin/whatsapp ─── verify gateway_admin_key matches GATEWAY_ADMIN_KEY env
│
│ Authorization: Bearer <INTERNAL_API_KEY> ◄── server-side only
▼
/api/v1/admin/whatsapp/config
If you change the auth model, BOTH the editor proxy (editor/app/api/admin/whatsapp/route.ts) AND the PBX middleware (api/src/routes/admin-whatsapp.js::requirePlatformAdmin) need to stay in sync.
The org-scoped routes (/api/v1/orgs/:orgId/ticket-alerts/*) use the standard authenticateOrg + requireRole('admin') middleware — the editor calls them through /api/pbx/* rewrite with the org's JWT in the Authorization header. No proxy needed.
Schema invariants (don't break these)¶
-
admin_whatsapp_configis a singleton. Enforced byunique index ux_admin_whatsapp_config_singleton on (is_singleton)whereis_singletondefaults toTRUE. Inserting a second row will 1062. Always read viaAdminWhatsappConfig.getSingleton(). -
ticket_alert_subscribers.country_codedefaults to'91'and v1 only accepts'91'. Future country expansion: update theINDIAN_MOBILE_REGEXinTicketAlertSubscriber.jsto a per-country dispatch, and updateticket-alerts.jsroute's hardcodedcountry_code !== '91'check (currently rejects anything else with 400). -
Composite unique on
(org_id, country_code, phone)means resubmitting the same phone returns 409 (not 500). The UI consumes the 409 to show "already subscribed" inline. Don't change this to silent-upsert without coordinating with the UI. -
MSG91_ADMIN_AUTH_KEYis NEVER in DB and NEVER in any API response body. Theauth_key_present: booleaninGET /admin/whatsapp/configis the only signal that the key is set. If a future caller needs the key value itself, push back — the design choice is rotation-by-env-edit, not API.
Hot path: a single 18:00 IST run¶
Inside ticketAlertScheduler.js::runOnce():
- Refuse if
MSG91_ADMIN_AUTH_KEYenv missing — audit-logticket_alert.run.abort, return. - Refuse if
AdminWhatsappConfig.getSingleton().isReadyForSend()is false — audit-logticket_alert.run.abortwithmissing.*breakdown, return. Organization.findAll({ where: { ticket_alerts_enabled: true } }).- Per org:
countMissedForOrg(orgId, startUtc, endUtc)— single COUNT query againstcall_recordswith the (direction, status, started_at) filter.- If 0 →
ticket_alert.skipwithreason: 'zero_missed', continue. TicketAlertSubscriber.findAll({ where: { org_id } }).- If empty →
ticket_alert.skipwithreason: 'no_subscribers', continue. - Build
recipients[](one entry per subscriber, per-recipientcomponents). msg91.sendBulkTemplate(...)— ONE HTTP call for the whole org.- On
result.ok = false→ticket_alert.send.failwith details, continue. - On success →
ticket_alert.send.okwithrecipient_count+missed_count.
The whole sweep is O(orgs) HTTP calls to MSG91, not O(subscribers). If the customer base grows to 10k orgs, this stays linear and finishes well inside the 1-hour window before the next cron tick.
Timezone handling — don't reinvent¶
The IST window is computed in todayIstWindow() using toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' }) to derive today's IST YYYY-MM-DD, then constructing UTC bounds with explicit +05:30 offset:
const ymd = now.toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' }); // '2026-05-13'
const startUtc = new Date(`${ymd}T00:00:00+05:30`); // UTC 2026-05-12T18:30Z
const endUtc = new Date(startUtc.getTime() + 24 * 60 * 60 * 1000); // UTC 2026-05-13T18:30Z
This avoids any dependency on the host's TZ env var. The astrapbx process can be running in UTC or whatever and the count is still correct.
node-cron itself handles the 0 18 * * * Asia/Kolkata schedule — IST-DST is not a thing (India hasn't observed DST since the 1940s) so this is stable forever.
Adding a new template / changing variables¶
The template missed_calls_alert is authored in MSG91 (out-of-band). If you change its variable count or order:
- Update
ticketAlertScheduler.js'srecipients.map(...)block — currently buildsheader_1,body_1,body_2. - Update
admin-whatsapp.js's test-send handler in the same way — keeps Test Send and the real cron behaviour in lockstep. - The chosen template name is stored in
admin_whatsapp_config.selected_template_name— admin can swap templates via the UI without code changes, BUT only if the new template has the same variable shape. Different shape = code change required.
If you want admin-configurable variable mapping (e.g. dropdowns "what fills body_1?"), that's a future enhancement. For v1 the mapping is hardcoded.
Known v1 limitations (potential future PRs)¶
| Limit | Where | What to change |
|---|---|---|
| Indian numbers only | country_code validation in TicketAlertSubscriber.js + route | Generalise the validator; UI currently has +91 hardcoded as readonly |
| One template for all orgs | admin_whatsapp_config.selected_template_name is a singleton field | Move to per-org or per-language template selection |
| Hardcoded IST | CRON_SPEC + TIMEZONE constants in scheduler | Add per-org alert_timezone column on organizations; spawn one cron per timezone or use a single 1-minute heartbeat |
| No per-subscriber language preference | All recipients get the same template_language from the singleton | Add language to ticket_alert_subscribers; group by language when batching MSG91 calls |
| Skipped sends aren't backfilled | If the scheduler is down during the 18:00 tick, that day is lost | Add a "catch-up" mode that runs on every astrapbx restart: check audit_log for missing ticket_alert.run.* entries in the last N days, run those |
| No delivery report ingestion | MSG91 sends a callback on delivery status — we ignore it | Add a webhook receiver in astrapbx; persist per-message delivery state for the admin UI |
How to reproduce a real send for debugging (without waiting for 18:00 IST)¶
# Generate an admin JWT on staging (dotenv writes its tip to stdout — redirect via file)
ssh root@94.136.188.221 'cd /opt/astrapbx
node -e "require(\"dotenv\").config({quiet:true});const j=require(\"jsonwebtoken\");process.stdout.write(j.sign({isAdmin:true,email:\"hari\"},process.env.JWT_SECRET,{expiresIn:\"10m\"}))" > /tmp/jwt 2>/dev/null
JWT=$(cat /tmp/jwt)
# Fire one test send with sample variables
curl -sS -X POST http://127.0.0.1:8000/api/v1/admin/whatsapp/test-send \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"919944421125\",\"sample_subscriber_name\":\"Hari\",\"sample_count\":3}"
rm -f /tmp/jwt'
Or skip the JWT entirely and call with INTERNAL_API_KEY:
ssh root@94.136.188.221 'cd /opt/astrapbx
IKEY=$(grep "^INTERNAL_API_KEY=" .env | cut -d= -f2-)
curl -sS -X POST http://127.0.0.1:8000/api/v1/admin/whatsapp/test-send \
-H "Authorization: Bearer $IKEY" \
-H "Content-Type: application/json" \
-d "{\"phone\":\"919944421125\",\"sample_subscriber_name\":\"Hari\",\"sample_count\":3}"'
To run the FULL sweep on demand (bypasses the cron, sends to all enabled orgs with missed calls today) — no API endpoint for this in v1; do it from a node REPL:
ssh root@94.136.188.221
cd /opt/astrapbx
node -e 'require("dotenv").config({quiet:true});require("./src/jobs/ticketAlertScheduler").runOnce().then(s => console.log(JSON.stringify(s,null,2)))'
This calls the same runOnce() the cron uses. Audit-log entries get the same shape. If you add a /admin/whatsapp/run-now HTTP endpoint, gate it carefully — it sends real WhatsApp messages to real subscribers.
Conventions to match if you extend this feature¶
- Multi-PR rollout for any substantial change. Schema → API → background work → UI. Each PR independently reviewable and rollback-safe. See the four-PR breakdown above.
- Phone validation at three layers: UI input mask + route validator + model
validates. Defense in depth, no single bypass. - Audit-log every autonomous action. Pattern:
ticket_alert.<verb>.<outcome>(e.g.ticket_alert.send.ok). StructureddetailsJSON. Useaudit()in the scheduler — it never throws, so an audit-log failure can't kill the run. - Server-derived UI flags.
is_ready_for_sendis computed by the backend, not by the UI; the UI just renders the boolean. Same forauth_key_present. Prevents UI/backend drift. - Comments explain WHY, not WHAT. Look at the comments in
msg91Service.js::sendBulkTemplatefor the style — every non-obvious choice has a "this is here because X" line.
Code pointers¶
api/src/jobs/ticketAlertScheduler.js— the cron +runOnce()api/src/routes/ticket-alerts.js— org CRUD (+ subscriber validation)api/src/routes/admin-whatsapp.js— singleton config + Test Send +requirePlatformAdminmiddlewareapi/src/services/msg91Service.js— MSG91 client (listTemplates,sendBulkTemplate)editor/app/dashboard/[orgId]/tickets/page.tsx— Get Alerts Sheet (state, handlers, JSX)editor/app/dashboard/page.tsx— admin WhatsApp Sheeteditor/app/api/admin/whatsapp/{route,templates/route,test-send/route}.ts— auth-laundering proxieseditor/lib/pbx/client.ts—ticketAlerts.*,adminWhatsapp.*