
How I Built a Self-Updating Portfolio with Telegram Webhooks
How I Built a Self-Updating Portfolio with Telegram Webhooks
Why I chose Telegram as my CMS — and how it reduced content update friction from 5 minutes to 10 seconds.
Published: March 16, 2026
Reading time: 8 minutes
Tags: typescript, next.js, telegram, webhooks, cms, automation
The Problem with Traditional Portfolios
My previous portfolio hadn't been updated in 6 months.
Not because I wasn't building things — I was shipping projects every week. But the friction of updating a portfolio was too high:
- Open laptop
- Navigate to project folder
- Edit markdown file
- Format screenshots
- Commit to Git
- Push to deploy
Total time: 5-10 minutes.
Doesn't sound like much. But when you're in flow state building something, stopping to document it feels like context-switching hell. So I didn't. And my portfolio gathered dust.
I needed a system where updating my portfolio was easier than not updating it.
Why Telegram?
I spend hours every day in Telegram anyway. It's my default communication tool for:
- Team coordination (CENTAUR)
- Project updates
- Quick notes to self
- File sharing
What if my portfolio was just another Telegram chat?
The vision: Send a message → instant publish. No laptop required. No Git commits. No deploy waits.
Telegram Bot API made this possible:
- Webhooks: Real-time push notifications when I send messages
- File uploads: Images, PDFs, videos — all supported
- Command parsing:
/addproject Title|Description→ structured data - Free tier: No rate limits for personal use
- Mobile-first: Works perfectly from my iPhone
Architecture Overview
The system has three layers:
1. Frontend (Next.js)
- Static site generation for fast load times
- Fetches projects/blog posts from backend API
- Hosted on Railway (auto-deploy from Git)
2. Backend (Express + TypeScript)
- REST API for CRUD operations (
/api/projects,/api/posts) - Telegram webhook handler (
/telegram/webhook) - PostgreSQL for persistence (Prisma ORM)
- Redis for session storage
3. Telegram Bot
- Listens for messages from my private chat
- Parses commands (
/addproject,/update,/publish) - Uploads images to Cloudinary
- Inserts structured data into database
graph LR
A[Telegram App] -->|Webhook| B[Express Backend]
B -->|Insert| C[(PostgreSQL)]
B -->|Upload| D[Cloudinary]
E[Next.js Frontend] -->|Fetch| B
B -->|Query| C
Implementation Deep Dive
Step 1: Setting Up the Telegram Bot
First, I created a bot via @BotFather:
/newbot
> dev-diary-bot
> devdiary_updates_bot
BotFather: "Done! Your token: 1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
Store the token in .env:
TELEGRAM_BOT_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
TELEGRAM_ADMIN_ID=14043201 # My Telegram user ID
Only I can send commands — unauthorized users get ignored.
Step 2: Webhook Handler
Express route that receives Telegram updates:
import { Router } from 'express';
import { Telegraf } from 'telegraf';
const bot = new Telegraf(process.env.TELEGRAM_BOT_TOKEN!);
const router = Router();
router.post('/telegram/webhook', async (req, res) => {
try {
await bot.handleUpdate(req.body);
res.status(200).json({ ok: true });
} catch (err) {
console.error('Webhook error:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Set webhook URL
bot.telegram.setWebhook(`https://patrick.technology/telegram/webhook`);
Key insight: Telegram sends a POST request to your webhook URL every time someone messages the bot. You parse the message and respond accordingly.
Step 3: Command Parsing
The bot listens for specific commands:
// Add new project
bot.command('addproject', async (ctx) => {
const userId = ctx.from.id;
if (userId !== parseInt(process.env.TELEGRAM_ADMIN_ID!)) {
return ctx.reply('Unauthorized');
}
const text = ctx.message.text.replace('/addproject ', '');
const [title, description, progress, imageUrl, githubLink, tags] = text.split('|');
const project = await prisma.project.create({
data: {
title: title.trim(),
description: description.trim(),
progress: parseInt(progress) || 0,
githubLink: githubLink?.trim(),
tags: tags ? tags.split(',').map(t => t.trim()) : [],
primaryImageUrl: imageUrl?.trim(),
},
});
ctx.reply(`✅ Project created: ${project.title}`);
});
// Update existing project
bot.command('update', async (ctx) => {
const text = ctx.message.text.replace('/update ', '');
const [projectId, field, value] = text.split('|');
await prisma.project.update({
where: { id: parseInt(projectId) },
data: { [field.trim()]: value.trim() },
});
ctx.reply(`✅ Updated project #${projectId}`);
});
// Publish blog post
bot.command('publish', async (ctx) => {
const text = ctx.message.text.replace('/publish ', '');
const [title, content, tags] = text.split('|');
const post = await prisma.blogPost.create({
data: {
title: title.trim(),
contentMarkdown: content.trim(),
tags: tags ? tags.split(',').map(t => t.trim()) : [],
slug: slugify(title),
},
});
ctx.reply(`✅ Published: ${post.title}\n${process.env.SITE_URL}/blog/${post.slug}`);
});
Step 4: Image Uploads
Telegram makes file uploads trivial. When I send a photo:
bot.on('photo', async (ctx) => {
const userId = ctx.from.id;
if (userId !== parseInt(process.env.TELEGRAM_ADMIN_ID!)) return;
// Get highest resolution image
const photo = ctx.message.photo[ctx.message.photo.length - 1];
const file = await ctx.telegram.getFile(photo.file_id);
const fileUrl = `https://api.telegram.org/file/bot${process.env.TELEGRAM_BOT_TOKEN}/${file.file_path}`;
// Upload to Cloudinary
const cloudinaryUrl = await uploadToCloudinary(fileUrl);
// Check caption for project reference
const caption = ctx.message.caption;
if (caption?.startsWith('ProjectName:')) {
const projectName = caption.replace('ProjectName:', '').trim();
const project = await prisma.project.findFirst({
where: { title: { contains: projectName } },
});
if (project) {
await prisma.project.update({
where: { id: project.id },
data: { primaryImageUrl: cloudinaryUrl },
});
ctx.reply(`✅ Image added to ${project.title}`);
}
} else {
ctx.reply(`📸 Uploaded: ${cloudinaryUrl}`);
}
});
async function uploadToCloudinary(url: string): Promise<string> {
const response = await cloudinary.uploader.upload(url, {
folder: 'dev-diary',
});
return response.secure_url;
}
Step 5: Idempotency (Prevent Duplicates)
Telegram can send duplicate webhooks during network issues. To prevent duplicate project entries:
// Add unique constraint on Telegram update_id
await prisma.telegramUpdate.create({
data: { updateId: ctx.update.update_id },
});
// If duplicate, Prisma throws unique constraint error → catch and ignore
Prisma schema:
model TelegramUpdate {
id Int @id @default(autoincrement())
updateId Int @unique @map("update_id")
createdAt DateTime @default(now())
@@map("telegram_updates")
}
The Workflow in Practice
Before (traditional CMS):
- Open laptop (if not already on)
- Navigate to
~/code/portfolio - Create
content/projects/new-project.md - Write markdown: title, description, tech stack, screenshots
- Upload screenshots to
/public/images/ - Format relative paths
git add,git commit -m "Add new project"git push origin main- Wait 2-3 minutes for Vercel deploy
- Check live site to verify
Total time: 5-10 minutes
Friction: High — requires laptop, Git knowledge, deploy wait
After (Telegram CMS):
- Open Telegram (already open on phone)
- Send message:
/addproject VICTOR|AI geopolitical intelligence platform|100|https://victor-intel.up.railway.app/screenshot.png|https://github.com/patrick-jaritz/VICTOR|python,fastapi,ai - Site updates in <10 seconds
Total time: <10 seconds
Friction: Minimal — works from phone, instant feedback
Challenges & Solutions
Challenge 1: Webhook Reliability
Problem: Railway's free tier has cold starts — first webhook after 10 minutes of inactivity takes 5-8 seconds to respond. Telegram times out and retries.
Solution: Added health check endpoint (/health) that Railway pings every 5 minutes to keep server warm. Also implemented exponential backoff for retries.
Challenge 2: Security
Problem: Anyone could theoretically send requests to /telegram/webhook and spam my database.
Solution: Three-layer security:
- Telegram sends secret token in webhook payload → verify on every request
- Check
ctx.from.idmatches my Telegram user ID - Rate limiting: max 30 requests/minute from any IP
Challenge 3: Markdown Formatting on Mobile
Problem: Writing markdown on a phone keyboard is painful (especially code blocks).
Solution: Added "Magic Format" command that converts plain text → markdown:
bot.command('format', async (ctx) => {
const text = ctx.message.text.replace('/format ', '');
const markdown = await convertToMarkdown(text); // GPT-4 API call
ctx.reply(markdown, { parse_mode: 'MarkdownV2' });
});
Now I write in plain English, send /format, and GPT-4 converts it to clean markdown.
Results
6 months later:
- 37 projects added (vs 2 in the previous 6 months)
- 12 blog posts published (vs 0 before)
- Update frequency: 3-4x per week (vs once every 2 months)
- Average update time: 8 seconds (vs 6 minutes)
- Mobile usage: 68% of updates happen from my phone
The friction disappeared. Updating my portfolio became easier than ignoring the urge to document something.
What I'd Do Differently
1. Add Voice Messages
Telegram supports voice notes. I could transcribe them via Whisper API and turn them into blog posts:
bot.on('voice', async (ctx) => {
const voice = await ctx.telegram.getFile(ctx.message.voice.file_id);
const audioUrl = `https://api.telegram.org/file/bot${BOT_TOKEN}/${voice.file_path}`;
const transcription = await openai.audio.transcriptions.create({
file: await fetch(audioUrl).then(r => r.blob()),
model: 'whisper-1',
});
const blogPost = await convertToBlogPost(transcription.text); // GPT-4
ctx.reply(`Draft created:\n\n${blogPost}\n\nSend /publish to go live`);
});
2. Add Analytics Integration
Track which projects get the most views via Google Analytics API:
bot.command('stats', async (ctx) => {
const topProjects = await getTopProjectsByViews();
ctx.reply(`📊 Top 5 Projects:\n${topProjects.map(p => `- ${p.title}: ${p.views} views`).join('\n')}`);
});
3. Scheduled Reminders
Set up cron jobs to remind me to update stale content:
// Every Monday 9am
cron.schedule('0 9 * * 1', () => {
const staleProjects = await prisma.project.findMany({
where: { updatedAt: { lt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) } },
});
if (staleProjects.length > 0) {
bot.telegram.sendMessage(
TELEGRAM_ADMIN_ID,
`⏰ ${staleProjects.length} projects haven't been updated in 30+ days. Update them?`
);
}
});
Lessons Learned
- Reduce friction, not features. The best tools get out of your way.
- Mobile-first matters. Most of my best ideas happen away from my desk.
- Webhooks > polling. Real-time beats "check every 5 minutes."
- Use what you already use. Telegram was already my daily driver — building on top of it meant zero onboarding.
- Imperfect > perfect. Shipping a "good enough" Telegram bot in 2 days beats planning the perfect CMS for 2 months.
Try It Yourself
Want to build your own Telegram-powered portfolio? Here's the starter kit:
1. Create a Telegram bot:
# Message @BotFather on Telegram
/newbot
> your-bot-name
2. Set up webhook handler:
npm install express telegraf prisma
3. Deploy to Railway:
railway init
railway up
4. Set webhook:
curl -X POST "https://api.telegram.org/bot<YOUR_TOKEN>/setWebhook?url=https://your-app.railway.app/telegram/webhook"
5. Send your first command:
/addproject My Project|Description|100
Full code: github.com/patrick-jaritz/dev-diary
Conclusion
Building a self-updating portfolio with Telegram wasn't about fancy tech — it was about removing the friction between having an idea and publishing it.
The result: a portfolio that's always current, because updating it is easier than not updating it.
If your content system has too much friction, you won't use it. Find the tool you already use every day, and build on top of it.
For me, that was Telegram. For you, it might be Discord, Slack, iMessage, or even email.
The point is: make documenting your work easier than ignoring it.
Want more posts like this? Subscribe to my newsletter or follow me on X/Twitter.
Questions? Reply via Telegram: @patrick_jaritz or email: patrick@patrick.technology
This post was written on my laptop, but edited and published via Telegram. Meta.
Get Updates
New posts on systems thinking, AI, and building things. No spam, unsubscribe anytime.