In this article, we’re going to walk through why we replaced our Slack notification pipeline with the Telegram Bot API for real-time business alerts in our startup, how we built it using EventBridge, SQS, Lambda, and CDK, and why Telegram might be the better choice for your serverless notification needs based on cost and integration ease.
This is relevant for any small team running a serverless platform that needs instant visibility into business (domain) events: signups, purchases, support tickets, and pipeline deployments, all without the overhead and cost of Slack’s email-to-channel pipeline or Webhooks, or these messages getting mixed up with team channels and chat messaging.
“The goal is simple: the moment something important happens on your platform that you want to know about in realtime, you get a formatted, actionable notification in your pocket; without paying for Slack, without SES email routing, and without the latency of email-to-channel delivery.”
Firstly, what is Telegram?
"Telegram is a messaging app with a focus on speed and security, it’s super-fast, simple and free. You can use Telegram on all your devices at the same time — your messages sync seamlessly across any number of your phones, tablets or computers. Telegram is one of the top 5 most downloaded apps in the world with over 1 billion active users.
With Telegram, you can send messages, photos, videos and files of any type (doc, zip, mp3, etc), as well as create groups for up to 200,000 people or channels for broadcasting to unlimited audiences. You can write to your phone contacts and find people by their usernames. As a result, Telegram is like SMS and email combined — and can take care of all your personal or business messaging needs. We also support end-to-end encrypted voice and video calls, group calls for up to 200 participants, and voice chats in groups that members can join whenever they want."
Why not just use Webhooks on Slack?
We found that there were some limitations with this integration on Slack when in the free tier, namely 1 webhook per second max, only 90 days of message history stored (Telegram is unlimited), and only ten integrations at most on Slack (we have distinct channels per notification type - and it is unlimited with Telegram).
The Problem We’re Solving 🎯
When you’re running a SaaS platform, and especially when you are an early startup, you need real-time visibility into what’s happening. Not CloudWatch dashboards you check once a day, but instant push notifications that hit your phone or tablet the moment a customer signs up, makes a purchase, or submits a support ticket.
Most teams reach for Slack automatically. It’s the default, I get it! But here’s what we discovered after running a Slack notification pipeline for 4 months:
- Cost: Slack’s free tier limits message history and only allows email-to-channel message delivery or webhooks for the first few months for free. Pro plans start at about $8.75/user/month (which soon racks up for a small startup).
- Latency: Email-to-Slack delivery adds 5-30 seconds of latency on top of your pipeline (even though it's the simplest integration path).
- Mobile experience: the formatting of the email to channel message is poor (really, it looks like an ill-formatted email!). Webhooks are possible, too, of course, with extra configuration.
We wanted something simpler, faster, and ultimately, free.
Why Telegram? 📱
Telegram’s Bot API is purpose-built for exactly this use case. Here’s why it won out for us personally:
Aspect | Slack (via SES) | Telegram Bot API |
|---|---|---|
Cost | SES charges + Slack Pro license | Completely free |
Latency | 5-30s (email routing) or < 1s (direct HTTPS webhook) | < 1s (direct HTTPS POST) |
Setup | SES domain verification, Slack channel email config, etc | Create bot, get token, done |
Rate limit | None | 30 messages/sec per bot |
Mobile | Heavy Slack app | Lightweight Telegram app, and separate to team chats |
Formatting | Plain text email body | Markdown with emoji, bold, monospace |
Dependencies | AWS SES SDK, email templates | Single |
Infrastructure | SES permissions, domain DNS, bounce handling, etc | One SSM parameter (bot token) |
Security | Excellent end-to-end encryption | Excellent end-to-end encryption |
The killer feature? A single HTTPS POST with fetch - and it is free. To get the same features with Slack using Webhooks was going to cost us around $20 a month.
Our Example
We’re building this for an online learning platform where we need notifications for the following (which we can turn on and off per domain event):
- New user signups — know when someone joins.
- Course purchases — celebrate revenue in real-time.
- Support tickets — respond quickly to customer issues.
- Video uploads — track content creator activity.
- CloudWatch alarms — infrastructure alerts in the same place.
- Deployment status — CI/CD pipeline results from our GitHub Actions pipeline.
💡 Note: All code examples are for discussion only and can be further productionised.
Architecture Overview 🏗️
The notification system has three paths, each optimised for its source:
┌──────────────────────────────────────────────────────────────────────────┐
│ Domain Event Path │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ EventBridge │────▶│ SQS Queue │────▶│ Notification Processor │ │
│ │ (domain │ │ (buffering, │ │ Lambda │ │
│ │ events) │ │ DLQ, retry)│ │ - Validates (Zod) │ │
│ └─────────────┘ └──────────────┘ │ - Formats (MarkdownV2) │ │
│ │ - Sends (Bot API) │ │
│ └──────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Alarm Path │
│ │
│ ┌─────────────┐ ┌───────────────────────────┐ │
│ │ SNS Topic │────▶│ Alarm Forwarder Lambda │ │
│ │ (CloudWatch │ │ - Parses alarm structure │ │
│ │ alarms) │ │ - Formats (MarkdownV2) │ │
│ └─────────────┘ │ - Sends (Bot API) │ │
│ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ Pipeline Path │
│ │
│ ┌─────────────┐ ┌───────────────────────────┐ │
│ │ GitHub │────▶│ curl --data-urlencode │ │
│ │ Actions │ │ (direct Bot API call) │ │
│ └─────────────┘ └───────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────┐
│ Telegram Bot API │
│ api.telegram.org │
│ │
│ ┌────────────────┐ │
│ │ 📱 Signups │ │
│ │ 💰 Purchases │ │
│ │ 🎫 Support │ │
│ │ 🎬 Uploads │ │
│ │ 🚨 Alarms │ │
│ │ 🚀 Deploys │ │
│ └────────────────┘ │
└──────────────────────┘
Three paths, one destination. Each is optimised for its trigger source, all converging on the same Telegram Bot API through a secondary adapter.
The Telegram Adapter: Replacing an Entire SES Pipeline with 30 Lines
Here’s the core of the solution; a secondary adapter that replaces the entire SES email sending pipeline:
export interface SendTelegramMessageParams {
botToken: string;
chatId: string;
text: string;
parseMode: 'HTML' | 'Markdown' | 'MarkdownV2';
}
export interface SendTelegramMessageResult {
success: boolean;
messageId?: number;
errorMessage?: string;
}
export async function sendTelegramMessage(
params: SendTelegramMessageParams,
): Promise<SendTelegramMessageResult> {
const { botToken, chatId, text, parseMode } = params;
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const body = new URLSearchParams({
chat_id: chatId,
text,
parse_mode: parseMode,
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await response.json();
if (!response.ok) {
return {
success: false,
errorMessage: data?.description ?? `HTTP${response.status} error`,
};
}
return { success: true, messageId: data?.result?.message_id };
} catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') {
return { success: false, errorMessage: 'Request timed out after 5 seconds' };
}
return {
success: false,
errorMessage: error instanceof Error ? error.message : 'Unknown network error',
};
}
}
Compare this to what we had before: an SES adapter that needed AWS SDK imports, IAM permissions for ses:SendEmail, domain verification, bounce handling configuration, and email template formatting. The Telegram adapter is a single fetch POST call with a 5-second timeout.
Key design decisions:
- Native
fetch— no SDK dependencies, available in Node.js 18+ Lambda runtimes. - 5-second timeout — prevents Lambda from hanging if Telegram is slow.
- Structured result — consistent interface for success/failure handling.
- Bot token never exposed — sanitised from all error messages and logs.
- Plug and play - we can use this stack for any Telegram notifications (pipeline, domain events, other...).
The Notification Processor Lambda: SQS Batch Processing
The main Lambda processes domain events from SQS with partial batch failure reporting; if one message in a batch of 10 fails, only that message is retried:
import { BatchProcessor, EventType, processPartialResponse } from '@aws-lambda-powertools/batch';
import { getParameter } from '@aws-lambda-powertools/parameters/ssm';
import { config } from '@config';
const processor = new BatchProcessor(EventType.SQS);
const botTokenSsmPath = config.get('telegramBotTokenSsmPath') as string;
const chatIdSignups = config.get('telegramChatIdSignups') as string;
const chatIdPurchases = config.get('telegramChatIdPurchases') as string;
// more where needed...
const eventTypeToChatId: Record<string, string | undefined> = {
UserSignedUp: chatIdSignups,
CoursePaymentMade: chatIdPurchases,
SupportTicketCreated: chatIdSupportTickets,
VideoUploadCompleted: chatIdFileUploads,
};
async function recordHandler(record: SQSRecord): Promise<void> {
const event = parseEventBridgeEvent(record);
if (!event) {
metrics.addMetric('InvalidMessageFormat', MetricUnit.Count, 1);
return; // Skip — don't retry invalid messages
}
const { detailType, detail } = event;
const chatId = eventTypeToChatId[detailType];
if (!chatId) {
throw new Error(`Missing chat ID mapping for:${detailType}`);
}
const message = formatNotification(detailType, detail);
const botToken = await getParameter(botTokenSsmPath, {
decrypt: true,
maxAge: 300, // 5-minute cache
});
const result = await sendTelegramMessage({
botToken, chatId, text: message, parseMode: 'MarkdownV2',
});
if (!result.success) {
metrics.addMetric('TelegramNotificationFailed', MetricUnit.Count, 1);
throw new Error(result.errorMessage); // Triggers SQS retry
}
metrics.addMetric('TelegramNotificationSent', MetricUnit.Count, 1);
}
The error handling strategy is deliberate:
- Invalid JSON / failed Zod validation / unknown event type - skip (don’t retry, it’ll never work)
- Missing chat ID / SSM failure / Telegram API error - throw (retry via SQS, might be transient)
- After 3 retries - message moves to DLQ for investigation (we can replay later)
Bot Token Security: SSM Parameter Store
The bot token is the only secret in the entire system. We store it in SSM Parameter Store as a SecureString and retrieve it with Lambda Powertools’ built-in caching:
import { getParameter } from '@aws-lambda-powertools/parameters/ssm';
const botToken = await getParameter('/sfe/develop/telegram/bot-token', {
decrypt: true,
maxAge: 300, // Cache for 5 minutes
});
This gives us:
- Encryption at rest via KMS
- IAM-scoped access — Lambda can only read its specific parameter
- Built-in caching — no custom cache logic, no cold-start penalty after first call
- No environment variable exposure — token never appears in CloudFormation, logs, or error messages
CDK Infrastructure: A Clean Nested Stack
The entire Telegram notification infrastructure lives in a single CDK nested stack, conditionally deployed when enabled:
// In the stateless stack
if (props.shared.telegramIntegration.enabled) {
new TelegramNotificationServiceNestedStack(
this, 'TelegramNotificationServiceStack', {
shared: { ...props.shared },
env, stateless: props.stateless,
codeDeployApplication: serviceLambdaApplication,
snsTopic, eventBus,
monitoringSnsTopic: props.monitoringSnsTopic,
monitoring: props.monitoring,
},
);
}
The nested stack creates:
- SQS Queue with DLQ (3 retries, 14-day retention in prod).
- EventBridge Rule routing domain events to the queue.
- Notification Processor Lambda (SQS event source, batch 10, 5s window).
- Alarm Forwarder Lambda (SNS subscription).
- IAM permissions scoped to the exact SSM parameter ARN.
- CloudWatch alarms on queue age and DLQ depth.
// IAM Least-privilege SSM access
const ssmParameterArn = `arn:aws:ssm:${region}:${account}:parameter${telegramIntegration.botTokenSsmPath}`;
const ssmPolicy = new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: ['ssm:GetParameter'],
resources: [ssmParameterArn],
});
telegramNotificationProcessor.lambda.addToRolePolicy(ssmPolicy);
telegramAlarmForwarder.lambda.addToRolePolicy(ssmPolicy);
Each Lambda can only read the one parameter it needs.
GitHub Actions: Pipeline Notifications Without AWS
For deployment notifications (both success and failure), we don’t even need Lambda. A single curl command in the workflow replaces the entire SES email step:
-name: Send Telegram notification
if: always()
continue-on-error:true
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
run:|
COMMIT_MSG=$(echo "${{ github.event.head_commit.message }}" | head -1 | cut -c1-72)
STATUS="${{ steps.status.outputs.result }}"
EMOJI="${{ steps.status.outputs.emoji }}"
curl --max-time 10 -s -X POST \\
"<https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage>" \\
--data-urlencode "chat_id=${TELEGRAM_CHAT_ID}" \\
--data-urlencode "parse_mode=Markdown" \\
--data-urlencode "text=${EMOJI} *${STATUS}* — \\`develop\\`
*Workflow:* ${{ github.workflow }}
*Commit:* \\`$(echo ${{ github.sha }} | cut -c1-7)\\` ${COMMIT_MSG}
*Actor:* ${{ github.actor }}
[View Run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
What we added:
- ✅ One
curlcommand - ✅ Two GitHub secrets
- ✅
continue-on-error: true(notification failure never blocks deployment)
The Alarm Forwarder: CloudWatch to Telegram
CloudWatch alarms arrive via SNS. The Alarm Forwarder Lambda parses the alarm structure and forwards it to a dedicated Telegram group:
const forwardTelegramAlarmsAdapter: SNSHandler = async (event: SNSEvent) => {
for (const record of event.Records) {
const alarmMessage = JSON.parse(record.Sns.Message);
// Graceful handling — malformed messages don't throw
if (!alarmMessage.AlarmName || !alarmMessage.NewStateValue) {
logger.warn('Malformed alarm notification, skipping');
continue;
}
const formattedMessage = formatAlarmNotification(alarmMessage);
const botToken = await getParameter(botTokenSsmPath, {
decrypt: true, maxAge: 300,
});
const result = await sendTelegramMessage({
botToken,
chatId: alarmsChatId,
text: formattedMessage,
parseMode: 'MarkdownV2',
});
if (!result.success) {
throw new Error(`Telegram API error:${result.errorMessage}`);
}
metrics.addMetric('TelegramAlarmForwarded', MetricUnit.Count, 1);
}
};
The alarm formatter produces messages like this below, which format perfectly when displayed in the Telegram app:
🚨 *CloudWatch Alarm*
*Alarm:* develop\\-telegram\\-notification\\-queue\\-age
*State:* ALARM
*Reason:* Threshold crossed: 1 out of 1 datapoints \\[350\\.0\\] was \\>\\= threshold \\(300\\.0\\)
*Time:* 2026\\-05\\-17T10:30:00\\.000Z
Cost Comparison 💰
Let’s compare the actual costs:
Component | Slack Pipeline (per 1,000 notifications) | Telegram Pipeline (per 1,000 notifications) |
|---|---|---|
Lambda execution | ~$0.001 | ~$0.001 |
SQS messages | ~$0.0004 | ~$0.0004 |
EventBridge events | ~$0.001 | ~$0.001 |
SES email sending | ~$0.10 | $0 |
Slack Pro (per user/month) | $8.75+ x 3 People | $0 |
Telegram Bot API | N/A | $0 |
Total (infra only) | ~$0.11 | ~$0.0024 |
Total (with Slack license, 3 users) | ~$26.35/month | ~$0.0024 |
The infrastructure cost difference is modest, but when you factor in Slack licensing for the team members who need notification access, Telegram saves significantly. That is only 3 people, but with a small startup of say 10-15 people, the costs are approximately $87.50-$131.25.
What We Gained by Switching
After running both systems in parallel for a week before cutting over:
- Latency dropped from ~15s to < 1s — no email routing delay.
- Better mobile experience — Telegram notifications are instant, lightweight, and don’t mix with work chat.
- Richer formatting — MarkdownV2 with emoji, bold, monospace, and links.
- Zero cost — Telegram Bot API is free with generous rate limits (30 msg/sec).
- Simpler GitHub Actions — one
curlcommand vs AWS credential setup + SES call.
When Slack Still Makes Sense
To be fair, Telegram isn’t always the right choice:
- Team collaboration — if your team already lives in Slack and needs threaded discussions on alerts, keep Slack.
- Enterprise compliance — some organisations mandate Slack for audit trails, or you may already be paying for a teams license.
- Rich integrations — Slack’s app ecosystem (PagerDuty, Jira, etc.) is unmatched.
- Interactive workflows — Slack’s Block Kit enables buttons, modals, and approval flows.
But for one-way business notifications for a startup— the “something happened, here’s what” pattern, Telegram is simpler, faster, and free.
Wrapping Up 📝
We’ve covered a lot of ground in this article:
The key takeaways:
- Telegram Bot API is free and fast — a single HTTPS POST replaces an entire SES email pipeline with sub-second delivery.
- Hexagonal architecture keeps it clean — the Telegram adapter is a secondary adapter, formatters are use cases, and Lambda handlers are primary adapters.
- SQS provides the safety net — partial batch failures, DLQ, and retry semantics mean notifications are eventually delivered.
- SSM Parameter Store for secrets — encrypted at rest, IAM-scoped, cached in memory, never logged.
- CDK nested stacks for isolation — conditionally deployed, independently testable, zero impact on existing infrastructure.
- GitHub Actions notifications are trivial — one
curlcommand, two secrets, no AWS credentials needed. - Event-driven architecture enables this — the notification system subscribes to domain events without coupling to the business logic that produces them.
The existing Slack infrastructure stays in place for future use (hopefully, we will grow the team in time, and Slack may make sense again). The Telegram integration is purely additive, i.e., a new notification channel that’s faster, cheaper, and simpler to operate.
I hope you found this article useful. If you have any questions or feedback, feel free to reach out!
Ready to level up your AWS skills?
Visit sign-up today and join a community of builders and architects dedicated to mastering the cloud.
.png)