I run a newsletter on Beehiiv. About six months ago I noticed my open rates were sliding while my subscriber count was growing. The two numbers were moving in opposite directions and I could not figure out why until I pulled the raw data.
Nearly 22% of my list had never opened a single email. Not one. They had been on the list for months, counted in my total, and pushing me toward a higher Beehiiv billing tier. I was paying $99/month when the real, engaged portion of my list put me firmly in the $49/month tier.
That is $600/year for subscribers who will never read, click, or buy anything.
I looked for a tool to fix it. Nothing existed that handled Beehiiv specifically, handled Apple Mail Privacy Protection false positives intelligently, or stored API keys with any serious encryption. So I built it. It is called ListTrim and it is live.
The Problem: Ghost Subscribers Are a Silent Margin Bleed
Beehiiv charges on total subscriber count, not engagement. The pricing cliff looks like this:
| Plan | Subscribers | Monthly |
|---|---|---|
| Launch | Up to 10,000 | $49 |
| Grow | Up to 25,000 | $99 |
| Scale | Up to 50,000 | $199 |
| Max | 50,000+ | $399 |
A newsletter with 10,500 subscribers, 2,000 of which are ghosts, pays $99/month. Remove the ghosts and you are at 8,500 subscribers. That is the $49/month tier. Fifty dollars saved every single month.
Beyond the billing tier, each ghost subscriber carries a silent infrastructure risk value of $0.006/subscriber/month. They occupy database rows, inflate every API response your integrations pull, and skew every analytics dashboard you look at. Across a newsletter agency running 15 publications, that drag compounds into a real number fast.
The deliverability cost is worse. Gmail and Outlook score your sender reputation on engagement rate. A list with 22% ghosts has its open rate artificially suppressed because the denominator is inflated with accounts that will never fire an open event. That pulls you from Primary inbox toward Promotions, and eventually toward Spam, without you ever knowing why.
The Apple Mail Privacy Protection Problem
Before I explain the architecture, this is worth addressing because most naive list-cleaning tools get it wrong.
Since iOS 15, Apple Mail pre-fetches emails and fires open tracking pixels automatically, regardless of whether the recipient actually read anything. A filter that simply removes everyone with a 0% open rate will incorrectly flag real Apple Mail readers as ghosts.
ListTrim uses multi-signal qualification instead. An account is only flagged as a ghost when all three of the following are true:
- Subscriber age is over 60 days
- The account has received more than 2 emails
- Lifetime open rate is exactly 0%
A genuine Apple Mail user will fire at least one pixel at some point across 60 days and multiple sends. A true ghost, whether a bot signup, a dead inbox, or a throwaway address, never will. This keeps false positive rates near zero.
The Stack and Why I Made Each Choice
Frontend: Next.js + React + Tailwind CSS on Vercel
Next.js gives me server-side rendering for the marketing pages and a clean API route layer for the backend. Tailwind keeps the UI fast to build and maintain solo. Vercel handles deployment with zero configuration.
Database: Supabase (PostgreSQL) with Row-Level Security
I chose Supabase over a raw Postgres instance specifically for its Row-Level Security policies. RLS means each user's data is scoped at the database layer, not just the application layer. If a query bug ever bypasses application-level auth checks, the database itself will not return another user's records. For a product that stores encrypted API keys, that second layer of isolation is not optional.
Background Orchestration: Inngest
This is the part most indie SaaS products skip and then regret.
The auto-clean feature requires monthly background jobs that run per-user, paginate through potentially 50,000 subscribers, and stay inside Beehiiv's API rate limits. A naive cron job that loops over all users simultaneously will hit rate limits within minutes on a multi-tenant system.
Inngest solves this with step-function orchestration. Each auto-clean job breaks down into discrete steps:
- Step 1: Decrypt the user's Beehiiv API key from Supabase
- Step 2: Fetch page 1 of subscribers from the Beehiiv API
- Step 3: Pause 500ms to respect rate limit headers
- Step 4: Loop until the full list is compiled
If any step fails due to a dropped API call or network spike, Inngest catches the exact failure point and retries with automatic exponential backoff. A 50,000 subscriber list never crashes midway through a clean. Results write back to Supabase when the job completes and the user gets an email summary.
Payments: Paddle as Merchant of Record
I chose Paddle over Stripe for one specific reason: tax compliance.
Stripe processes payments. It does not handle tax obligations. That means registering in every jurisdiction where you have customers, calculating the correct VAT or GST rate, collecting it, filing returns, and remitting it. For a solo product with users in the EU, UK, Australia, and Canada, that overhead is not manageable.
Paddle is the Merchant of Record for every transaction. Paddle owns the tax obligation in every jurisdiction, calculates the correct rate at checkout, files the returns, and remits the tax. The product receives revenue with no compliance overhead.
The Part Everyone Asks About: How API Keys Are Stored
The most common objection when I show people ListTrim is some version of "I am not pasting my Beehiiv API key into a random tool." That is a reasonable objection and it deserves a real answer.
When a user submits their API key, it is encrypted using AES-256-GCM via Node's native crypto module before it ever touches the database. The encrypted blob is what gets written to Supabase. The plaintext key exists only in server memory for the milliseconds it takes to encrypt.
The full implementation:
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32-byte key from env
export function encryptApiKey(plaintext: string): string {
const iv = randomBytes(16); // unique IV generated per encryption
const cipher = createCipheriv(ALGORITHM, KEY, iv);
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final()
]);
const authTag = cipher.getAuthTag(); // GCM authentication tag
// Concatenate iv + authTag + ciphertext into a single hex string for storage
return Buffer.concat([iv, authTag, encrypted]).toString('hex');
}
export function decryptApiKey(stored: string): string {
const data = Buffer.from(stored, 'hex');
const iv = data.subarray(0, 16);
const authTag = data.subarray(16, 32);
const encrypted = data.subarray(32);
const decipher = createDecipheriv(ALGORITHM, KEY, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([
decipher.update(encrypted),
decipher.final()
]).toString('utf8');
}
Three things that matter here:
The IV is unique per encryption. randomBytes(16) runs every time. Reusing an IV with AES-GCM is a well-documented catastrophic failure mode that can expose the encryption key. This is not a best practice, it is a hard requirement.
The GCM auth tag provides tamper detection. If anyone modifies the ciphertext in the database, decipher.final() throws before any plaintext is returned. Even if an attacker gains write access to Supabase, they cannot substitute a malicious ciphertext and extract a decrypted key.
The encryption key never touches the codebase or database. It lives in an environment variable on the server runtime only. The database stores a hex string that is useless without the key, and the key is useless without the database row.
What ListTrim never stores: subscriber email addresses, any subscriber personal data, or anything beyond the encrypted API key, publication ID, and anonymized job stats.
Business Model
The pricing is deliberately asymmetric: scanning is free, cleaning costs money.
A publisher can connect their Beehiiv account, run a full scan, and see their exact ghost count and monthly overpayment without a credit card. Most users are surprised by the number. Once you see that you are paying $50/month for subscribers who have never opened anything, the $19/month Starter plan is an obvious trade.
Paid tiers unlock one-click removal, monthly auto-clean scheduling, CSV export before deletion, and re-engagement campaigns. The re-engagement flow lets users create a Beehiiv segment of ghost subscribers and send three emails from their own account before any deletion runs. Anyone who opens gets saved. Anyone who does not gets flagged for removal when the user confirms at day 14.
Try It
The free scan is at listtrim.vercel.app. It takes two minutes, requires only a Beehiiv API key and publication ID, and shows your ghost count and savings estimate before you spend anything.
For a deeper look at the deliverability mechanics and Inngest step-function design, the full technical write-up is on the ListTrim blog.
If you have questions about the Inngest setup, the Paddle integration, or the RLS policies in Supabase, drop them in the comments. I read everything.
Top comments (0)