Skip to main content

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.

One link, both bots

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 /start payload 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_GATEA Telegram page to an unproven chat
warn — defaultSent anyway (non-regressing), counted in proxima_telegram_unverified_page_total, logged as a WARN.
enforceSkipped; 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.

Hidden when no bot is configured

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-SHA256 over 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 captured initData to 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:

RouteMethodAuthNotes
/api/v1/auth/telegramPOSTPublicRate-limited. Validates initData, issues JWT.
/api/v1/auth/telegram/linkPOSTService accountRate-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-codePOSTJWTGenerates an 8-char link code.
/api/v1/auth/telegram/linkDELETEJWTUnlinks 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.

  • 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.
Disabled when no bot token is set

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.