The hardest part of running a web development business has never been the code. It is finding the next client.

For most of the time I have done this work, leads arrived the way weather does. A referral here. Someone who saw a site I built. A friend of a friend who needed something. It was enough to keep the thread going, but it was not a pipeline. It was luck with a delivery schedule I did not control.

Then the ground moved. Anyone can generate a passable landing page from a prompt now. The floor of “I need a website” work rose, the number of people offering it multiplied, and the old signals of credibility stopped doing as much work. Being good at building things became necessary and insufficient at the same time.

I did what a lot of developers do when the market gets noisy: I tried to out-build it. Better portfolio, faster sites, sharper case studies. It did not move the needle, because the problem was never the quality of the work. It was that nobody knew the work existed.

The market did not stop needing developers. It stopped finding them by accident. Distribution became the hard part, and distribution is a systems problem.

So I stopped treating lead generation as marketing and started treating it as engineering. That reframe is the whole story of what came next.

The tool before the product

The first version was not a product. It was a script I ran from my laptop when I wanted work.

It did three things. It searched Google Places for businesses in a category and a city. It pulled whatever public information it could find about each one. Then it drafted an email to that business and put it in a file for me to read before sending anything.

That was it. No dashboard, no accounts, no billing. I ran it, read the output, deleted the bad ones, and sent the rest by hand.

It worked well enough that I kept running it. Then I started automating the parts I was doing manually, in the order they annoyed me most. Sending became automated before tracking did, because addressing envelopes is more tedious than counting replies. Reply detection came next, because I missed two people who had actually written back. Suppression lists came after that, for reasons I will come back to.

Each piece was added because the manual version of it had already failed me at least once. That turns out to be a good way to decide what to build, and a terrible way to end up with a coherent architecture. I would pay for that later.

Why it became a SaaS

I showed it to a few people the way you show anyone a thing you are proud of. Not as a pitch. Just: look at this stupid thing I made, it books me meetings while I sleep.

The reaction was not what I expected. Nobody was interested in the technology. Every one of them asked a version of the same question: could you point it at my business?

That question is worth more than any amount of market research, because it costs the person asking it something. They are volunteering their own problem. A cleaning company in South Florida, an AI consulting firm, an insurance agent — none of them cared how it worked. They cared that their calendar was emptier than they wanted.

The gap between “a script I run for myself” and “a thing other people pay for” is enormous, and almost none of it is the interesting part. It is accounts, billing, permissions, isolation, per-customer configuration, and the thousand small assumptions you baked in when the only user was you.

I will come back to those assumptions, because they produced the worst bugs I have ever shipped.

The stack, and why

The whole platform runs on Azure. Not because I benchmarked the clouds, but because I was already there for client work, and the marginal cost of one more resource group in an environment I understood was close to zero. For a solo developer, the best infrastructure is very often the infrastructure you already know how to debug at eleven at night.

Azure Functions for the API

The backend is a single Python Azure Functions app. HTTP-triggered endpoints, one file of route handlers, blueprints to keep it navigable.

It is serverless because the traffic profile demanded it. A cold outreach platform does nothing at all for most of the day and then does a great deal in a short window. Paying for an always-on server to sit idle between sending windows is paying for silence.

The tradeoffs are real. Cold starts are noticeable on the first request after a quiet period, which matters on the marketing site’s lead capture and does not matter anywhere else. And the single-app model means everything shares one deployment, so a syntax error anywhere takes the whole API down. I have done that. Now I import the module as a deploy check rather than trusting that it parses, because a decorator attached to the wrong function parses perfectly and registers zero routes.

Azure Table Storage instead of a relational database

This is the decision people ask about most, and the one I have gone back and forth on.

I chose Table Storage — a NoSQL key-value store, partition key and row key, no joins, no schema — over PostgreSQL. The reasoning at the time was cost and operational overhead. Table Storage is close to free at my volume, there is no server to patch, no connection pool to exhaust, no migrations to run at deploy.

For the shape of the data, it mostly fits. A lead is a document. A campaign is a document. Most queries are “give me everything in this partition,” which is exactly what the store is good at.

What it cost me was joins, and therefore truth.

In a relational database, “which leads belong to this client” is a foreign key and a query you cannot get wrong. In Table Storage, it is a convention you maintain by hand across a dozen files, and conventions drift.

Here is the specific way that bit me. Leads exist in two shapes. Some are sourced for one particular client and carry that client’s identifier. Most come from a shared pool and carry nothing, because a campaign selects from the pool by targeting rather than by ownership. So the obvious query — filter leads by client — silently returns a fraction of the truth.

That single misunderstanding produced five separate customer-visible bugs in two days. A daily sending cap that read zero while a client had already sent thirty. A summary that undercounted by a third. An activity page that hid 113 of a client’s 282 sends. None of them threw an error. They all just quietly lied.

The fix was to stop asking “who owns this lead” and start asking “which campaign worked this lead,” which is a membership question with an actual answer. But I want to be precise about the lesson: the database did not cause this. I caused it, by encoding a relationship as a convention instead of a constraint. A schema would have made the mistake impossible to express. Table Storage let me express it fluently and be wrong.

If I were starting over at this volume, I would probably still pick Table Storage for the lead pool and put the relational parts — tenants, campaigns, memberships, billing — in Postgres. Use the boring database for the things that must be true.

Container App Jobs for the scheduled work

Everything time-based runs as Azure Container App Jobs on cron schedules: sourcing new leads, sending the day’s batch, sending follow-ups, placing calls, compiling the weekly client digest.

These are Node scripts in a container image. They pull the latest image on their next scheduled run, which means deploying the pipeline is one az acr build and no restart.

Splitting the crons out from the API was one of the better structural calls. The API answers requests in milliseconds. The pipeline does slow, expensive, failure-prone work: network calls to a places API, model calls that take seconds, email sends that get rate-limited. Putting those in the same process as the request handlers would mean one long-running job could starve the thing a customer is actually looking at.

Astro for the marketing site, React for the dashboard

The public site is Astro with Tailwind. The customer dashboard is React 19 with React Router, served from the same static host under a nested path.

That split is deliberate. The marketing site’s job is to be found and to load instantly — it is mostly static content, and shipping a large JavaScript bundle to render text is a tax on the exact metric that matters. Astro produces HTML and ships almost no JavaScript by default.

The dashboard is the opposite. It is an application: stateful, interactive, behind a login, and nobody is finding it through search. React earns its weight there.

One deployment detail that has bitten me more than once: the two are built separately and the dashboard is nested into the marketing site’s output before upload. Forget that step and you ship a perfectly good marketing site with nothing behind the login. It is now written at the top of the deploy notes in language my future self cannot misread.

Claude for the copy, with a cost model attached

Every email is written by a model, and the architecture of how is where most of the engineering went.

The naive version — call the model once per prospect, generate a whole email — works and is unaffordable. At a few hundred sends a day across every client, you are paying for the same paragraphs about the same business over and over.

So a pitch is assembled from two parts. A scaffold is generated once per client and stored: four interchangeable versions of the offer paragraph, the call to action, the signoff, and a short phrase naming the problem that client solves. Then, per prospect, the model writes exactly one thing — the opening line, grounded in something real about that specific business.

A send costs one short model call instead of a whole email. The four rotating variants exist for a second reason that has nothing to do with cost: sending hundreds of byte-identical emails a month is how you teach a mailbox provider that you are bulk. Same substance, genuinely different sentences.

The scaffold is also the point where a human can intervene. It is stored, not regenerated, so a client’s approved wording stays approved. That matters more than it sounds. It is the difference between a system that writes on your behalf and a system that might say anything.

The rest

Resend for transactional and outbound email. Twilio for the voice channel and SMS. Stripe for subscriptions. Google Places for sourcing. Key Vault for secrets, so nothing lives in an environment variable I might paste somewhere.

The struggles, which is where the real lessons are

It worked perfectly for me and silently failed for everyone else

The single most expensive assumption in the codebase was a default. Unattributed data belonged to me, because when I built it, everything belonged to me.

That default survived into multi-tenancy. The result was a system that behaved flawlessly when I tested it as myself, and quietly showed customers a fraction of their own data. Every one of those five bugs I mentioned traced back to it.

There is a line I ended up writing into the project notes, in bold, for whoever reads them next:

If it works when you test it as yourself, you have not tested it.

The generalization is that multi-tenancy is not a feature you add. It is a property every single query either has or does not have, and the ones that do not will not announce themselves. They will just be wrong for somebody who is not you.

The call to action that got clicks instead of clients

This one still stings, because the numbers looked good.

One client’s emails ran a 31% open rate and a 15% click rate. By any normal marketing scorecard that is a strong campaign. The reply rate was 0.3%.

The cause was one sentence in the prompt. The call to action invited a reply and offered a link, on the theory that giving people options is generous.

It is not generous. A link is easier than a reply, so the link wins every time. And a click is worth nothing here: the client cannot act on a click. They can only act on a person who wrote back. We had optimized a funnel into producing the one outcome that could not be converted.

The fix was to delete the option. The call to action is now a single question that a busy person can answer off the top of their head in about five words — not a meeting request, not a quote, not two choices joined by “or”. The link moved to the footer where it belongs.

The wider lesson is that an intermediate metric that cannot be converted is not a leading indicator, it is a distraction. Opens and clicks were measuring the wrong thing confidently.

Deliverability is the actual product

I thought I was building a lead generation tool. I was building an email infrastructure company that happens to write copy.

Since 2025, Google, Yahoo and Microsoft have enforced bulk sender requirements with real teeth: SPF, DKIM and DMARC passing and aligned, spam complaints under 0.3%, bounces under 2%, one-click unsubscribe. The penalty is no longer the spam folder. It is rejection. The mail does not arrive anywhere.

Practically, this shaped the architecture more than any framework choice:

  • Cold outreach never sends from the primary domain. A reputation hit must not take invoices and password resets with it.
  • Volume is a function of identities, not campaigns. One address carries roughly 30 cold sends a day before filtering starts, so more throughput means more addresses, each with its own warm-up clock.
  • Suppression must never fail open. If the opt-out list cannot be read, the correct behavior is to refuse to send. An empty list read as “nobody opted out” means emailing every person who asked you not to. That is one of the few genuinely unrecoverable mistakes available here.

Most of the hard engineering in this platform is not the AI. It is the unglamorous machinery that decides who may be contacted, from which address, how many times, and when to stop.

Decisions tangled up with I/O

Early on, the sending logic chose who to contact, when, from which address, and how many — inline, between a database read, a model call, and an email send.

Which meant the only way to find out what the system would do was to let it do it. Every rehearsal was a live performance. Every bug in the selection logic was discovered by sending something.

Pulling the decisions out into pure functions — given this state, here is the plan — was the highest-leverage refactor in the project. The plan is now computable without a network, which means it is testable, which means I can ask “who would this send to tomorrow, and why” and get an answer without anyone receiving an email.

If I could give one piece of advice to someone building an automation system that touches the outside world, it would be this: separate the thing that decides from the thing that acts. You will be able to test the first one, and you will need to.

The allowance that reset at midnight

A small one, but it illustrates a category.

Free accounts get ten leads a month. The count was measured from the first of the calendar month. Someone signed up at 8pm on 31 August, received their ten by 11:24pm, and at 12:06am was handed another ten — two months of allowance in forty-two minutes — because the calendar had rolled over.

Nothing was broken. Every component did exactly what it said. The bug was in the boundary, which belonged to no component. A rolling thirty-day window fixed it, and now every account gets the same deal measured from the day it signed up.

I have found that a surprising share of bugs in scheduled systems live in boundaries like that: month ends, daylight saving, time zones, the gap between when a job is scheduled and when it actually runs.

What it is now

It sources leads in a client’s niche and market, writes each prospect their own email, sends it from a warmed address belonging to that client, follows up on a schedule, calls the ones worth calling, detects and routes replies, and stops contacting anyone who asks to be left alone. Clients see their own dashboard. I see all of it.

It runs my own outreach too. The platform finds my web development clients, which means I am my own most demanding customer and every weakness shows up on my calendar first.

What I would do differently

Put the relational data in a relational database from the start. Not the leads — the ownership. Tenants, campaigns, memberships. The things that must be true.

Design for more than one customer on day one, or accept a rewrite. Not the whole product. Just the data access. Every read either scopes correctly or it does not, and retrofitting that is archaeology.

Measure the thing you can act on. Opens and clicks were a comfortable story. Replies were the truth, and I had the data to see the gap months before I looked at it properly.

Write down why, next to the code. The most valuable thing in the repository is not any function. It is the comments explaining what a piece of code is defending against, with the number attached. This CTA ran 0.3% replies. This default hid 113 of 282 sends. Six months later, that is the difference between a rule I respect and a rule I refactor away because it looked redundant.

The part that surprised me

I set out to solve my own lead problem. I did solve it. But the more interesting outcome is that building the machine taught me more about how businesses actually get customers than fifteen years of building websites for them had.

You learn quickly when your own calendar is the test suite. A subject line that does not work is not a hypothetical. It is a quiet week.

And it turns out the developers and small businesses I was trying to reach have exactly the same problem I did. Good at the work. Invisible to the people who need it. That is not a marketing problem or an AI problem. It is a distribution problem, and distribution is a system you can build.


The platform is The Outreach Platform. It is built and run in Green Bay, Wisconsin, and it prospects nationwide.