Sharper Targeting

Outreach Ledger

One Postgres logbook of every outreach touch. Built at Paioneers.

Outreach Ledger — case study architecture diagram

Situation

Outreach at Paioneers ran across a handful of separate tools: cold email through Instantly, LinkedIn touches, and whatever else a given campaign needed. Each tool kept its own record of who it had contacted, and nobody had one place to check whether a lead had already been touched or should be left alone.

Challenge

A lead who opted out or replied badly in one tool could still end up in a fresh list built somewhere else, because the only record of that reply lived inside the tool that received it. The fix needed to work as a system of record other tools could query before sending, not a spreadsheet someone updates by hand.

The Play

Build one append-only ledger in Postgres (Neon), fed by webhooks out of the outreach stack plus a poller as a backup ingestion path, with every event fingerprinted so duplicates collapse. Derive suppression and reporting from that single ledger instead of letting each tool keep its own truth.

Workflow

Outbound at Paioneers ran across a handful of tools at once: cold email through Instantly, LinkedIn touches, and whatever else a given campaign called for. Each tool kept its own record of who it had touched. Nobody had one place to ask a simple question: has this person already been contacted, and should we leave them alone.

That gap gets expensive fast. A lead who replied "not interested" in one tool could still land in a fresh list built somewhere else, because the only record of that reply lived inside the tool that received it. The usual fix is a spreadsheet someone maintains by hand. That works for about a week.

I started with a small MVP: a Postgres database on Neon with a handful of tables, contacts, campaigns, events and replies, and a webhook that caught activity out of Instantly and wrote it into the log. The idea was simple: every touch, everywhere, in one append-only ledger. Suppression and reporting get derived from that ledger instead of living as their own source of truth somewhere else.

The first real events exposed the gap between the plan and the actual data. I'd built the webhook assuming the source would hand over a clean array of labels per event. It doesn't. It fires one discrete event per status change, and the payload shape didn't match what I'd wired up.

Once that was sorted, I rebuilt the schema to actually hold up as a system of record rather than a side log. Events got a unique fingerprint, so the same webhook firing twice, or a backfill overlapping live traffic, collapses into one row instead of two. I added a poller as a second ingestion path, so a missed webhook doesn't mean a missing event, and it can safely re-run without duplicating anything already caught live. On top of the raw events I added two views: one that reconstructs a contact's full timeline across tools, and one that rolls a campaign up into a single picture.

The last piece was making the ledger useful to the tools that actually send outreach, not just query-able after the fact. A suppression check now sits in front of every new list: before a lead goes into a campaign, whatever tool is building that list can ask the ledger, in one call, whether that email or domain has already opted out, bounced, or said no. What used to mean opening three tools and cross-referencing by hand is now one lookup.

Every outreach tool writes to one ledger; one query answers who to leave alone.

It's live now and running on real traffic, catching events as they happen and backfilled against anything the webhook missed. The question "have we already touched this person, and should we skip them" has exactly one place to go for an answer, instead of a different answer depending on which tool you happened to check.

Highlights

  • One ledger, many tools

    Every touch across the outreach stack lands in the same append-only log instead of scattered per-tool records.

  • Suppression became one query

    Checking whether a lead should be skipped is now a single lookup instead of cross-checking tools by hand.

  • The webhook payload wasn't what I assumed

    The source system fires discrete per-status events instead of a labels array, so routing had to be rebuilt around event type.

Loop je tegen iets soortgelijks aan?

Als dit lijkt op wat jij probeert op te lossen, praat ik er graag over mee. Geen verkooppraatje, gewoon even samen kijken of het te bouwen valt.

Laten we praten