Skip to main content
Contact us
Legacy admin dashboard and e-commerce platform

Project Story

An automated monthly report emailed to hundreds of funeral brands

Every month MarketIQ works out how each enrolled funeral brand's locations rank against nearby competitors and emails it a report with a link to the full spreadsheet. We built it for Legacy.com on Bun, Hono, Prisma and HubSpot, with a Postgres lock, resumable sends and an audit log. It went live in October 2026.

Client
Legacy.com
Industry
Funeral services
When
2026
Status
Live since October 2026
  • Bun
  • Hono
  • Prisma
  • PostgreSQL
  • HubSpot
  • XLSX reports

A report funeral brands used to have to go find

Legacy.com hosts obituaries for more than 70% of deaths in the United States, which means it can see how busy every funeral home in a town is compared with the one down the road. MarketIQ turns that into a monthly report for the companies enrolled in it: funeral home brands, and consolidators (companies that own several brands).

The first version lived as a tab inside the LegacyPro dashboard, so a funeral home only saw it if someone logged in and clicked over. Legacy’s own requirements doc for 2.0 describes low engagement and accuracy concerns with that version, and it puts one rule above the rest, correctness over timeliness. A late report is fixable. A wrong number sent to a customer, or a report sent to the wrong customer, isn’t.

So 2.0 sends it instead. Each enrolled brand or consolidator gets one HubSpot email with:

  • three headline cards (its obituary count, its market share and its rank, each against last month)
  • a top five table that ranks its homes and their competitors together by obituary count, with its own homes highlighted
  • its market share over time
  • a link to a full XLSX spreadsheet for every location it owns

The email goes out through HubSpot’s single-send transactional API, the kind of email sent to one recipient because of something specific to them, with a BCC to an audit mailbox so Legacy keeps a copy of every report that went out.

A made-up funeral home's monthly email and spreadsheet, showing its rank, market share and change from last month against made-up competitors.

From obituary counts to a market share

Legacy’s data has three levels. A consolidator may own brands and a brand owns funeral homes. Each funeral home (a “rooftop” in the industry) is where the numbers live. Reporting happens per rooftop and rolls up to whoever is enrolled.

Nothing in MarketIQ duplicates Legacy’s data. It reads two of Legacy’s existing REST APIs. The organizations API gives the hierarchy, the enrollment flag (an obitdata_type field in each organization’s metadata) and the competitor list Legacy’s team keeps for each funeral home. The funeral-home-deaths API takes a comma-separated list of funeral home IDs and a date-of-death range and returns a count for each. MarketIQ asks it twice per account, once for the report month and once for the month before.

The math is deliberately plain. A home’s market share is its count divided by the total for its own homes plus their nearby competitors, so it’s a local share. Month-over-month change is stored both as a count and as a percent, and the percent is left empty when last month was zero, because there’s no honest percentage for growth from nothing.

Most of the real work was edge cases Legacy’s team found while testing on staging in May and June:

  • A brand’s own homes also showed up in each other’s competitor lists, so their obituaries were counted twice and every share came out too low. Client homes are now excluded from competitor sets.
  • Competitors were being looked up by the wrong kind of ID, which produced rows named “unknown”. Lookups now use funeral home IDs, and anything still unresolved goes to Legacy’s internal team instead of into a customer’s spreadsheet.
  • A recipient field holding several comma-separated addresses failed while reporting success. Each address now gets its own send, which is why 379 accounts came to about 480 emails.
  • In September Legacy’s team found the deaths API returns records that aren’t deduplicated unless you ask, which inflated some counts. The call now asks.

What happens on send day

MarketIQ is one Bun process running two things: a Hono HTTP API, and a poll loop that wakes every five minutes and asks whether it’s time to send. There’s no cron, no queue and no Redis. Postgres holds all the state, through Prisma.

Send day is the 10th of the month, rolled forward past weekends and US federal holidays (the calculator applies the federal “observed” rule, so a holiday on a Saturday blocks the Friday before it). The send starts after 10:00 a.m. Central.

When the loop decides it’s time, it creates one send_runs row for the report month, then tries to take a lock on it with a single atomic UPDATE ... WHERE locked_by IS NULL statement. If a second copy of the service is running, only one of them wins. A lock older than 30 minutes counts as abandoned, so a crashed run can be picked up again.

Then it fetches the current list of enrolled brands and consolidators from Legacy’s API and, for each one, upserts a send_items row keyed on the run, the account type and the account ID. Each item moves from pending to processing to sent or failed. For each account the service resolves its homes and their competitors, pulls the two months of counts, computes the metrics, stores the report as JSON with a random download token, sends the email and writes an audit log entry.

Each spreadsheet is built on request when someone opens the link, from the report JSON saved that month.

One account at a time, on purpose

The first 2.0 send went out on October 15, 2026, to 379 enrolled brands and consolidators and about 480 recipient emails. It took about five and a half hours.

Honestly, that’s slow, and we chose it. The emails themselves go out fast. The time goes into fetching death counts: two API calls per account, one account after another, about 760 calls in all. Network errors and 5xx responses are retried three times with exponential backoff starting at half a second. A 4xx isn’t retried, since asking again won’t change the answer.

If any count for any of an account’s homes still fails, that account’s email is blocked and its item is marked failed, so nobody gets a report with a hole in it. The other accounts carry on. Because sent items are skipped when a run restarts, a run that stops halfway resumes at the next unsent account without sending anyone a second copy. Scheduled sends, manual sends and test sends all go through that same per-account path.

That said, the dry run already fetches everything in bulk up front, and the send could do the same and finish in minutes. Legacy wants to try that for the November run, with the one at a time approach kept as the fallback.

A send run working through accounts one at a time, then stopping partway and resuming at the next unsent account so nobody gets a second copy.

The admin side, and how it's tested

Legacy runs MarketIQ from its own internal admin, a Django app its team already uses to manage funeral homes. Its buttons call MarketIQ’s API, authenticated with keys kept in an api_keys table:

  • POST /admin/trigger-dry-run starts a dry run as a background task. It caches Legacy’s organization tree, fetches every count it needs in one pass and returns what the month would send, with warnings for accounts missing homes, competitors or an email address. It can also email Legacy’s team a summary.
  • POST /admin/test-send sends one account’s report to an address Legacy picks.
  • POST /admin/manual-send resends one account’s real report.
  • GET /admin/send-runs and GET /admin/audit-log show what went out, what failed and why, filtered by account, action or date.

The API is documented in an OpenAPI file rendered with Redoc, which Legacy asked for as a deliverable. Scheduled emails sit behind a SENDS_ENABLED flag, so the testing deployment runs against live data and never sends the monthly email. During a live run the test buttons return an error on purpose, because a test would take the lock the run is holding.

The repo has about 300 tests in Bun’s test runner, covering the business-day calendar, the metrics, retries, the lock and every admin endpoint, and GitHub Actions runs them with Biome linting and a Prisma schema check on every pull request. It runs on AWS ECS Fargate in Legacy’s account, with logs in CloudWatch.

How the first run went

The launch slid twice. July became August so Legacy’s team could clean up the brand and consolidator setup at their end, and the first real send went out in October.

Akash Altman at Legacy called it “a huge milestone that is the official release of MarketIQ 2.0.” He spotted only one failure in the whole run. Thirteen accounts didn’t get a report, because there was no email address on file for them yet.

We’ve also proposed alerts that flag a month where an account’s numbers jump oddly, so Legacy’s team doesn’t have to check every report by hand. That part isn’t built yet. The next send is November 12.

Related case study

Legacy

Questions about this project

MarketIQ 2.0 is a monthly competitive report that Legacy.com sends to the funeral home brands and consolidators enrolled in it. HQ built it in 2026. For each enrolled account it compares that account's funeral homes against a competitor set Legacy defines for each home, using obituary-derived death counts, works out each location's rank, market share and month-over-month change, and sends a branded HubSpot email with headline numbers, a top five table and a link to a full XLSX spreadsheet. It replaced the original MarketIQ tab inside Legacy's LegacyPro dashboard.

HQ, a software design and development agency in Ogden, Utah, built MarketIQ 2.0 for Legacy.com, a monthly report pipeline whose first run in October 2026 went to 379 enrolled funeral brands and consolidators and about 480 recipient emails. HQ built the scheduling, the metrics, the spreadsheet generation and the HubSpot email delivery, plus an admin API for dry runs, test sends and manual sends with an audit log of every action.

The first MarketIQ 2.0 send took about five and a half hours because it fetches death-count data for one account at a time, two API calls per account, roughly 760 calls in total. HQ built it that way so a failure only affects one account and an interrupted run can resume without re-sending anyone's email. Sending the emails themselves is fast. HQ has proposed fetching the data in bulk up front, the way the dry run already does, which would bring the send down to minutes.

In MarketIQ 2.0, each monthly run is one row in a Postgres send_runs table and each account is a send_items row with a unique key on the run and the account. A run takes a lock with an atomic update, and a lock older than 30 minutes is treated as abandoned. When a run restarts, accounts already marked sent are skipped, so it resumes at the next unsent account and nobody gets a duplicate email. Scheduled sends, manual sends and test sends all use that one code path.

MarketIQ runs its HTTP API and a poll loop in one Bun process. Every five minutes the loop checks whether today is send day, which is the 10th of the month rolled forward past weekends and US federal holidays, and whether it is after 10:00 a.m. Central. Postgres acts as both the state store and the lock, so HQ didn't need a cron service, a job queue or Redis.

MarketIQ calculates a funeral home's market share as its obituary-derived death count for the month divided by the total for the client's homes plus the competitors Legacy has assigned to them, so it is a local share. Client homes are excluded from competitor lists so they are never counted twice. Month-over-month change is reported as a count and a percent, and the percent is left blank when the prior month was zero.

HQ built MarketIQ 2.0 as a TypeScript service running on Bun, with Hono for the HTTP API and PostgreSQL through the Prisma ORM. Reports go out through HubSpot's single-send transactional email API and link to an XLSX file generated on request. It is tested with about 300 Bun tests run in GitHub Actions, documented with OpenAPI and Redoc, and runs on AWS ECS Fargate in Legacy.com's account.

Let's build together

Whether you're looking to design, build, update, or refresh an existing app or website, we can help. Even if we're not a good fit, we will help point you to somebody who might be.

Contact us