Skip to content

Notifications, Short-Links & Remote Config — Technical Reference

Part of the Aashray Technical Reference. Pairs with Business Logic — Notifications, Short-links & Config, which describes these mechanisms in plain terms with no code detail. Related technical detail currently lives inline in: Accounts, Identity & Auth, Booking Lifecycle & Engine, Admin Panel Map (not yet split out into its own business file).

This file is 100% derived from reading the backend, admin, and app source on main — no operational/staff-knowledge content lives here (that's in the business-logic companion). It is the cross-repo, backend-authoritative spec for every way Aashray reaches a user outside the request/response cycle, plus two infrastructure services the app depends on. It covers: push notifications (Expo push, token capture at login, notification→deeplink navigation), transactional email (SES SMTP + Handlebars templates — which email sends when), WhatsApp (Meta Cloud API templates for booking creation and every status transition, wifi, and the daily kitchen meal count), short-links + the /go/ redirect service (how the app's Profile tutorial links resolve), and remote app config / version gating (the updates endpoint the app polls to force upgrades). Backend is the source of truth throughout; the member app and admin panel are described as clients of these mechanisms.

Where the three transports overlap: a single booking action typically fans out to email + WhatsApp + push through different code paths. sendUnifiedEmail (email) internally also calls sendUnifiedWhatsApp; push is sent separately by the calling controller via notifyCardno / sendDualUserNotifications.

Verification status: audited & anchored per docs/DOCS-METHODOLOGY.md on 2026-07-07 against aashray-backend main. Every rule carries a [src: …] anchor. This domain was found fully accurate — all discrepancies (Expo-not-FCM, uncleared stale tokens, '/home' vs '/' default mismatch, hardcoded WhatsApp recipients, local-file meal-count baseline, no admin route for updates) confirmed real against code; no false claims.


1. Push notifications (Expo)

What it is. Server-initiated push delivered through Expo's push service (expo-server-sdk), not raw FCM. Tokens are Expo push tokens of the form ExponentPushToken[…]. Firebase Cloud Messaging is only the underlying Android transport that Expo brokers; the backend never talks to FCM directly.

Member-app behavior. - A push token is registered when the app foregrounds and is passed into login (see Accounts & Identity). On Android the app creates a default channel (max importance). - Tapping a notification reads its data payload: a screen field (with optional params) drives in-app navigation via expo-router — a notification can land the user on any route. On error it falls back to Home. - Because notifications (and deep links) can enter the app on a deep screen, those screens must stand alone (clear back/Home affordance).

Backend rules & logic. - services/notification.service.js is a singleton NotificationService wrapping new Expo(). Config: defaultSound:'default', defaultTitle:'Notification', defaultBody:'This is a notification', receiptCheckDelay:15000, maxRetries:3, ratePerSecond:600, chunkSize:100. [src: services/notification.service.js:13-22,277] - sendPushNotifications(arg1, arg2?, arg3?) accepts two signatures: (tokens[], notification, options?) or (tokenData[], options?). It maps to per-token message objects, validates every token with Expo.isExpoPushToken (invalid/missing tokens are logged and skipped), throws if no valid tokens remain. - Each message: { to, sound, title, body, data:{ screen: screen||'/', ...data }, priority:'default', badge, channelId, categoryId, mutableContent, ttl }. Default screen when unset is '/'. [src: services/notification.service.js:39-61,174,186] - Delivery: messages are grouped into per-second buckets of ratePerSecond (600), each bucket chunked (~100) via chunkPushNotifications, sent with retry + linear backoff (1000ms × attempt, up to maxRetries), and a 1s pause between buckets. - After sending, _processReceipts fetches receipts and logs errors; a DeviceNotRegistered receipt is logged as a warning but the stale token is NOT removed from the card record. - helpers/notification.helper.js wrappers: - sendSingleNotification(token, options) — one recipient. - sendDualUserNotifications({primary, bookedBy, screen='/home', data}) — sends to a primary (direct token or cardno lookup in CardDb) and optionally the "booked-by" user (cardno lookup). Never throws; returns {success, sentCount, totalRequested}. Skips silently when no token found. - notifyCardno(cardno, {title, body, screen, data, sound}) — looks up token by cardno and sends one; returns {success, reason?} (missing_cardno/no_token/invalid_payload/error). - getOtherBookingUser(booking, cancellerCardno) — pure helper returning the other party (cardno vs bookedBy) for dual notifications on cancellation.

Token capture / lifecycle. - Capture: POST /api/v1/client/verifyAndLogin body carries { mobno, password, token }; on a correct password the backend runs CardDb.update({ token }, { where:{ mobno } }). The Expo token lives in card_db.token (TEXT, nullable). - Clear: GET /api/v1/client/logout?cardno=… sets token = null. - There is no dedicated token-refresh endpoint — the token is only rewritten on the next login. [src: controllers/client/auth.controller.js:78-87,131-134; models/card.model.js:83-86]

Every route.

Method Path Auth realm Purpose Key params/body
POST /api/v1/client/verifyAndLogin Public (credential) Login; also captures the Expo push token body { mobno, password, token }
GET /api/v1/client/logout Member Logout; clears stored token query cardno
POST /api/v1/profile/notification Member Generic/manual push sender (test or ad-hoc) body { tokenData: [{ token, title, body, screen, data }] }

(No standalone "send push" admin route exists — pushes are triggered inline by booking/admin controllers, below.)

Admin operations. Push is fired as a side-effect of admin actions, not from a dedicated screen: - Admin room / travel / adhyayan / flat management confirm/cancel/checkout → sendDualUserNotifications(...). - adhyayanManagement soft-delete shibir → collects all affected cardno + bookedBy tokens and sendPushNotifications(tokens, { title:'Adhyayan Cancelled by Admin', body:'…cancelled by admin…', screen:'/bookings' }). - Member-side cancellations (room/travel/utsav booking controllers) notify the other party via notifyCardno(other, { title, body, screen:'/bookings' }).

Data & relationships. Single column card_db.token. No separate device/token table, so one token per card (last-logged-in device wins). Push recipients are always resolved from cardno → token.

Edge cases & branches. - Invalid or missing Expo tokens are dropped silently (logged) — a user who never logged in on this build gets nothing. - DeviceNotRegistered errors are observed but not self-healed (stale token persists). - sendDualUserNotifications defaults screen to '/home', while NotificationService defaults to '/' — inconsistent default target. - Rate limiting is best-effort in-process; a crash mid-send does not resume.

Discrepancies. - The client stack docs say "Firebase push notifications," but the backend uses Expo push (ExponentPushToken) — Firebase is only the Android transport. Treat Expo as the contract. [src: services/notification.service.js:13,174] - Stale-token cleanup is a known gap (DeviceNotRegistered not cleared). This is the code-level cause of the business-logic companion's "no way to update a stale phone notification address." [src: services/notification.service.js:235-237] - The two mismatched default deep-link targets ('/home' vs '/') mean a notification sent through a path that omits screen can land the user on a different fallback screen depending on which helper sent it. [src: helpers/notification.helper.js:49 ('/home'); services/notification.service.js:186 ('/')]


2. Transactional email

What it is. Handlebars-templated email sent through Amazon SES over SMTP via nodemailer. Fire-and-forget: sendMail uses a callback (no await/throw), so email failures never block a booking.

Member-app behavior. No in-app email UI; email is a passive confirmation channel. The member sees bookings/toasts in-app; the email arrives separately to card.email (or the booker's email).

Backend rules & logic. - utils/sendMail.js: transporter from SES_SMTP_HOST/PORT/USERNAME/PASSWORD; from = "Vitraag Vigyaan Aashray" <SES_SMTP_EMAIL>; header X-SES-CONFIGURATION-SET: aashrayconfigset; templates in /emails (.hbs, no layout, partials from same dir). Logs email_sent / email_send_failed. - controllers/helper.js orchestrators: - sendUnifiedEmail(cardno, bookingIds{type:[ids]}, bookedBy, bookingStatus=CONFIRMED, template='unifiedBookingEmail', sendWhatsApp=true) — loads details for each booked type (utsav/adhyayan/travel/room/flat/food) from the DB, builds a context with showAdhyanDetail/showRoomDetail/… flags, subject = getSubject(status), welcomeMessage = getWelcomeMessage(status, country). Sends to user.email || bookedBy.email. If travel booked, cardno != null, and NODE_ENV==='prod', also sends a copy to RAJ_PRAVAS_EMAIL. When sendWhatsApp is true it then calls sendUnifiedWhatsApp(...) (email + WhatsApp share this entry point). [src: controllers/helper.js:353-359,625,652-656,671-682; getSubject :327-335] - sendUnifiedEmailForBookedBy(userBookingIdMap, bookedBy, status, sendWhatsApp=true) — flattens a per-card map, detects self vs on-behalf, delegates to sendUnifiedEmail. - getSubject: pending → 'Bookings created', cancelled → 'Bookings cancelled', else 'Bookings confirmed'. - getWelcomeMessage: pending → NRI variant ("NRIs can pay at the Research Center upon arrival") vs domestic ("Payment is due within 24 hours"); cancelled/confirmed have their own copy. - helpers/mailer.helper.js: - sendCancellationEmail(cardno, bookingIds, bookedBy)sendUnifiedEmail(..., STATUS_CANCELLED, 'unifiedCancellationEmail', sendWhatsApp=false)WhatsApp deliberately suppressed here (cancellation WA is handled by the status-change functions in §3). - sendOpenBookingEmail(bookingType, openBookings) — when a waitlisted booking "opens up," dispatches per-type: utsav→sendUtsavBookingUpdateEmail, adhyayan→sendAdhyayanBookingUpdateNotification, travel→sendTravelBookingStatusUpdateMail.

When each email sends.

Event Trigger site Template Status
Booking created (self/guest/mumukshu) roomBooking / guestBooking / mumukshuBooking controllers → sendUnifiedEmailForBookedBy unifiedBookingEmail pending
Booking confirmed after payment payment.controllersendUnifiedEmail(...CONFIRMED, 'unifiedBookingEmail') unifiedBookingEmail confirmed
Admin manual room confirm roomManagement.controllersendUnifiedEmail(...CONFIRMED) unifiedBookingEmail confirmed
Cancellation sendCancellationEmail unifiedCancellationEmail cancelled
Waitlist opened sendOpenBookingEmail rajAdhyayanUpdate / utsavStatusUpdate / rajPravasStatusUpdate open/available
Forgot password auth.controller (line ~188) forgotPasswordEmail — (also WhatsApp temp password)
Maintenance request maintenanceRequest.controller maintainanceRequest
Travel booking (Raj Pravas) copy helper.js when travel + prod same template, RAJ_PRAVAS_EMAIL cc any

Every route. Email has no HTTP routes of its own — it is always a side-effect of the booking/auth/maintenance routes documented in their own domains.

Admin operations. Admin confirm/cancel actions reuse sendUnifiedEmail / sendCancellationEmail; travel management sends a sendMail(...) directly. There is no template-management UI — templates are checked-in .hbs files.

Data & relationships. Recipient = card_db.email; content assembled from the same booking tables (RoomBooking, FlatBooking, TravelDb, ShibirBookingDb, UtsavBooking, FoodDb) joined to ShibirDb/UtsavDb/UtsavPackagesDb. /emails templates present: unifiedBookingEmail, unifiedCancellationEmail, rajAdhyayanUpdate, rajAdhyayanCancellation, rajPravasStatusUpdate, rajPravasCancellation, _rajPravasConfirmation, rajSharanCancellation, utsavBooking, utsavStatusUpdate, forgotPasswordEmail, maintainanceRequest, plus _footer / styles partials.

Edge cases & branches. - If neither user.email nor bookedBy.email exists, no email is sent (silent). - Fire-and-forget: an SES failure is logged but never surfaced to the user or the API response. - The Raj Pravas cc only fires in prod.

Discrepancies. Cancellation email suppresses WhatsApp (WA cancellation is a separate transition-based path), whereas booking/open emails do fan out to WhatsApp — the "email ↔ WhatsApp" coupling is inconsistent by design and easy to misread.


3. WhatsApp messaging

What it is. Template ("HSM") messages via the Meta WhatsApp Cloud API. utils/sendWhatsAppMessage(phone, templateName, components, langCode?) POSTs to WHATSAPP_API_URL with Authorization: Bearer WHATSAPP_BEARER_TOKEN, language default WHATSAPP_DEFAULT_LANG (en_US). helpers/whatsapp.helper.js (~2600 lines) owns all template selection.

Member-app behavior. Passive channel — the member receives WhatsApp confirmations/updates; there is no in-app WhatsApp UI. Numbers are normalized by utils/phoneFormatter.formatWhatsAppPhone(mobno, country) (India → 91 prefix; country-code map for US/UAE/UK/etc.; UAE leading-1 correction; fallback 91).

Backend rules & logic. - Template naming convention: bn_/bk_ prefix + type token (adh=adhyayan, sha=room/sharan, flt=flat, pvs=travel/pravas, usv=utsav, psd=food/prasad) + audience (s_b self, gu_b guest booker, gu_f guest attendee, mu_b mumukshu booker) + status (w/wg waiting, ppg/pp payment-pending, cf/cnf/cnfm confirmed) + optional _nri. bn_ = booking notification (creation), bk_ = status-change transition. - sendWithTemplateFallback(phone, template, components) — tries the template in default lang, retries in en, then falls back to a "confirmed" variant (with parameter-count fixups for known templates), finally to booking_adhyayan_self_confirmed. Handles Meta's 404 "template does not exist." Non-fatal. - buildBodyComponents pads/normalizes params (Meta rejects empty strings → single space). - Creation fan-out: sendUnifiedWhatsApp(cardno|user, adhyayan[], travel[], flat[], utsav[], room[], bookedForCardno?, food[]) runs per-type senders concurrently (Promise.allSettled): sendAdhyayanWhatsApp, sendRoomWhatsApp, sendTravelWhatsApp, sendUtsavWhatsApp, sendFlatWhatsApp, sendFoodWhatsApp. - Status-change (transition) senders: sendAdhyayanStatusChangeWhatsApp, sendRoomStatusChangeWhatsApp, sendFlatStatusChangeWhatsApp, sendTravelStatusChangeWhatsApp, sendUtsavStatusChangeWhatsApp all take (booking, previousStatus, options), map prev→new status to a bk_* template, and dispatch separately to the attendee (self) and the booker (guest). They resolve credits refunded, payment id, and pick _a (admin) vs _c (cron/callback) variants from updatedBy/options.isCron. - Cost/volume skips: adhyayan "Param Gyaan Sabha" is skipped; PR (permanent resident) self food bookings are skipped (still sent when a PR books food for a guest). Adhyayan status-change WhatsApp only fires when the shibir location === RESEARCH_CENTRE.

Per-domain triggers (status-change messages).

Domain Function Triggered from
Room sendRoomStatusChangeWhatsApp admin room mgmt (confirm/cancel/checkin/checkout), payment callback, client room cancel
Flat sendFlatStatusChangeWhatsApp admin room/flat mgmt, payment callback, client cancel
Adhyayan sendAdhyayanStatusChangeWhatsApp adhyayanBooking.helper, admin adhyayan mgmt (RC only)
Utsav sendUtsavStatusChangeWhatsApp admin utsav mgmt, payment callback, client utsav cancel
Travel sendTravelStatusChangeWhatsApp admin travel mgmt, payment callback, client travel cancel

Other WhatsApp messages. - Wifi: sendWifiRequestWhatsApp(cardno, username, status, code?, deviceType?) — status→template map deleted→per_wf_code_req_del, rejected→…rej, reset→…res, pending→…pend, approved→per_wf_code_req_cnf_ad_m; device type deduced from username suffix (ph/pc/tb). sendWifiLowAlertWhatsApp(activeCount) → template temp_wifi_code_alert to a hardcoded admin number 919819988657. [src: helpers/whatsapp.helper.js:1257-1259] - Late checkout fee waived: sendLateCheckoutFeeWaivedWhatsApp(transaction)bk_sha_s_f_lcf_waived. - Password update: password_update_app template (from profile/auth password update). - Tomorrow's kitchen meal count: sendTomorrowMealsCount(recipients[]) — sums FoodDb (individual) + BulkFoodBooking breakfast/lunch/dinner for tomorrow (Asia/Kolkata), writes a local baseline file last_meals_count.json, and sends template daily_kitchen_meal_count to each recipient card. checkAndSendMealsCountUpdate() — recomputes and compares to the baseline file; if changed, notifies Maharaj (hardcoded cardno 0002823407); if no baseline exists (e.g. after restart) it saves the baseline and skips notifying to avoid a false alert. [src: helpers/whatsapp.helper.js:2418 (COUNT_FILE), 2494,2552-2559 (Maharaj :2559)]

Every route. WhatsApp has no HTTP routes — all sends are side-effects of booking/admin/wifi/cron code paths.

Admin operations. Admin booking-management actions and cron jobs are the primary WhatsApp initiators; the daily meal-count uses a scheduled job. No admin WhatsApp console exists in the reviewed code.

Data & relationships. Recipient phone from card_db.mobno + card_db.country. res_status gates PR food skips. Payment ids read from Transactions (razorpay_order_id). Meal counts read FoodDb + BulkFoodBooking.

Edge cases & branches. - No mobile number → skipped with a warning. - Template-missing (404) → fallback chain; if all fail, the error is logged (non-fatal). - NRI (country !== 'india') selects _nri payment-pending templates. - Day-visit room bookings (nights===0 / roomtype==='NA') use dedicated *_sdv templates. - The meal-count baseline is a single JSON file on local disk — on multi-instance or ephemeral (Render) deploys the baseline can reset, silently skipping one change notification. This is the code-level cause of the meal-count fragility referenced in the business-logic companion.

Discrepancies. - Two hardcoded recipients: wifi-low-alert phone 919819988657 and meal-count cardno 0002823407. These are the "fixed recipient lists" the business-logic companion refers to — changing them today requires an engineer to edit and redeploy code, not a staff-facing setting. [src: helpers/whatsapp.helper.js:1259,2559] - Meal-count durability depends on a local file, not the DB — fragile across restarts/instances. - Cancellation WhatsApp comes from the status-change functions, not from the cancellation email path (which sets sendWhatsApp=false) — the two channels diverge on cancellation.


What it is. A first-party URL shortener. Admins mint slug → target_url links (grouped by department type); the public /go/:slug endpoint 302-redirects and counts clicks. The member app's Profile/Support tutorial rows open these short URLs.

Member-app behavior. On Profile → Support, each help topic expands to tutorial rows with Apple/iOS and Android buttons that open platform-specific how-to links formatted as https://aashray.vitraagvigyaan.org/go/{slug} (a light haptic fires on open). The app doesn't create links; it only opens them.

Backend rules & logic. - Model short_links: slug (unique, string), target_url (TEXT), type (ENUM accounts|room|card|office|food|adhyayan|travel|utsav|avt|wifi, default wifi), active (bool, default true), click_count (int, default 0), createdBy (string), timestamps. [src: models/short_link.model.js:13-56] - Create validates: slug matches ^[A-Za-z0-9_-]+$, target_url is a valid http/https URL, type ∈ VALID_TYPES; uniqueness enforced in a locked transaction; createdBy = req.user.username. - Authorization is two-layer: route-level authorizeRoles(...) broad gate (any admin role) plus per-type check in the controller via TYPE_ROLE_MAP (each type → [ROLE_SUPER_ADMIN, ROLE_<TYPE>_ADMIN]). Update also re-checks the role for both the current and the new type when type changes. [src: controllers/admin/shortLink.controller.js:31-42,64-78,173-186; routes/admin/shortLink.routes.js:35,42-57] - Redirect: redirectShortLink finds { slug, active:true }, increment('click_count'), then res.redirect(target_url) (302). Missing/inactive → 404. [src: controllers/admin/shortLink.controller.js:135-148; routes/admin/redirect.routes.js:10; app.js:197-198]

Every route.

Method Path Auth realm Purpose Key params/body
POST /api/v1/short-links/ Admin (role-gated) Create a short link body { slug, target_url, type }
GET /api/v1/short-links/:type Admin (type role) List all links of a department type param type
PUT /api/v1/short-links/:id Admin (role-gated) Update target_url / type / active param id; body { target_url?, type?, active? }
DELETE /api/v1/short-links/:id Admin (role-gated) Delete a link param id
GET /go/:slug Public Resolve + count click + 302 redirect param slug

Mounts (app.js): /api/v1/short-links (admin, router.use(auth)) and / for the redirect router (GET /go/:slug), placed before the catch-all 404.

Admin operations. admin/common/shortLinks.js + .html UI: a client-side roleTypeMap computes the admin's allowed types from sessionStorage roles, fetches links per allowed type, and renders a table with Copy / Enable-Disable / Delete actions plus summary cards (Total / Active / Disabled / Total Clicks). It builds the display URL as https://aashray.vitraagvigyaan.org/go/{link.slug}.

Data & relationships. Standalone short_links table; type maps to admin roles via TYPE_ROLE_MAP (same role constants used across the Admin Panel Map). No FK to bookings/cards; createdBy is a free-text username.

Edge cases & branches. - Duplicate slug → 400 (SequelizeUniqueConstraintError also mapped to 400). - Disabled link (active:false) → 404 from /go/ (no distinct "disabled" message). - click_count increments on every hit including bots/link-previews; no dedupe or rate limit. - Changing a link's type requires the admin to hold a role for both old and new types.

Discrepancies. - Route-level and controller-level role checks are intentionally duplicated (commented as such) — redundant but not a bug. - The redirect is unauthenticated and uncounted-for-abuse (any public hit inflates click_count).


5. Remote app config / updates (version gating)

What it is. A minimal remote-config surface: a single updates table per-OS that the app polls to decide whether an upgrade is available or mandatory (force-update).

Member-app behavior. The app is expected to call the updates endpoint with its OS and compare its running version to latestVersion; when mandatory is true it should block usage until updated, showing releaseNotes. (Note: the app-client spec does not document this call — see Discrepancies.)

Backend rules & logic. - Model updates: os (ENUM android|ios), version (string), mandatory (bool, default false), releaseNotes (TEXT), timestamps. [src: models/updates.model.js:13-29] - checkForUpdates(req) validates os ∈ {android, ios} (400 otherwise; it lowercases os first, so mixed-case is accepted), fetches the latest row for that OS by createdAt DESC (404 if none), and returns { latestVersion, mandatory, releaseNotes }. [src: controllers/client/updates.controller.js:9-32; routes/client/updates.routes.js:6; app.js:164] - No per-user flags, no feature toggles, no A/B config — version gating only.

Every route.

Method Path Auth realm Purpose Key params/body
GET /api/v1/updates/ Public (no auth) Return latest version + mandatory flag + release notes for an OS query os=android\|ios

Admin operations. No admin route to create/edit updates rows exists in the reviewed code — rows are presumably inserted directly in the DB (or via an unshown tool). There is no UI counterpart in admin/.

Data & relationships. Standalone updates table; unrelated to cards/bookings. It is the only true "remote config" store. Note: the app-client spec's other "config" flags (showDevelopmentDashboard, isFlatOwner, credit balances) live on the user/card record, not here.

Edge cases & branches. - Missing/invalid os → 400; no version row → 404 (the app must treat 404 gracefully — likely "no update"). - "Latest" is purely by insertion time (createdAt), not by semantic version compare — an out-of-order insert could regress the reported version.

Discrepancies. - The app-client spec does not document the updates/force-update flow at all — the endpoint exists backend-side but its client consumer is unspecified here. - No auth on a version endpoint is fine, but there is also no admin management path for it — operationally opaque. [src: only routes/client/updates.routes.js references the model; no admin updates route/controller] - Version freshness relies on createdAt ordering rather than a version comparison.


How this connects to other domains (technical)

  • Accounts, Identity & Auth: login is where the Expo push token is captured (verifyAndLogin body token) and logout clears it; forgot-password fans out to both email (forgotPasswordEmail) and WhatsApp (temp password). The recipient identity for all three transports is the card_db row (token, email, mobno, country, res_status).
  • Booking Lifecycle & Engine and Payments, Credits & Reconciliation: booking creation and payment confirmation are the main fan-out points — sendUnifiedEmail (email + sendUnifiedWhatsApp) plus controller-level push. Status transitions (confirm/cancel/checkin/checkout, waitlist-open) drive the bk_* WhatsApp templates and sendDualUserNotifications.
  • Adhyayan & Utsav: adhyayan status-change WhatsApp is Research-Centre-gated; utsav updates have dedicated templates and emails; feedback deep links (/adhyayan/feedback/:id, /utsav/feedback/:id) are reached via the notification screen payload.
  • Admin Panel Map: short-link type authorization reuses the same admin role constants; admin booking-management actions are the primary initiators of push/WhatsApp/email side-effects; the shortLinks admin UI lives under admin/common/.
  • Services, WiFi, Maintenance, Support & Gate: wifi request lifecycle and low-code alerts, and maintenance requests, each have their own WhatsApp/email templates but share the same sendWhatsAppMessage / sendMail plumbing described here.
  • Deep-linking: the /go/ redirect and the push-notification screen payload are the two mechanisms that can land a user on a deep route; the deep-link route table lives in the app-client data-model appendix (API Reference).