I use a Telegram bot to send myself notifications from scripts, cron jobs, and deploy pipelines. It’s free, takes five minutes to set up, and the messages show up instantly on every device.

I also use it for things like error alerts and new user sign-up notifications. Eventually these will all live in proper logging systems, but especially at an early stage, getting a ping when someone signs up makes me happy.

Create the Bot via BotFather

Open Telegram and search for @BotFather. Start a conversation and send:

/newbot

BotFather asks you for a display name and a username (must end in bot). Once done, you get a token like:

7123456789:AAH1234abcd5678efgh9012ijkl3456mnop

That’s the only credential you need.

Get Your Chat ID

The bot needs to know where to send messages. That’s your chat ID. The easiest way to get it:

  1. Open a conversation with your new bot in Telegram and send it any message (e.g. “hello”).
  2. Then hit the Telegram API:
curl -s "https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates" | jq '.result[-1].message.chat.id'

That returns your numeric chat ID. If you get an empty result or null, make sure you’ve actually sent a message to the bot first—it won’t see anything until you do. The -1 grabs the most recent update, which is usually the one you want.

Tip: If you want to send to a group instead, add the bot to the group, send a message in the group mentioning the bot (or make it a group admin), then run the same getUpdates call. The chat ID for groups is negative. Note that getUpdates won’t work if you have a webhook set — remove it first with deleteWebhook.

Sending Messages via curl

Once you have the token and chat ID, sending a message is one curl call:

curl -s -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/sendMessage" \
  -d chat_id="<YOUR_CHAT_ID>" \
  -d text="Deploy finished ✅"

I wrap this in a shell function so I can call it from anywhere:

notify_telegram() {
  curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
    -d chat_id="${TELEGRAM_CHAT_ID}" \
    --data-urlencode text="$1" > /dev/null
}

Then in scripts:

notify_telegram "Backup completed: $(du -sh /backups/latest | cut -f1)"

For Markdown formatting in messages, add -d parse_mode="MarkdownV2". MarkdownV2 requires escaping a bunch of special characters (., -, (, ), !, etc). Fine for bold and italic, but for anything more complex, use HTML parse mode instead—less painful as long as you escape <, >, and & in your text:

curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
  -d chat_id="${TELEGRAM_CHAT_ID}" \
  -d parse_mode="HTML" \
  -d text="<b>Deploy</b> finished for <code>main</code> branch"

Sending Messages via TypeScript

For anything beyond simple scripts, I use TypeScript with bun:

const TELEGRAM_BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN!;
const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID!;

async function sendTelegramMessage(text: string, parseMode?: "HTML" | "MarkdownV2") {
  const body: Record<string, string> = {
    chat_id: TELEGRAM_CHAT_ID,
    text,
  };
  if (parseMode) body.parse_mode = parseMode;

  const res = await fetch(
    `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(body),
    }
  );

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`Telegram API error: ${res.status} ${err}`);
  }
}

await sendTelegramMessage("Build <b>passed</b> ✅", "HTML");

Run it with:

TELEGRAM_BOT_TOKEN=xxx TELEGRAM_CHAT_ID=yyy bun run notify.ts

Sending Images (and the Gotchas)

I ran into this while building MyOG.social. The Telegram bot API has a sendPhoto endpoint, but there are a couple of gotchas.

The URL preview trap: If you send a URL via sendMessage, Telegram sometimes generates a link preview with an image. That’s not the same as sending a photo. The preview is low-res and unreliable.

Use sendPhoto explicitly:

curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendPhoto" \
  -d chat_id="${TELEGRAM_CHAT_ID}" \
  -d photo="https://example.com/image.png" \
  -d caption="OG image for latest post"

Caching: In my experience, Telegram caches images aggressively by URL. If you update an image at the same URL (which is exactly what happens with dynamically generated OG images), Telegram keeps serving the old cached version.

The workaround is to append a query parameter to bust the cache:

curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendPhoto" \
  -d chat_id="${TELEGRAM_CHAT_ID}" \
  -d photo="https://example.com/image.png?v=$(date +%s)"

Or upload the file directly instead of passing a URL—that bypasses caching entirely:

curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendPhoto" \
  -F chat_id="${TELEGRAM_CHAT_ID}" \
  -F photo=@/path/to/image.png \
  -F caption="Fresh image, no cache"

Note the switch from -d to -F—you need multipart form data for file uploads.

In TypeScript:

async function sendTelegramPhoto(imagePath: string, caption?: string) {
  const form = new FormData();
  form.append("chat_id", TELEGRAM_CHAT_ID);
  form.append("photo", Bun.file(imagePath));
  if (caption) form.append("caption", caption);

  const res = await fetch(
    `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendPhoto`,
    { method: "POST", body: form }
  );

  if (!res.ok) {
    const err = await res.text();
    throw new Error(`Telegram API error: ${res.status} ${err}`);
  }
}

The file upload approach is what I settled on for MyOG—download the generated image first, then upload it to Telegram. One extra step, but you always get the latest version.