Marketing-Ops Platform: desktop → Telegram → web
Ad campaign management platform, built for my own advertising projects. Started in 2024 as a Tkinter desktop for personal use — grew into a fully fledged product with 80+ features, a Telegram bot, and a production web app on Next.js + FastAPI with multi-user RBAC and encrypted token vault. By September 2026 the repository holds 826 commits: 13 desktop tabs, 228 API endpoints in the web app, spend from two ad networks flows into the tracker in the platform's own timezone, and reports trace a click to the ad and roll registrations into first payments per app, country and offer.
Launching one campaign = 2 hours of manual work
In media buying, launching one ad campaign takes 9 connected steps: assemble the naming (store / product / country / buyer / niche), pick the landing page and offers in Keitaro, create a short URL via Cutt.ly, set OG tags, attach Facebook Pixel and Access Token, create a post on the FB page, add seed comments.
Every step is easy to mess up: the alias repeats from a previous campaign, wrong pixel ID, a missing UTM tag — attribution breaks and budget burns. With 5-10 launches a day, the whole team spends more time copy-pasting than making ads.
No ready-made solution fits: commercial SaaS is tied to a single CPA network, and in-house tools elsewhere are locked to rigid processes. We needed a tool that understands our naming structure, our integrations and evolves with team needs.
Four iterations, three interfaces
Tkinter native macOS app — for personal use
Python + Tkinter + native macOS Edit menu (Undo/Cut/Copy/Paste). A personal productivity tool: one full form with auto-pick of landings/offers, validation, template saving, local history in SQLite. ~2000 lines of Python, packaged into a .app bundle.
Telegram bot for the team — everything runs in chat
python-telegram-bot 20 + a custom state machine: step-by-step field collection via inline buttons. The same business-logic code is reused from the desktop via shared modules. A media buyer creates a campaign from a phone, straight from chat.
Production web app with multi-user RBAC
Fully rewritten from scratch as production-grade SaaS: Next.js 14 (App Router, TypeScript, Tailwind, shadcn/ui) + FastAPI (async SQLAlchemy 2 + Pydantic v2) + PostgreSQL 16 + Celery + Redis 7.
Encrypted token vault on Fernet (AES-128-CBC + HMAC-SHA256) — the frontend never sees raw API tokens. JWT auth with bcrypt passwords. Audit log of every action in the DB. Deployed via Docker compose on Vultr VPS, origin IP hidden behind Cloudflare.
Summer 2026: interface parity and a data layer
June to September brought 178 commits. The first half of the work was desktop/web parity: six audit passes (Pass-38 … Pass-43, 24 commits between June 6 and 14). Each pass: a parallel audit by tab cluster, a consolidated gap list, then fixes under the rule "one file, one editor" — two concurrent editors of the same file silently undo each other's work. One pass had to be rolled back: type-check and build passed, the page returned 200, yet every authenticated page failed in the browser — the button component handed the Radix wrapper two children where it accepts exactly one. The pass shipped again two days later, and every frontend deploy is now gated by a full prerender of all pages.
The second half is data. Ad-network spend lands in the tracker in the platform's own timezone: Meta spend in each ad account's timezone, Unity Ads spend from hourly data folded into Los Angeles days, which is how the tracker counts. On top: click-to-ad attribution from fbclid, sign-up → first payment reports per app × country × offer, a crawl of the partner-network offer database, and a rules engine that proposes verdicts per funnel.
What's inside — top 10 functions from 80+
Editable table, post auto-numbering, side-by-side progress bar, results table with success/error per row.
Fernet (AES-128-CBC + HMAC-SHA256), master key from ENV, separate vault-admin role. Tokens never leave the backend.
"You usually use Hershey's with Kisses", time-based hints ("14:00 — your typical posting time").
Form auto-saves to DB. If the browser or app quits unexpectedly — recovery from the latest save, "clear draft" button.
Red/green field border, button disabled on collision. Auto-fix in batch (appending -2, -3, or a random word).
Grouping by country and store, quick-apply, preview before applying, JSON import/export, ability to share templates across the team.
Action logger with timestamp per change, search/filter (Campaign/Post/Ads/Error), date-range filter, CSV/JSON export, click-to-jump to campaign.
Backup manager keeps the last 10 copies locally, manual-backup button in menu, last-save time indicator in the status bar.
Warning when trying to create a campaign with the same store + product + country as an existing one, side-by-side compare with the previous version.
Cmd+1/2/3 tab switch, Cmd+N new campaign, Cmd+H history, Cmd+Q quick-actions panel. Full keyboard navigation.
This is my personal build — an internal tool I use myself. Shown here as a reference for what I can build solo: full-stack product with three interfaces, production deploy, multi-user RBAC, ~80 polished features. By September 2026: 13 desktop tabs, 33 web-app screens, 85 utility scripts. In client projects I bring the same level of attention to detail to your task — the stack is chosen to fit the job.
Production-grade technologies
- Next.js 14 (App Router)
- React 18 + TypeScript
- Tailwind CSS + shadcn/ui
- Radix UI primitives
- react-hook-form + Zod validation
- Sonner for toast notifications
- FastAPI 0.115 (async)
- SQLAlchemy 2 async + Alembic migrations
- Pydantic v2 for all schemas
- JWT (jose, HS256, 24h TTL)
- bcrypt for passwords
- Fernet (AES + HMAC) for vault
- PostgreSQL 16 (pgcrypto, uuid-ossp)
- Celery 5 for async tasks
- Redis 7 for broker + cache
- SQLite (desktop / Telegram versions)
- Tracker ClickHouse — read-only per-click data for early rules
- Docker compose, multi-service
- Nginx (TLS, security headers, source-map blocking)
- Cloudflare DNS/CDN, origin IP hidden
- Vultr VPS, Ubuntu 24.04
- Let's Encrypt SSL automation
- Scheduled jobs on the tracker host: spend sync every 15 min, rules report daily
- Python 3 + Tkinter
- Native macOS Edit menu
- SQLite for local DB
- py2app for .app bundle
- python-telegram-bot 20 (async)
- FSM state machine
- inline buttons + callback handlers
- Analytics bot: campaign cards, pause/resume, rules-engine verdicts, health monitor
- Keitaro 11.10 API (campaigns, landings, offers, costs)
- Facebook Graph API + Marketing API (posts, comments, pages, campaigns)
- Meta Conversions API via a self-hosted event relay
- Unity Ads Statistics API (hourly spend and installs)
- Cutt.ly API (short URLs)
- Partner-network APIs for sales reconciliation
- Webhook handlers for statuses
- Pytest: 221 tests in 10 files, 83 of them added in summer 2026
- Rules-engine backtest on 90 days of history with no look-ahead
- Health monitor: 22 checks in 7 groups every 30 minutes
- Audit-log table in DB
- Centralized ErrorHandler
- Structured logging
What changed in the workflow
8× faster, with no-duplicate guarantee and full field validation
campaigns per run with editable table and individual configuration
across 10 phases — from a first working version to a production SaaS
an fbclid prefix filter dropped 492 of 672 clicks a day, so sales reached Meta without a click ID; verified on live logs: 180/672 → 672/672
of clicks from a single comment link are resolved down to the ad: 13.5% from IDs Meta appends itself, 21.6% from our own fbclid decoder; four identically named campaigns split into separate buckets
the shortener rejected branded-domain requests and the client retried 14 times with growing pauses; it now falls back to the default domain at once
Systemic effect: the platform became a single source of truth for every ad launch the team makes. Telegram bot for media buyers on the road, web app for office work and team collaboration, desktop for those who prefer native macOS. All three use the same code for business logic and one shared database of campaigns/templates/tokens.
Lesson for client projects: the right architecture (separation of concerns, shared business logic, multi-frontend) lets you add a new interface — mobile app, Slack integration, voice assistant — in a week on the same backend.
Summer 2026: spend and reports. Meta spend is written to the tracker in each ad account's own timezone: one profile had accounts on two timezones, the day window drifted by ~10 hours, and part of a day's spend landed on the neighbouring day. A unique-clicks flag on the write removed a 10–15% overstatement. Writes are full-day totals and repeats are safe: 96 runs a day, and yesterday is recomputed after midnight while the network delivers its late hours. Sign-up → first payment reports per network campaign: spend and installs from the second network, registrations, payments and revenue from the tracker, joined on a six-character campaign ID carried in the campaign name; cost per install, funnel-step rates, ROI and the break-even point. The mature rate (registrations older than 6 hours) showed the same offer converting differently across apps, so the app is examined before the offer. Payments arrive over 24–48 hours, and the first-day rate is flagged in the report as understated.
Click-to-ad attribution. An fbclid prefix filter in the event collector dropped 492 of 672 clicks a day: a sale reached the Conversions API without its click ID, and of 4 sales a day the ad account saw 2. Checking the prefix family lifted the share to 672/672; then the prefix list was removed altogether, since such a list goes stale at the next format change. 48 sales from a 7-day window were resent with the same deterministic event ID, 48 of 48 accepted. Several campaigns run one post with one link, and the tracker merged their clicks into one bucket; the adid field inside fbclid matched the real ad ID in 0 of 137 checked clicks, so the mapping table is our own, seeded from clicks where Meta appends the IDs itself: 262 pairs, 22 ads, 0 collisions, independent check 361 of 361. An ad → ad set → campaign directory across 10 ad accounts (912 ads, 760 ad sets, 244 campaigns) with 0 integrity violations; the collector is idempotent by a high-water mark — before that, every run doubled its counters. The analytics bot doubled tracker figures on identically named campaigns and summed sales from a single stream — 10 against 13 in the tracker, which produced a false stop signal; all streams are now summed and the report matches the tracker to the cent. 34 tests on real samples, fully offline.
Rules engine. Five verdicts per funnel "tracker campaign × Meta campaigns", from an early stop to scaling. The engine proposes and the operator decides: the engine writes nothing to the tracker or the ad accounts. Three sources: the tracker as the single source of money and funnel data, Meta for rank, live spend and account health, the partner network for sales reconciliation as a safeguard: when the network reports more sales than the tracker, a stop is blocked. Thresholds were derived from 92 campaign-days and matched the operator's heuristics: they are the ROI sign-change points for unique-click price and landing-click price. The per-click reconstruction matched the tracker report in 1,704 of 1,704 day × campaign cells. A 90-day backtest with no look-ahead: the first version proposed stopping the flagship funnel, which went on to deliver 139% of the account's profit. A "never stop a profitable funnel" guard was added, the minimum sales for a CPA verdict rose from 3 to 8, and the worst-price-zone rule now requires at least 2 sales — it had issued 9 of 17 verdicts at zero sales. The rules' effect over 90 days is 0.8–1.8% of spend, and the report says so plainly. 49 tests, a daily Telegram report; the bot's campaign cards compute their verdict with the same engine and show the reason.
Analytics bot and vault. Meta profiles are added and switched from chat, and a token is validated and deleted from the conversation immediately; pause and resume work under any of the operator's tokens, and Meta rate limits trigger retries with 1, 2 and 5-second pauses. The web vault: scope and expiry checks, reveal only after re-entering the password, archive with restore, auto-lockdown, a log of login devices. An offer with an empty or placeholder URL cannot be created — added after 33 offers were found pointing at a placeholder.
Related cases
Swift/SwiftUI MVP in 9 weeks — from kickoff to App Store
Native iOS 17+: schedule + push (24h/1h/10min), Speech Framework for on-device pronunciation, StoreKit 2 +…
PWA for a fitness studio: 1.2 MB vs 47 MB native
PWA with iOS Add to Home Screen, offline via Service Worker, WebPush iOS 16.4+, YClients embed + YooKassa…
Playable ad for a casual game: CPI −38%, install rate +73%
Interactive HTML5 mini-level built with Cocos2d-js. 18 variations across 5 ad networks (FB / TikTok /…
The 9,900 ₽ audit — with a concrete report and quote
I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).
Or just send your question — I reply within 2 hours