The question

A clinic that buys ads sees leads and bookings. The number that pays the bills is different: appointments that actually happen. If one channel books cheaply but its patients do not show up, the cheapest-looking channel is not the cheapest.

This project traces that path end to end: ad spend, lead, booking, occurred appointment. The problem is real; the data is synthetic. No real patient, company or ad-platform data is used, and every channel result below comes from assumptions I built into the data generator. It shows that the pipeline can expose a difference like this, not that any real channel behaves this way.

What I built

LayerWhat it does
Synthetic funnelFive channels over six months, fixed random seed, about 2,800 leads and 1,257 bookings, so every result is repeatable
Attribution SQLViews that join spend to leads to bookings to occurred appointments, with spend aggregated separately so joins cannot inflate it
Intake API (Flask)Validates each lead and writes it. Accepts only a short allowlist of fields, so contact and health details cannot enter
n8n workflowWebhook, clean, call the API, then route success (insurance or self-pay follow-up) or rejection (logged with a reason)
Reporting copyPostgreSQL in Docker, refreshed by a script that checks row counts and total spend before it commits
DashboardMetabase, four charts
Staff knowledge assistantQuotes approved policy text only, with code-level refusals for patient identifiers, clinical questions and emergencies
Weekly funnel reportA local language model writes the sentences; code fills in and verifies every number, with a fixed-template fallback, an API endpoint and a weekly n8n schedule
Backlog and specsTen backlog items and four engineering specs for what I did not build
Tests124 automated tests: 8 on the data, 13 on the API, 50 on the assistant, 53 on the weekly report and its endpoint
Metabase dashboard with four charts: cost per booking versus per occurred appointment by paid channel, show rate by channel, occurred appointments per month by channel, and rejected leads by reason

The four-chart dashboard, built on the Postgres reporting copy. All data is synthetic.

n8n workflow: webhook, field-cleaning code node, HTTP request to the lead API, an IF node that splits success from rejection, a follow-up routing step, and a rejected-lead record sent to a log endpoint

The n8n intake workflow: receive, clean, call the API, then route success or rejection.

The result (simulated)

ChannelShow rateCost per bookingCost per occurred appointment
Meta49%$41.22$87.13
Google82%$81.99$106.26
Microsoft77%$80.66$106.82

Meta looks about half the price per booking. But only 49% of its booked visits happen, so per occurred appointment the gap shrinks from roughly 2× to about 1.2×. That is why the dashboard leads with cost per occurred appointment, not cost per booking. I chose these rates when generating the data to see whether the pipeline would expose the difference. It did.

Decisions that mattered

Aggregate spend separately, then join. Joining spend straight onto leads repeats each spend row once per lead and inflates cost. A test now guards this, because it is the classic way a dashboard quietly lies.

A strict field allowlist. The system only needs attribution, so the API refuses contact and health fields instead of silently dropping them. This is minimum-necessary thinking, not a compliance claim.

n8n orchestrates; a small API owns the write. n8n has no SQLite node, and validation that matters belongs in code I can test.

A reporting copy in Postgres. I could not verify official SQLite support in Metabase, so I did not build on it. The API keeps writing to SQLite; a sync script refreshes Postgres.

Quote, do not write. The knowledge assistant returns a passage copied word for word from a current approved document, with its ID and date. It cannot invent a policy.

What went wrong

What happenedCauseLesson
Three tests failed right after I saved filesThe SQL views had never been createdDependencies between steps must be written down, not implied
n8n said the webhook was not registeredTest mode listens for exactly one callClick Listen before every test
The API rejected a lead for an unexpected fieldMy own node sent an extra fieldThe strict allowlist did its job
Metabase would not accept the database loginMost likely browser autofill, not confirmedSet a known password and use a private window
My own test question got no answer from the assistantThe policy never used the word I searched forWrite documents in the words staff actually use
The weekly report's checker approved a wrong claim and a tie reported as one winner; the model wrote its own direction wordsGaps in my own checker: an unlisted ranking word, ties decided on unrounded values, a direction placeholder merely nearbyFixed each one and tested the exact sentence that slipped through
A lead was lost when the API was stoppedDescribed belowBecame an engineering spec

The gap in my own design

With the HTTP node's “Never Error” setting on, a 422 from the API is routed to a rejected branch. With the API stopped there is no response at all: the node failed with a connection error and the lead existed only in n8n's run history. I also noticed that the rejected-lead log calls the same API, so it fails too, and that a 422 (do not retry) and a 500 (retry) take the same path. I then tried n8n's Retry On Fail with the API stopped: no retries were observed and the lead was still lost. A real fix needs a durable inbox and classified retries, and retries are only safe once duplicate leads are prevented. All of it is written up as engineering specs.

The weekly report: the model writes words, code owns the numbers

The model gets named placeholders such as {total.spend} and writes the sentences around them. Code computes every figure from the database, fills the placeholders in, and rejects text that breaks the rules: a digit written outside a placeholder, an unknown placeholder, causal wording, a direction or ranking word without the matching placeholder, a channel name that does not match its data. After three rejected tries the report falls back to a fixed template, and an unverified summary is never published.

Measured on a local 8B model: 7 of 10 summaries passed on the first try with the final checker and prompt (3 of 10 before one worked example was added to the prompt), and a separate run of 5 complete reports passed on the first attempt 5 times. These are small samples on one model. The checks guarantee the numbers and guard the wording; they cannot prove a sentence is a fair reading of the numbers, which is why reading the live output mattered: it found three gaps my tests had not.

What I did not build

What I would do next

Build duplicate-lead protection, then the outage handling that depends on it. Deliver the weekly report (email or chat) and re-measure its pass rate on more runs and a second model. Fix a tension I found in the assistant: its privacy rule refuses a numeric case ID, although the privacy policy tells staff to use case IDs.

The full source, tests, backlog and specs are in the GitHub repository. The story of the mistakes is in Engineering Log 10.