Account Linking & Security
Before a user can see any tickets in the Mini App, their Telegram account must be linked to a Console account. Linking is what lets the backend map an incoming Telegram identity to a real Console user — and therefore to that user's RBAC permissions and client access.
This is a single identity system shared by both Telegram bots. The same telegram_id mapping written here is what the Service Desk Bot uses to know which tickets are yours and the Ops Bot uses to attribute your Acknowledge/Resolve taps and scope what you can ask the AI Investigator. Link once (via /link on either bot, or the Console profile) and both work.
Why linking is required
The Mini App authenticates with a Telegram-issued initData payload, which only identifies the user's Telegram ID. The backend needs to know which Console user that Telegram ID belongs to. That mapping is stored on the Console user record (in the user's traits as telegram_id). Until it exists, the backend cannot issue a scoped JWT, and the Mini App shows a "not linked" screen.
Linking flow
Linking starts in the Console under Profile → Telegram, and uses a short-lived, single-use code so a user can prove they control both their Console account and their Telegram account. Tapping Link Telegram calls POST /api/v1/auth/telegram/generate-code (JWT-protected, so the request is already tied to the logged-in Console user). The backend returns an 8-character alphanumeric code stored in Valkey with a 5-minute TTL, keyed to that user, plus a one-tap deep-link URL.
There are two ways to redeem the code:
- One-tap (recommended) — tap Open Telegram. This opens the bot via the deep link
https://t.me/{botUsername}?start=<code>; the bot receives the code as the/startpayload and links the account automatically, then offers the Open Service Desk button. - Manual — copy the code and send
/link <code>to the bot yourself (useful if the deep link doesn't open).
Either way, the bot calls POST /api/v1/auth/telegram/link with the code and the user's Telegram ID. The backend looks up the code in Valkey, deletes it (so it can only be used once), and writes the Telegram ID onto the matching Console user's traits. The next time the user opens the Mini App, initData resolves to their Console account and a scoped JWT is issued.
Unlinking
A user can unlink from Profile → Telegram in the Console, which calls DELETE /api/v1/auth/telegram/link (JWT-protected) and removes the telegram_id from their traits. Sending /unlink to the bot replies with instructions pointing to this UI flow. Once unlinked, the Mini App returns to the "not linked" screen.
Linking is not paging permission — and not deliverability either
Linking writes traits.telegram_id. That is an identity fact: it says which Telegram account
belongs to which Console user, and both bots read it to scope what you can see and to attribute
your taps.
It says nothing about whether the paging bot can actually reach you — and note that the paging bot
is a different bot. Linking happens here, on the Service Desk bot; pages are DMed by the ops
bot (PROXIMA_OPS_TELEGRAM_BOT_TOKEN). A Telegram bot cannot open a conversation — it can only
DM someone who has already started a private chat with it — so a perfectly valid, freshly
linked telegram_id can belong to a person the ops bot has never been able to message. A page
addressed there is accepted by Telegram's API and arrives nowhere.
Proving the private chat is a separate step, taken with the ops bot: its own deep link
(/start vfy_…, opened on PROXIMA_OPS_TELEGRAM_BOT_USERNAME), its own column
(user_oncall_profile.telegram_verified_at) and its own gate on the paging resolver:
PROXIMA_TELEGRAM_VERIFIED_GATE | A Telegram page to an unproven chat |
|---|---|
warn — default | Sent anyway (non-regressing), counted in proxima_telegram_unverified_page_total, logged as a WARN. |
enforce | Skipped; escalation falls through to the chain's other channels. |
warn is the default and should stay the default until a verification drive is finished —
flipping to enforce early un-pages everyone who has not verified yet. Unlinking, or re-linking a
different Telegram account, resets the stamp, so a re-link is unverified again by design: the old
proof names the previous account.
The full flow, which bot does what, the checks the bot and the backend each make, and the rollout order live in On-Call → Personal Pages.
Inviting users to Telegram
New users don't have to be onboarded through the Console first. When you create a user (Admin → Users → Invite user), the dialog offers an Access choice that decides how the person is activated — and, when the Service Desk bot is configured, the invite flow can hand them the Telegram deep link automatically, with no separate admin step.
There are three invite outcomes:
- Console only — the standard set-password invite. The user receives an email with a link to set their password and sign in to the Console; nothing Telegram-specific is sent.
- Console + "Also send Telegram Service Desk link" — the same set-password invite, but the email also embeds the Telegram deep link (
t.me/<bot>?start=<code>) so the user can link Telegram in the same sitting. Opt-in via the checkbox on the Console path. - Telegram only — no password is set. The invite email contains just the
t.me/<bot>?start=<code>deep link, and the user activates their account by opening it and linking Telegram (exactly the redeem step described in Linking flow above). This is the fastest path for a client contact who will only ever use the Service Desk bot.
The deep link's <code> is the same short-lived, single-use link code covered above — it is minted and delivered by the backend invite flow itself (POST /api/v1/users with invite_channel / include_telegram_link), not something an admin has to generate and paste in by hand.
A Telegram-only user is marked with a telegram_only trait and shows a "Telegram only" badge on their user page. If that person later needs full Console access, an admin can upgrade them with "Invite to Console" (POST /api/v1/users/{id}/invite-console, gated on auth:users:write), which sends the standard set-password invite. Accepting that invite works whether the user is still in the invited state or has already become active by linking Telegram.
The Telegram invite options only appear when a Service Desk bot is configured (telegram_bot_enabled in /auth/config). On a deployment without a bot, the create-user dialog shows only the standard Console invite.
Security model
initData validation (HMAC-SHA256)
When the Mini App calls POST /api/v1/auth/telegram, it passes the raw Telegram initData. The backend validates it per the Telegram specification:
- It computes
HMAC-SHA256over the data-check string using a key derived from the bot token (HMAC("WebAppData", bot_token)), and compares it to the hash Telegram included. A mismatch means the payload was tampered with or not actually signed by Telegram → request rejected. - It checks
auth_date: the payload is rejected if it is more than 5 minutes old (or dated in the future). This bounds replay of a capturedinitDatato a 5-minute window.
Only after both checks pass does the backend look up the user by telegram_id and issue a JWT. If no linked user is found, the backend returns 403 telegram_not_linked and the Mini App shows the "not linked" screen.
JWT scoped to the linked user
The JWT the backend issues is the same kind of token a normal Console login produces — it carries the linked user's identity and therefore their full RBAC and client scoping. The Mini App cannot see, create, or modify anything the underlying Console user couldn't. There is no special "Telegram" permission tier; the integration simply rides on the linked user's existing authorization.
Rate limiting
The public Telegram auth routes are rate-limited per IP using the same auth rate limiter as other public auth endpoints:
| Route | Method | Auth | Notes |
|---|---|---|---|
/api/v1/auth/telegram | POST | Public | Rate-limited. Validates initData, issues JWT. |
/api/v1/auth/telegram/link | POST | Service account | Rate-limited. Links via code. Performed only by the Telegram bot, authenticated with its service-account API key and pinned to the specific PROXIMA_TELEGRAM_BOT_SA_ID — not merely "any service account", so no other SA can bind an attacker-chosen telegram_id with a leaked link code. Fails closed with 403 when that variable is unset. |
/api/v1/auth/telegram/generate-code | POST | JWT | Generates an 8-char link code. |
/api/v1/auth/telegram/link | DELETE | JWT | Unlinks the account. |
Only POST /api/v1/auth/telegram is public. The two state-changing operations that act on a specific
Console account — generating a code and unlinking — are JWT-protected, so they can only be
performed by the authenticated owner of that account; and the link call itself is restricted to the
bot's own service account, because it is the step that binds a proven Telegram identity to a Console
user.
Link-code hardening
- Single-use — the code is deleted from Valkey the moment it is consumed.
- Short TTL — 5 minutes, after which the code expires automatically.
- Large keyspace — 8 alphanumeric characters give a very large combination space, making guessing impractical within the TTL.
- Duplicate-link protection — if a Telegram ID is already linked to another Console user, the link request is refused, preventing one Telegram account from being attached to multiple accounts.
The Telegram auth routes are only registered when the backend has a bot token configured (PROXIMA_TELEGRAM_BOT_TOKEN). On a deployment without a bot token, none of these endpoints exist. See Deployment.