deep links

use telegram start payloads for referrals, onboarding, invite attribution, and group setup.

private chat links

link.ts
const payload = "ref_67";
const url = "https://t.me/my_bot?start=" + encodeURIComponent(payload);

read the payload

telegram delivers the payload as arguments to /start. yaebal's command matching (in Composer.command(), @yaebal/commands, and the @yaebal/filters command filter) stages three fields on ctx:

  • ctx.command — the matched name, as typed.
  • ctx.payload — the raw, trimmed remainder after the command. this is the field that maps 1:1 to a deep-link start parameter.
  • ctx.args — the same remainder split on whitespace, as a string[] (empty if there's nothing after the command). a deep-link payload never contains whitespace, so ctx.payload and ctx.args[0] agree for deep links — reach for ctx.args when a command takes several space-separated tokens instead.
start.ts
import { createBot } from "yaebal";

const saveReferral = async (userId: number, code: string) => {};
const bot = createBot(process.env.BOT_TOKEN!);

bot.command("start", async (ctx) => {
  // ctx.payload is the raw trimmed remainder after "/start " — exactly what
  // telegram passed as the start parameter. ctx.args is the same thing split
  // on whitespace, for commands that take multiple tokens.
  const payload = ctx.payload;

  if (payload.startsWith("ref_")) {
    await saveReferral(ctx.from!.id, payload.slice(4));
  }

  await ctx.reply("welcome");
});

filter deep links

@yaebal/filters ships dedicated filters for the deep-link shapes telegram supports, so you don't have to parse ctx.payload by hand in every handler:

  • start/start restricted to private chats.
  • startGroup/start fired in a group or supergroup, i.e. a ?startgroup= link landed.
  • deeplink(param)/start whose payload equals param (string) or matches param (regexp, staging ctx.match).

register the specific filter before a catch-all .command("start") — filters run in registration order, and a broad handler earlier in the chain would otherwise swallow every referral before the narrower filter gets a chance:

referral.ts
import { createBot } from "yaebal";
import { deeplink } from "@yaebal/filters";

const bot = createBot(process.env.BOT_TOKEN!)
  // register before the plain /start handler — filters run in order, and
  // a catch-all /start below would otherwise swallow every referral too
  .filter(deeplink(/^ref_(\d+)$/), (ctx) =>
    ctx.reply(`welcome! you were referred by user ${ctx.match[1]}`),
  )
  .command("start", (ctx) => ctx.reply("welcome!"));

bot.start();

group and channel links

use startgroup when the link should add the bot to a group and carry setup context, or startchannel for channels. both still arrive as a plain /start <payload> on the wire — telegram only differentiates them at the link-building stage.

group-link.ts
const url = "https://t.me/my_bot?startgroup=" + encodeURIComponent("team_67");
const channelUrl = "https://t.me/my_bot?startchannel=" + encodeURIComponent("team_67");

distinguish where a /start landed with the chat-type filters from @yaebal/filters: isPrivate, isGroup, and isChannel narrow ctx.chat.type. startGroup is just and(isGroup, command("start")) — there's no equivalent startChannel export yet, so compose it the same way:

channel-start.ts
import { createBot } from "yaebal";
import { and, command, isChannel } from "@yaebal/filters";

const bot = createBot(process.env.BOT_TOKEN!);

// there's no ready-made "startchannel" export — compose one from the filters
// that already exist. a channel deep link arrives as a channel_post, not a
// private message, so command("start") still matches it.
bot.filter(and(isChannel, command("start")), (ctx) => {
  // ctx.chat.type is narrowed to "channel" here
  return ctx.reply("channel setup: " + ctx.payload);
});

safety rules

  • treat payloads as untrusted user input.
  • keep payloads short and url-safe.
  • store attribution server-side if the payload would expose sensitive data.
  • make referral writes idempotent; users can click the same link multiple times.
  • order matters: register deeplink() filters and other specific matchers before generic .command("start") handlers.
  • test the first-message path with @yaebal/test.