Skip to content

Allow this site to count your visits with Yandex Metrica? Metrica will store cookies in your browser to do this. How the site uses cookies

VC
Case 19 of 22 · Built end to end · Front end and back end

A marketing automation platform: desktop → Telegram → web

A platform for managing ad campaigns that I built for my own advertising work. It started in 2024 as a Tkinter desktop app for personal use. It grew into a full product with 80+ features, a Telegram bot and a web app on Next.js and FastAPI, with separate user roles and an encrypted store for access tokens. By September 2026 the repository had 826 commits, the desktop app had 13 tabs, and the web app had 228 API endpoints. Ad spend from two ad networks goes into the tracker in each network's own time zone. Reports trace a click back to the specific ad and follow sign-ups through to first payments by app, country and offer.

Project type
Marketing automation platform (3 interfaces)
Technology
Next.js 14 · FastAPI · PostgreSQL · Celery
Timeline
2024 → 2026 · 826 commits May–September 2026
Scale
13 tabs · 228 API endpoints · 221 tests
01 · Problem

Launching one campaign took 2 hours of manual work

In ad buying, launching one campaign takes 9 linked steps: build the campaign name (store / product / country / buyer / niche), pick the landing page and offers in the Keitaro ad tracker, make a short link with Cutt.ly, set the link preview (OG tags), attach the Facebook pixel and access token, publish a post on the Facebook page and add the first comments.

Each step is easy to get wrong: a campaign alias repeats from an earlier campaign, the pixel ID is wrong, a UTM tag goes missing. Then you can no longer tell which ad brought the sale, and the budget is wasted. With 5–10 launches a day, a large share of the team's time went on copying and pasting.

The team needed a tool built around its own naming scheme and its own integrations, one that could grow as its needs grew.

02 · Evolution

Four versions, three interfaces

v1
DESKTOP

A native macOS app on Tkinter, for personal use

Python, Tkinter and the native macOS Edit menu (Undo, Cut, Copy, Paste). A tool for my own work: one complete form that picks landing pages and offers automatically, checks the input, saves templates and keeps a local history in SQLite. About 2,000 lines of Python, packaged as a .app bundle.

v2
TG BOT

A Telegram bot for the team: everything happens in the chat

python-telegram-bot 20 and a custom step-by-step dialogue: the bot collects the fields one by one through buttons in the chat. The business logic is shared with the desktop app through common modules. An ad buyer can create a campaign from a phone, straight from the chat.

v3
WEB APP

A web app for several users, with roles

Rewritten from scratch as a full web application: Next.js 14 (App Router, TypeScript, Tailwind, shadcn/ui), FastAPI (async SQLAlchemy 2 and Pydantic v2), PostgreSQL 16, Celery and Redis 7.

An encrypted token store built on Fernet (AES-128-CBC and HMAC-SHA256): the browser never sees raw API tokens. Sign-in uses JWT, and passwords are stored with bcrypt. Every action is logged in the database. It runs in Docker Compose on a Vultr VPS, and the server's real address is hidden behind Cloudflare.

v4
DATA

Summer 2026: matching the interfaces, and a data layer

From June to September there were 178 commits. The first half of the work was making the web app match the desktop app: six review rounds (Pass-38 to Pass-43, 24 commits between 6 and 14 June). Each round had three steps: parallel reviews of groups of tabs, one combined list of differences, then fixes under the rule "one file, one editor", because two editors working on the same file at once quietly undo each other's work. One round had to be rolled back. Type checks and the build passed and the page returned 200, yet every page behind the login broke in the browser: the button component passed two child elements to a Radix wrapper that accepts exactly one. The round went out again two days later, and now every front-end deploy has to pass a full pre-render of all pages first.

The second half was data. Ad-network spend goes into the tracker in each platform's own time zone: Meta spend in each ad account's time zone, and Unity Ads spend from hourly data grouped into Los Angeles days, which is how the tracker counts them. On top of that came tracing clicks to ads by fbclid (the click ID Facebook adds to links), reports from sign-up to first payment by app, country and offer, collecting the partner network's list of offers, and a rules engine that suggests what to do with each funnel.

03 · Features

What's inside: 10 of the 80+ features

01 · Batch
Batch mode: 2–10 campaigns at once

An editable table, automatic post numbering, a progress bar, and a results table showing success or error for each row.

02 · Vault
Encrypted token store

Fernet (AES-128-CBC and HMAC-SHA256), the master key kept in an environment variable, a separate vault-admin role. Tokens stay on the server.

03 · Smart suggestions
Suggestions based on your history

"You usually pair this store with this product", plus time hints ("14:00 is when you usually post").

04 · Auto-save
The draft is saved every 5 seconds

The form saves itself to the database. If the browser or the app closes unexpectedly, the form is restored from the last save. There's also a "clear draft" button.

05 · Alias check
Duplicate alias check as you type

The field border turns red or green, and the button is disabled if the alias is taken. In batch mode duplicates are fixed automatically by adding -2, -3 or a random word.

06 · Templates
Templates with categories

Grouped by country and store, applied in one click, previewed before applying, imported and exported as JSON, and shared across the team.

07 · History
A full action log with search

Every change is logged with a timestamp. Search and filters (campaign, post, ads, error), a date range, export to CSV or JSON, and one click takes you to the campaign.

08 · Backup
Automatic backup every 30 minutes

The most recent copies are kept locally, there's a manual backup button in the menu, and the status bar shows when the data was last saved.

09 · Duplicate guard
A warning about duplicate campaigns

A warning appears when you try to create a campaign with the same store, product and country as an existing one, with a side-by-side comparison against the earlier campaign.

10 · Shortcuts
macOS keyboard shortcuts

Cmd+1/2/3 switch tabs, Cmd+N starts a new campaign, Cmd+H opens the history. Most of the work can be done from the keyboard.

This is my own internal tool, and I use it myself. It's here to show what I can build on my own: a product with three interfaces, in real use, with user roles and 80+ carefully built features. By September 2026 it had 13 desktop tabs, 33 web-app screens and 85 utility scripts. I bring the same attention to detail to client projects and choose the technology to fit the job.

04 · Technology

The technology behind it

Front end (v3)
  • Next.js 14 (App Router)
  • React 18 + TypeScript
  • Tailwind CSS + shadcn/ui
  • Radix UI primitives
  • react-hook-form + Zod validation
  • Sonner for pop-up notifications
Back end (v3)
  • 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 the token store
Data and queues
  • PostgreSQL 16 (pgcrypto, uuid-ossp)
  • Celery 5 for background tasks
  • Redis 7 as the task broker and cache
  • SQLite (desktop and Telegram versions)
  • The tracker's ClickHouse: read-only click-level data for early rules
Deployment and servers
  • Docker Compose, several services
  • Nginx (TLS, security headers, source maps blocked)
  • Cloudflare DNS and CDN, server address hidden
  • Vultr VPS, Ubuntu 24.04
  • Automatic Let's Encrypt certificates
  • Scheduled jobs on the tracker's server: spend sync every 15 minutes, a daily rules report
Desktop (v1)
  • Python 3 + Tkinter
  • Native macOS Edit menu
  • SQLite for local DB
  • py2app for .app bundle
Telegram (v2)
  • python-telegram-bot 20 (async)
  • Step-by-step dialogues (state machine)
  • Buttons in messages and their handlers
  • Analytics bot: campaign cards, pause and resume, rules-engine recommendations, health checks
Integrations
  • Keitaro 11.10 API (campaigns, landing pages, offers, costs)
  • Facebook Graph API + Marketing API (posts, comments, pages, campaigns)
  • Meta Conversions API through my own event relay
  • Unity Ads Statistics API (hourly spend and installs)
  • Cutt.ly API (short links)
  • Partner-network APIs for checking sales
  • Webhook handlers for status updates
Quality
  • Pytest: 221 tests in 10 files, 83 of them added in summer 2026
  • Rules engine tested on 90 days of history without look-ahead
  • Health checks: 22 checks in 7 groups every 30 minutes
  • Action log table in the database
  • Central ErrorHandler
  • Structured logging
05 · Results

What changed in day-to-day work

Campaign launch time
2 hrs ~15 min

8 times faster, with duplicate checks and every field validated

Batch mode
1 2-10

campaigns per run, in an editable table with settings for each one

Features built
80+

across 10 stages, from the first working version to a multi-user web app

Conversion events that carry the click ID
27% 100%

a filter on fbclid prefixes dropped 492 of 672 clicks a day, so sales reached Meta without a click ID; checked on live logs: 180/672 → 672/672

Click → campaign, ad set, ad
0 35.1%

of clicks from a single link in a comment are traced to the exact ad: 13.5% from IDs Meta adds itself, 21.6% from my own fbclid decoder; four campaigns with the same name are now counted separately

Short link without a custom domain
270 s 4.9 s

the link shortener rejected requests for a branded domain, and the client retried 14 times with growing pauses; now it switches to the default domain at once

The overall effect: every ad launch the team makes now goes through one platform. The Telegram bot is for ad buyers on the move, the web app for office work and teamwork, and the desktop app for those who prefer native macOS. The desktop app and the bot share the same business-logic code, and the web app was brought into line with the desktop app tab by tab.

What this means for client projects: when the business logic is kept separate from the interfaces, a new interface (a mobile app, a Slack integration, a voice assistant) can be added on the same back end without rewriting the logic.

Summer 2026: spend and reports. Meta spend is written to the tracker in each ad account's own time zone. One profile had accounts in two time zones: the day boundary was about 10 hours off, and part of a day's spend landed on the next day. Counting only unique clicks when writing removed a 10–15% overstatement. Each write is a full-day total, so running it again is safe: it runs 96 times a day, and yesterday is recalculated after midnight while the network sends its late hours. Reports from sign-up to first payment run for each campaign in the second network. Spend and installs come from that network; sign-ups, payments and revenue come from the tracker. The two are matched by a six-character campaign ID in the campaign name. The report shows cost per install, conversion at each step, ROI and the break-even point. The settled rate (sign-ups older than 6 hours) showed the same offer converting differently in different apps, so the app is checked before the offer. Payments arrive over 24–48 hours, so the report flags the first-day rate as understated.

Tracing clicks to ads. A filter on fbclid prefixes 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 whole prefix family raised the share to 672/672. Then the prefix list was removed altogether, since such a list goes out of date with the next format change. 48 sales from a 7-day window were sent again with the same fixed event ID, and all 48 were accepted. Several campaigns ran 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 lookup table is my own, built from clicks where Meta adds the IDs itself: 262 pairs, 22 ads, 0 collisions, and an independent check of 361 out of 361. A directory of ads, ad sets and campaigns covers 10 ad accounts (912 ads, 760 ad sets, 244 campaigns) with 0 integrity errors. The collector remembers the last record it processed, so repeated runs don't double-count; before that, every run doubled its counters. The analytics bot doubled tracker figures for campaigns with the same name and counted sales from only one stream: 10 against 13 in the tracker, which triggered a false stop signal. Now all streams are added up, and the report matches the tracker to the cent. 34 tests on real samples, all offline.

The rules engine. Five possible recommendations for each funnel (a tracker campaign and its Meta campaigns), from an early stop to scaling up. The engine suggests and the operator decides: it writes nothing to the tracker or the ad accounts. It reads three sources. The tracker is the only source of money and funnel data. Meta provides ranking, live spend and account health. The partner network is a cross-check on sales: when it reports more sales than the tracker, a stop is blocked. The thresholds were derived from 92 campaign-days and matched the operator's rules of thumb: they are the points where ROI crosses zero, measured by the price of a unique click and of a landing-page click. Rebuilding the numbers from individual clicks matched the tracker report in 1,704 of 1,704 day-and-campaign cells. A 90-day backtest without look-ahead showed that the first version would have stopped the flagship funnel, which went on to bring 139% of the account's profit. So a rule was added never to stop a profitable funnel, the minimum sales for a cost-per-action decision rose from 3 to 8, and the worst-price-zone rule now needs at least 2 sales; it had issued 9 of 17 recommendations with zero sales. Over 90 days the rules affect 0.8–1.8% of spend, and the report says so plainly. 49 tests and a daily Telegram report. The bot's campaign cards use the same engine and show the reason for each recommendation.

The analytics bot and the token store. Meta profiles are added and switched from the chat, and a token is checked and deleted from the conversation straight away. Pause and resume work with any of the operator's tokens, and Meta rate limits trigger retries after 1, 2 and 5 seconds. The web token store checks scope and expiry, shows a token only after you re-enter your password, can archive and restore, locks itself down automatically, and logs the devices used to sign in. An offer can no longer be created with an empty or placeholder link. That check was added after 33 offers were found pointing to a placeholder.

Ready to start?

Start with the 5,000 ₽ audit: a written report and a payback estimate

I'll tell you what to automate first, when it will pay for itself and whether your task needs AI at all. Sometimes it doesn't.

Or just ask a question. During business hours I reply within 2 hours