Case study
ContactDock
A Telegram bot, a Mini App dashboard and a serverless API that turn any website contact form into Telegram messages, with no backend on the website. The contact form on this portfolio runs on it.
- Role
- Solo project: product, bot, API, Mini App and deployment
- Timeline
- 2026
- Stack
- Node.jsTelegram Bot APITelegram Mini AppsVercel FunctionsUpstash RedisLuaAES-256-GCMHMAC-SHA256

- automated tests
- 75
- automated tests
- languages: English, Polish, Russian
- 3
- languages: English, Polish, Russian
- form fields: 7 standard, 5 custom
- 12
- form fields: 7 standard, 5 custom
- day links with renewal reminders
- 90
- day links with renewal reminders
The problem
A contact form needs somewhere to send submissions: a mail server, a CRM or an admin panel. For freelancers and small businesses that is more infrastructure than the form itself.
ContactDock moves the whole flow into Telegram. The owner creates a link in the bot, pastes a ready-made form into the page, and every submission arrives as a message with buttons to handle it.
What I built
- Bot commands and buttons to create, renew, test and revoke form links
- A ready-made HTML form for each link, unstyled so it matches the website
- Up to 12 fields per form: 7 standard and up to 5 custom, each required or optional
- Notifications with buttons to mark a submission in progress, close it or copy the email
- Mini App dashboard with an overview, links, full-text search and filters by date, website and status
- CSV export of the filtered submissions, sent straight to the chat
- Reminders 7 days and 1 day before a 90-day link expires, sent by a daily cron job
- Bot and dashboard in English, Polish and Russian
Screens
Architecture
Serverless functions on Vercel handle the website form, the bot webhook, the Mini App API and a daily cron job. All state lives in Upstash Redis.
01
Entry points
- Contact form on any website
- Telegram bot webhook
- Mini App (static HTML, CSS, JS)
- Vercel Cron, daily at 09:00 UTC
02
API
- One Vercel Function per route
- Origin, field and size validation
- Rate limits in an atomic Lua script
- Mini App initData signature check
03
Data and delivery
- Upstash Redis lists and sorted sets
- Submissions encrypted with AES-256-GCM
- Telegram messages, buttons and CSV files
Engineering decisions
01
Links that cannot be forged
Every form link carries a token signed with HMAC-SHA256 and bound to one website. Each submission is also checked against the list of active links, so revoking a link takes effect immediately.
02
Encrypted at rest
Submissions are stored in Redis encrypted with AES-256-GCM, using a key derived from a server secret. Older plain records stay readable, so switching on encryption needed no migration.
03
No lost submissions under concurrency
Redis lists are changed one item at a time by Lua scripts using LPOS and LSET instead of being rewritten. A submission that arrives while a status changes is never overwritten.
04
Spam and abuse limits
5 submissions per minute per IP, 30 per minute and 500 per day per link, counted atomically in Redis. A hidden honeypot field silently drops bots, and bodies over 16 KB are rejected.
05
Only trusted Telegram traffic
The webhook requires Telegram’s secret header, every Mini App request verifies the signed initData and expires after 24 hours, and the bot ignores group chats so members cannot manage someone else’s links.
06
Telegram’s 4096-character limit
When a submission does not fit into one message, a binary search finds the longest equal cut for every visitor value and adds a note. The full text stays in the dashboard.
Testing
75 tests run on Node’s built-in test runner with Redis and Telegram mocked, so they need no network access or real credentials. A few of them:
- link tokens are bound to the site and reject tampering
- Telegram Web App initData is authenticated and time-limited
- message storage encrypts new records and remains compatible with legacy records
- dashboard status update keeps a submission pushed concurrently
- contact endpoint enforces a daily submission cap per link
- bot ignores group chats so members cannot manage the group links
Want to talk about this project?
I’m happy to walk through the code, the trade-offs and what I would build next.