Deep Links
A deep link is a short link (WhatsApp, email, QR code) that opens a specific screen, path or web page, with optional tracking. It is created in the admin app, served by a small link handler, resolved by an API, and acted on by this app.
For the admin-side how-to (creating links, recipes, URL building) see the Admin guide. This page is for developers.
The pieces
| Piece | Repo / service | Job |
|---|---|---|
| Admin app | admin-app-frontend | Create, edit, switch off, delete and customise links. Holds the list of features per module. |
| Admin API | admin-app-backend (/api/admin/deepLink) | Stores links in the Cosmos container deep_link. Paged, filtered list. Soft delete. Needs an admin login. |
| Public API | aquagenapi (/api/user/deepLink) | resolve/<id>: what a link opens (no login). pixel/<id>: the email open pixel. |
| Link handler | deeplink_handler_web → dl.aquagen.co.in | Static page. Reads the prefix and id, records the click, sends the person to the app or site. |
| This app | aquagen_web_appp | Captures the link, resolves it, merges the URL's extras, routes, records deep_link_handled. |
URL format
https://dl.aquagen.co.in/<prefix>/<LINK_ID>?utm=a,b&data=<base64url JSON>
| Prefix | Opens |
|---|---|
d | AquaGen: the mobile app if installed (otherwise the stores), the website on desktop |
ar | AquaRecycle website (the uwms app) |
lp | LakePulse website |
rwi | Rain Water Intelligence website |
i | Not a site: the email open pixel, served by aquagenapi at /api/user/deepLink/pixel/<id> |
The link id is generated (8 characters, A–Z and 0–9). Ids starting with TEST_ are test links: they work normally but record no analytics events.
End-to-end flow
Person taps https://dl.aquagen.co.in/d/ABCD1234?utm=email&data=...
│
▼
Link handler ── sends deep_link_click (user "deeplink_handler"; skipped for TEST_ links)
│
├─ phone, app installed ─────────► app opens via aquagen://d/ABCD1234?... (intent:// on Android)
├─ phone, no app (prefix d) ─────► Play Store / App Store
└─ desktop, or ar / lp / rwi ────► website, link handed over in a cookie (see Cookie handoff)
│
▼
This app (DeepLinkService.capture, before the router and splash)
│ saves { link, data, utm } as pending
▼
After sign-in (MainLayout → DeepLinkService.redirect)
│ GET /api/user/deepLink/resolve/<id> → { id, title, mobileCompatible, target }
│ 404 (off / deleted / unknown) → toast "This link is no longer active"
│ merge target + data + utm
│ record deep_link_handled (skipped for TEST_ links)
▼
HandleRedirect.handle → navigate to the screen, or open the URL
Nothing in the URL says where the link goes. That is stored on the server, so a link can be changed or switched off after it was shared.
The link document
Stored in the Cosmos container deep_link (partition key /id).
| Field | Meaning |
|---|---|
id | Generated id, also the URL's last part. TEST_ prefix for test links |
type | Always "link" |
title, description | Labels for admins; the title is also sent to Mixpanel |
module | d, ar, lp, rwi or i. Decides the prefix. Fixed once shared |
active | Off links resolve as not found |
mobileCompatible | Whether the mobile app may follow the link (default false) |
target | What it opens (below) |
deleted, deletedAt, deletedBy | Soft delete. Deleted links are filtered out of every query and resolve as not found |
createdAt, createdBy, updatedAt, updatedBy | Audit |
target is one of:
{ "mode": "feature", "feature": "ALERTS", "params": { "range": "week" }, "dynamicParams": ["email"], "utm": ["sms"] }
{ "mode": "path", "path": "/reports", "params": { } }
{ "mode": "custom", "type": "URL", "params": { "url": "https://example.com", "location": "inapp" } }
{ "mode": "custom", "type": "EMAIL_PIXEL", "params": { "imageUrl": "https://…" }, "dynamicParams": ["email"] }
How this app handles a link
All in libs/shared/src/services/deeplink/, plus one hook in the layout.
| File | Role |
|---|---|
deeplink.js | DeepLinkService. capture() runs from AppStore before the router, reads the cookie or ?link=, stores the pending link. redirect() runs from MainLayout after sign-in: resolve, merge, event, navigate. On Capacitor it also listens for appUrlOpen and the launch URL. |
redirectHandler.js | HandleRedirect.handle(target, navHelper, loginData, navigate). Does the navigating itself and returns nothing. |
libs/components/src/pages/layouts/MainLayout.jsx | Calls attach() / redirect(), shows the "no longer active" toast. |
libs/shared/src/services/api/urls.js | deepLinkBaseUrl for the resolve call. |
libs/shared/src/enums/analyticsEnum.js | DEEP_LINK_HANDLED: 'deep_link_handled'. |
mixPanelAnalytics.js | Adds deepLinkId and deepLinkTitle to later events in the session. |
Merge rules
- The saved link decides where it goes;
datacan never changemode,feature,path,typeor the URL. datamay only set the names in the target'sdynamicParams, and only simple values (string, number, boolean). It overrides a saved param of the same name.utmin the URL replaces the link's defaultutm; with none in the URL the defaults apply.- The result's
paramsare added to the route as a query string.
Routing by mode
| Mode | What HandleRedirect does |
|---|---|
feature | Looks the key up in the active app's navigation helper (routes, sideBarDetails for the permission check). Unknown key, or no permission → the app's home or locked page. Special keys live in featureHandlers (see below). |
path | Must start with / and must not start with // or /\; otherwise home. |
custom + URL | Opens params.url (http/https only): in-app browser (@capacitor/browser on a device, same tab on web) or external. |
any, on the mobile app, with mobileCompatible off | Opens the home page and does nothing else. |
Adding a feature that needs its own routing
- If the screen is in the active app's navigation helper, it works already by its key.
- If not (the energy pages are an example), add an entry to
featureHandlersinredirectHandler.jsthat returns the path.ENERGY_MONITORINGreturns/monitoring/energy_category/ENERGY_CATEGORY/CONSUMPTION, the same address the PowerGen toggle uses. - Add the key to
FEATURES_BY_MODULEin the admin app (src/deeplink/schemas.js), and markmobile: trueif the mobile app has the screen.
Each module has its own routes, so the admin app's feature list depends on the module chosen: AquaGen, AquaRecycle, LakePulse and Rain Water Intelligence each list only their own screens.
Analytics
| Event | From | Notes |
|---|---|---|
deep_link_click | Link handler | Properties id, utm, dynamicParams. Mixpanel user is always deeplink_handler |
deep_link_handled | This app | id, title, utm, dynamicParams (allowed names only) |
deep_link_open | aquagenapi pixel | id, title, utm, allowed params. User is the email param, else email_pixel |
Test links send none of them. See Analytics Events for the full event list.
Where things are configured
| What | Where |
|---|---|
| Web origins, store links, Mixpanel token, cookie domain | deeplink_handler_web/.env.production (VITE_WEB_ORIGIN, VITE_AR_ORIGIN, VITE_LP_ORIGIN, VITE_RWI_ORIGIN, VITE_PLAY_STORE_URL, VITE_APP_STORE_URL, VITE_MIXPANEL_TOKEN, VITE_COOKIE_DOMAIN) |
| Link domain and pixel address used in the admin | admin-app-backend/app/services/deep_link.py (LINK_ORIGIN, PIXEL_ORIGIN) |
Pixel limits, TEST_ prefix, Mixpanel URL | aquagenapi/app/constants/constants.py; token in Config.MIXPANEL_TOKEN (env MIXPANEL_TOKEN) |
| Resolve API address in this app | libs/shared/src/services/api/urls.js |
Deploying a change
| Changed | Redeploy |
|---|---|
Public API (resolve, pixel) | aquagenapi |
| Link fields, list, delete | admin-app-backend, then the admin app |
| Handler (prefixes, cookie, store links, click event) | deeplink_handler_web (npm run deploy, hosting only) |
libs/shared deep-link code or MainLayout | All four web apps (production, lakepulse, rwi, uwms) and the mobile app (a new build and store release) |
Deploy the APIs first, then the handler, then the apps, then the admin app.