Campaign Manager
One lead ledger across email, LinkedIn, CRM, calendar, and product signups
- Scheduled sync jobs
- 13
- Derived lead statuses
- 18
- API endpoints
- 110+
- Unit tests
- 440+
- Dashboard pages
- 20
- Source systems
- 8
Campaign Manager is the single source of truth for outbound leads. It ingests every touch from a cold-email platform, connected Gmail inboxes, Google Calendar, a meeting recorder, a LinkedIn outreach tool, the product's own user database, and product analytics into one append-only event timeline per person, then derives lead status, cold-ness, attribution, and the HubSpot deal stage from those events. It also runs a guarded prospecting pipeline that finds, screens, enriches, and pushes new leads into campaigns.
The problem
Outbound runs across tools that do not see each other. A cold-email platform marks a lead hot, the conversation moves to the founder's Gmail, and the platform never learns the lead replied, so its state goes stale and the CRM inherits a status nobody trusts. Retargeting on LinkedIn is not tied back to the campaign that produced the lead, and a signup or payment in the product is not tied to either. Answering simple questions, like which hot leads went cold this week or which campaign produced a paying customer, means reconciling five tools by hand.
How it is built
Every source writes into one MongoDB ledger: a leads collection with one document per human carrying every identifier learned about them, and an append-only events collection where each fact has a dedupe key, so re-running a sync or replaying a webhook is harmless. Identity resolution tries provider ids, then email, then canonical LinkedIn URL, and merges leads that turn out to be the same person. Email messages keep their RFC headers so a Gmail reply can be stitched to a campaign thread before the lead's address is even known.
A status engine recomputes each lead after every ingest and on a periodic sweep, purely from events; a human override is itself an event. Attribution records last touch, first touch, and the assist chain for signups, payments, and meetings. HubSpot receives a mapped deal stage and a timeline note explaining each change, and human stage moves made in HubSpot are read back as overrides.
Thirteen scheduled jobs run in-process with APScheduler in a fixed order so downstream jobs see the freshest ledger. Each has cursors, bounded retries, and timeouts; a failing integration never blocks the others, and a dry-run flag logs every outbound write instead of sending it.
Prospecting is a separate guarded pipeline: search, screen on free data, pay to find emails only for survivors, screen again, pause for review, then push. Screening fails closed and records every reason. AI features (reply drafts assembled from switchable context sections, a read-only tool-using Ask agent, and a self-learning knowledge base) are gated so nothing is sent or promoted to the drafter without a person approving it.
What it taught me
- Giving every event a dedupe key made all syncs replayable and turned webhook retries, cursor resets, and manual re-runs into non-events.
- Screening must fail closed: when the CRM is unreachable, the people it would have checked are blocked and the run says so, because failing open means emailing someone who opted out.
- Company domain is not identity; matching people by email or canonical LinkedIn URL only avoids blocking a whole company because one colleague said no.
Architecture
Sync jobs poll each outreach channel, the calendar, the product database, and product analytics into one MongoDB ledger of leads and events. A status and attribution engine recomputes each lead after every ingest, and the result is pushed to HubSpot as contact properties and deal stages while human stage moves are read back. A Next.js admin UI reaches the FastAPI backend through server-side proxy routes, and an AI provider drafts replies and answers questions over read-only tools.
Layer 1
Outreach channelsExternal
email platform, Gmail, LinkedIn, calendar
→ Sync jobs (poll, webhook)
Product signalsExternal
user database, analytics
→ Sync jobs (poll)
Admin UIClient
Next.js, proxied server-side
→ FastAPI backend (proxy)
Layer 2
Sync jobsWorker
13 polled jobs plus webhooks
→ MongoDB ledger (append events)
FastAPI backendService
REST, segments, prospecting
→ MongoDB ledger (query) → AI provider (draft, ask)
Layer 3
MongoDB ledgerStore
leads, events, messages
→ Status + attribution (recompute)
AI providerModel
drafts, Ask agent, qualification
Layer 4
Status + attributionService
recomputed from events
→ HubSpot (push stage)
Layer 5
HubSpotExternal
deal stages, read-back
→ Status + attribution (read back)
Stack
Backend
- Python 3.13
- FastAPI
- APScheduler
- PyMongo
Data
- MongoDB
- HubSpot CRM
- Gmail and Google Calendar APIs
- PostHog HogQL API
Frontend
- Next.js 16
- React 19
- Tailwind CSS v4
- Radix UI
- SWR
- Recharts
AI
- Anthropic and OpenAI (provider-neutral)
- tool-using read-only agent
- lexical knowledge retrieval
Infrastructure
- Docker
- AWS App Runner (single instance)