From internal MVP to whitelabel SaaS: a startup-investor matching platform

A VC firm's internal investor-matching MVP became a whitelabel multi-tenant SaaS: AI deal analysis, Elasticsearch match scoring, four years in production.

From internal MVP to whitelabel SaaS: a startup-investor matching platform
Photo by Dylan Gillis on Unsplash
Client
Venture-capital firm's fundraising platform (anonymized)
Role
Backend engineer & architect
Period
2022–2026
Stack
Django · DRF · Vue · PostgreSQL · Elasticsearch · Celery
Outcomes
  • Kept a matching platform in production ~4 years of continuous development
  • Graduated an LLM deal-analysis pipeline from Dify prototype to native Django/Celery
  • Cut Elasticsearch reindex load from thousands of ops/minute to 4 batch operations/hour (per project docs)
  • Converted a single-client tool into a whitelabel multi-tenant SaaS with per-tenant branding and per-tenant LLM vendor selection
Contents

Most of my client work is a sprint. This one was a marathon — I stayed with a single platform for the better part of four years, from the first Investor App commit in 2022 to outreach-email fixes shipping in early 2026. That length is the interesting part. You get to make a call, live with it for eighteen months, and find out whether you were right.

It started as a venture-capital firm’s internal MVP: a Django + Vue tool where a fundraising startup builds a profile and gets matched against a large, curated database of investors and contacts. My job on paper was “backend engineer.” In practice I was the person deciding what the product should become, and it became a lot.

Growing the fundraising workflow

The MVP was a matching screen. Over the next year it grew into the whole job a founder actually does when raising: weighted match scoring, saved searches, ringfencing investor lists so two startups in the same portfolio don’t get pitched to the same fund, notes, warm-intro templates, and outreach campaigns run straight from the product. The outreach side layered up incrementally — a third-party sequencing API first, then a self-hosted SMTP/IMAP proxy so the platform could own its own sending, with email validation gated behind concurrency and daily-job caps so we didn’t torch domain reputation. None of that was one big design. It was accretion, guided by watching the firm’s team actually use the thing.

Unconventional call #1: whitelabel inside one deployment

In mid-2024 the firm decided the internal tool was good enough to sell — other companies wanted to run it as their own branded matching platform. The conventional move for “give each customer their own instance” is per-client deployments. I didn’t do that. I made it multi-tenant inside one deployment.

A middleware resolves the tenant from the request, and a SiteModel base class scopes every tenant-owned query so one customer can never read another’s data. Each tenant is its own Django Site with its own logo, trial and free-tier limits, and — the part that mattered most — its own AI configuration.

class SiteCompany(SiteModel):
    llm_vendor = models.CharField(default="claude")  # claude | gpt | gemini | perplexity
    keys = models.JSONField(default=dict)            # tenant-supplied API credentials

The reason to eat the multi-tenancy complexity up front: one codebase, one CI pipeline, one place to fix a bug for everyone. The trade-off is that data isolation becomes a correctness property you have to defend on every query, not a deployment boundary you get for free. I’d make the same call again — the operational savings of not babysitting N deployments dwarfed the cost of being disciplined about tenant scoping.

Per-tenant, bring-your-own LLM

Whitelabel is what forced the LLM abstraction, and I think that’s the most product-y decision in the whole thing. A whitelabel customer doesn’t want to run their deal analysis through my AI account — they want their own vendor, their own billing, their own compliance posture. So the AI layer resolves the vendor and keys from the tenant:

def client_for(tenant: SiteCompany) -> LLMClient:
    # one interface over Claude, GPT, Gemini and Perplexity
    return LLMClient(vendor=tenant.llm_vendor, keys=tenant.keys)

Claude is the default; a tenant can switch to GPT, Gemini or Perplexity and drop in their own keys without touching code. Building the abstraction cost more than hardcoding one provider, but it turned “which LLM do you use?” from a blocker in a sales call into a config field.

Match scoring on Elasticsearch, and the reindex storm

By late 2024 the matching heuristic had earned its own subsystem. The score is a weighted blend of how well a startup and an investor line up:

MATCH_WEIGHTS = {
    "industry_sector": 0.35,
    "investor_type":   0.18,
    "stage":           0.18,
    "region":          0.10,
    "countries":       0.09,
    "city":            0.05,
}

I moved scoring off Python/Postgres and onto Elasticsearch so those weighted queries stayed fast as the contact database grew. That worked — and then it bit back. Match scores are derived data, so every related change fired a Django signal that reindexed the affected contact document immediately. At scale that meant potentially thousands of Elasticsearch writes a minute, most of them redundant.

The fix was to stop treating “the index must be perfectly current” as a requirement it never actually was. I decoupled ES writes from signals entirely: instead of reindexing inline, a change just enqueues an id.

PendingElasticsearchSync.objects.get_or_create(content_type=ct, object_id=obj.pk)

A cron’d management command drains that queue in batches of 500 every 15 minutes. Per the project’s own docs, that took Elasticsearch load from thousands of operations a minute down to four batch operations an hour, for search freshness nobody could perceive was any different. The unconventional bit isn’t the queue — it’s being willing to let the index be a few minutes stale on purpose.

Unconventional call #2: prototype on Dify, ship on Celery

The AI deal analyser — feed it a pitch deck, get back a structured investment report — is where I most deliberately used a throwaway. In December 2024 I stood up self-hosted Dify to prototype the analysis workflows, with a small FastAPI sidecar exposing the research/scraping steps as tools Dify could call. Visual workflow editing let me iterate on a ten-section report in days instead of weeks, in front of stakeholders, without a deploy per change.

The conventional wisdom says don’t build throwaway infrastructure. I did, because at that stage the bottleneck was figuring out what the report should say, not shipping production code. Dify was a prototyping surface, and I treated it as disposable from the start.

Two months later, once the shape had stabilized and it actually mattered, I graduated the whole pipeline into native Django/Celery tasks with a version-controlled prompt library — one .txt per report section, in git, diffable and reviewable like any other code. That bought three things Dify couldn’t: prompts under real version control, testability, and one fewer platform to operate. Prototype fast on the visual tool, then pay down to boring, versioned code the moment the idea proves out. Knowing when to make that switch is most of the skill.

What four years taught me

The platform stayed in active production through early 2026. The honest coda: after roughly three years of accretion, the core matching domain had collected enough migrations and dead models that the right move was a greenfield rebuild with cleaner, GIS-aware models rather than another in-place refactor — and I called that too.

The thing I’d underline for a peer: none of these were tickets handed to me. Deciding to go multi-tenant instead of multi-deploy, to let the search index go briefly stale, to prototype on a tool I fully intended to throw away — that’s product judgment compounding over years on the same codebase, which is a very different muscle than shipping a feature and moving on.

In this case study · 3 posts

  1. 01 Prototype on Dify, Ship on Celery
  2. 02 Taming Elasticsearch Reindex Storms in Django
  3. 03 One Django Codebase, Four LLM Vendors