Hogsend is brand new.Try it
Hogsend
Recipes

Link a Telegram account to an email

How the /link email-confirm flow attaches an email to a Telegram account — the user sends /link you@example.com, Hogsend mails a single-use confirmation link, clicking it binds telegram:<id> to the email on one contact and identifies the PostHog person client-side. Plus the /start one-tap deep link for users you already know.

A Telegram user and an email subscriber are the same person on two surfaces, but nothing ties them together until they link. The @hogsend/plugin-telegram connector does this two ways. For a cold connect started inside Telegram, /link you@example.com mails a single-use confirmation link; clicking it proves inbox ownership and folds the email onto the telegram:<id> contact. For a user you already know (a logged-in dashboard), a one-tap t.me/<bot>?start=<token> deep link binds immediately. After either, the contact has both a telegram:<id> external key and an email, and the member is both emailable and credited for Telegram activity.

This is consumer wiring — a journey on telegram.link_requested and a small connect-page route set — plus how a journey reads the result.

The bind is gated on a real email click, never on the typed address alone:

StepWhat happens
/link you@example.comThe connector emits telegram.link_requested carrying the lowercased email
telegram-link-request journeyValidates the address shape, calls telegramColdConnect.mintConfirm (which runs the cold-connect throttle — per-user + per-email Redis-INCR windows, fail-closed — then seals a single-use token), emails the confirm link, replies in Telegram
Click the emailed linkOpens GET /connect/telegram?tok=… — the engine-served connect page, styled in the Hogsend Studio "Crimzon" language (ink surface, hairline card, the real Telegram paper-plane logo in an accent-tinted chip) with the "didn't start this in Telegram? you can safely close this tab" reassurance footnote; the token is read client-side, never reflected into markup
Confirm connection buttonPOST /connect/telegram/exchange peeks the sealed token, ingests telegram.linked to fold telegram:<id> + email onto one contact, then consumes the token; the page calls posthog.identify client-side with { telegram_id }

The bind happens on a human button click (POST), never on GET — so an email or Telegram link-preview prefetch can't complete it.

The wiring

Two pieces make /link work: a journey on telegram.link_requested that mints and mails the confirmation, and mounting the cold-connect routes that serve the connect page and the exchange. Both lean on the engine createColdConnect() primitive — telegramColdConnect is the prebuilt flow (connectorId: "telegram", platformKey: telegram:<id>, linkedEvent: "telegram.linked", identifyPropKey: "telegram_id"), so the journey only mints and mails, and the routes mount in one line.

src/journeys/telegram-link-request.ts
import { hours } from "@hogsend/core";
import {
  defineJourney,
  getEmailService,
  sendConnectorAction,
} from "@hogsend/engine";
import { TelegramEvents, telegramColdConnect } from "@hogsend/plugin-telegram";

// Loose shape check only — the binding is PROVEN by the email being delivered.
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;

export const telegramLinkRequest = defineJourney({
  meta: {
    id: "telegram-link-request",
    name: "Telegram — Link Request (/link)",
    enabled: true,
    trigger: { event: TelegramEvents.LINK_REQUESTED }, // "telegram.link_requested"
    entryLimit: "unlimited",
    suppress: hours(0),
  },

  run: async (user, _ctx) => {
    const chatId = user.properties.chatId ? String(user.properties.chatId) : null;
    const fromId = user.properties.fromId ? String(user.properties.fromId) : null;
    if (!chatId || !fromId) return;

    const reply = (text: string) =>
      sendConnectorAction({
        connectorId: "telegram",
        action: "sendMessage",
        args: { chatId, text },
      });

    const email = user.properties.email ? String(user.properties.email) : "";
    if (!email || !EMAIL_RE.test(email)) {
      await reply("To connect your email, send:\n\n/link you@example.com");
      return;
    }

    // Mint a server-sealed confirm token. The cold-connect primitive owns the
    // anti email-bomb throttle now (per-user + per-email Redis-INCR windows,
    // fail-closed), so a forged/replayed /link can't spray a victim's inbox, and
    // a Redis fault returns { ok:false } so we never send a link we can't honor.
    const minted = await telegramColdConnect.mintConfirm({
      platformUserId: fromId,
      email,
    });
    if (!minted.ok) {
      if (minted.reason === "rate_limited") {
        await reply(
          "You've requested a few link emails recently — check your inbox, " +
            "or try again in a little while.",
        );
        return;
      }
      await reply("Linking is briefly unavailable — please try again shortly.");
      return;
    }

    const apiPublicUrl = process.env.API_PUBLIC_URL ?? "http://localhost:3002";
    const url = telegramColdConnect.confirmUrl({
      apiPublicUrl,
      token: minted.token,
    });

    // TRANSACTIONAL send — skipPreferenceCheck so the confirm link is NEVER
    // dropped by unsubscribe/frequency suppression.
    await getEmailService().send({
      template: "transactional/magic-link",
      props: { magicLinkUrl: url, expiresIn: "15 minutes" },
      to: email,
      userId: email,
      userEmail: email,
      subject: "Confirm your Telegram connection",
      category: "transactional",
      skipPreferenceCheck: true,
    });

    await reply(
      `📧 I've emailed a confirmation link to ${email}.\n\n` +
        "Open it to finish connecting — it expires in 15 minutes.",
    );
  },
});

The connect page and the /exchange bind are both provided by telegramColdConnect.routes — there's no hand-rolled page or peek/consume/ingestEvent block to write any more. Mount it on the app:

src/index.ts
import { createApp, createHogsendClient } from "@hogsend/engine";
import { telegramColdConnect } from "@hogsend/plugin-telegram";

const client = createHogsendClient({ journeys, email: { templates } });

const app = createApp(client, {
  webhookSources,
  // GET /connect/telegram (the Studio-styled page) + POST .../exchange (the
  // sealed-token bind). The basePath is derived from connectorId, so the
  // confirm links already in flight keep resolving.
  routes: [telegramColdConnect.routes],
});

When you already know the contact's email — a logged-in dashboard — skip the email round trip. Mint a personalized deep link and the user binds in one tap:

import { mintTelegramStartLink } from "@hogsend/plugin-telegram";

const minted = await mintTelegramStartLink({
  botUsername: process.env.TELEGRAM_BOT_USERNAME ?? "",
  email: contact.email,
});
// minted.url === "https://t.me/<bot>?start=<token>" — render it as a button.

mintTelegramStartLink stores token → email in Redis under a short opaque token (Telegram caps the start param at 64 chars and forbids the signed-state form, so the binding lives server-side, never in the link) with a 900-second TTL. When the user taps it, /start <token> resolves the bound email and the connector emits telegram.linked carrying both userId and userEmail — the engine folds the Telegram identity onto the email contact with no journey of your own required.

Reading the linked identity in a journey

JourneyUser carries id, email, and properties, but not the nested Telegram metadata. The telegram.linked event sets user.email once the bind commits, so a journey on telegram.linked can branch on it directly:

// src/journeys/telegram-linked.ts
import { hours } from "@hogsend/core";
import { defineJourney, sendConnectorAction } from "@hogsend/engine";
import { TelegramEvents } from "@hogsend/plugin-telegram";

export const telegramLinked = defineJourney({
  meta: {
    id: "telegram-linked",
    name: "Telegram — Account Linked",
    enabled: true,
    trigger: { event: TelegramEvents.LINKED }, // "telegram.linked"
    entryLimit: "unlimited",
    suppress: hours(0),
  },

  run: async (user, _ctx) => {
    const chatId = user.properties.chatId ? String(user.properties.chatId) : null;
    if (!chatId) return;
    const email = user.email ?? "your email";

    // Confirm the cross-channel link back in Telegram.
    await sendConnectorAction({
      connectorId: "telegram",
      action: "sendMessage",
      args: {
        chatId,
        text:
          `✅ Linked your Telegram to ${email}.\n\n` +
          "Your community activity and email lifecycle are now one contact.",
      },
    });
  },
});

The richer contacts.properties.telegram metadata (username, first_name, last_name, language, and the derived last_seen) is on the contact row, deep-merged and non-clobbering — read the authoritative contacts row if a journey needs it. It is never a resolution key; telegram:<id> (the externalId) is the merge key.

  • The linked email is proven by the click, not the typed address. mintConfirm seals { platformUserId, email } server-side under a single-use token and only binds when the emailed link is clicked — so a forged /link can't attach an address the sender does not control.
  • The bind is on POST, never GET. A link-preview prefetch fetches the page (GET) but cannot complete the bind, which requires the explicit Confirm connection button (POST).
  • /start peeks, never consumes. The deep-link redeem reads the Redis binding without deleting it, so a webhook auto-retry can't burn a user's one-tap link — single use is bounded by the 900s TTL instead.

Related: Welcome new Telegram members replies on the first message and routes onboarding, and the Telegram integration documents both link paths and the identity model.