In August a director at a large company received a cold pitch from one of our sending profiles. He answered in one line: he was already a customer of the company we were selling for. The pitch went out from the profile of an employee at that same company, which is the part that stings. The exclusion list for that account held around two hundred names at the time, and his employer was missing from every one of them.
That reply changed how we build these lists. Below is what we now put on one, where each name comes from, where the check has to live so it holds, and the two ways a list that looks complete still lets people through. The numbers come from our own database: 543 companies currently listed across four accounts, 707 candidates rejected between 29 July and 15 September 2026, and 6,095 sourced people behind them.
The list comes from an export, and it carries a date
The list that failed was assembled from memory. Someone read through the account, recalled the obvious names, and typed them. It held about two hundred companies and it felt thorough, which is the trap.
Here is what the same account looks like when the list is rebuilt from documents. The first load came from a letter the client sent on 29 July naming current customers and active global prospects: 126 companies, loaded 5 August. Over the next 22 days that list grew to 154 through seven separate additions. Twenty five of those additions arrived because the client named them in writing, most of them in one weekly report on 24 August. One was caught sitting in the send queue before anything left. Two were added after a real human being had already received a cold message from us.
Roughly nine in ten of the names missing from the first version came from the client saying them out loud later. They were knowable on day one. They were absent because the first version was built by recall.
So the rule we run now is plain. An exclusion list is built from a file the client exports out of their own systems, it records who sent it and when, and it gets refreshed every time a new batch is prepared. A row with no source line on it is somebody's recollection, and it will be missing the same names next month.
Four places the names come from
The customer list is the obvious source and the smallest one. Across our four accounts with lists, the entries break into four families, and each answers a different question.
Current customers. The account you would be embarrassed to pitch. This is the export, and it is the one every seller expects.
Live pipeline. Companies the client is already in conversation with, including deals that are dormant and might revive. One client flagged two of these by hand in a single email: one already a customer, one an open opportunity, neither to be contacted for that reason above all. Pipeline moves faster than a customer list, which is why the refresh cadence matters more here than anywhere else.
The founder's own conversations. This one is easy to miss and cheap to collect. One founder exported his LinkedIn connections and message history, and 386 companies from his live personal threads went onto the list in a single query. He had been asking for a do-not-touch list for weeks. The data to build it was sitting in an export he could produce in ten minutes. That same export has a second use: counting how many of his connections actually match the buyer profile, which is a separate piece of honesty worth having before you promise a client that their network is a channel.
People who ask. In September a co-founder wrote back to one of our sequences asking to be taken out of the funnel. His company went on the list that day with his sentence recorded as the source. Under GDPR a person objecting to direct marketing has to be honoured, so this family carries a legal obligation, and it is the one family that arrives one name at a time, forever.
Four families, one table, every row carrying the sentence and the date that put it there. When a client asks later why a company is blocked, the answer is a quotation instead of a recollection.
One door, or the rule leaks
An exclusion list that lives in a spreadsheet protects nobody. The interesting question is where the comparison runs.
We found this out by getting it wrong. The filter used to sit inside the script that tops up a sender's queue. It worked. Then a different script queued a batch directly, went around that filter, and put three of a client's current customers into a live sending queue. The rule was real, it was written down, and there were five different doors into the send queue: the top-up job, the batch loader, the follow-up loop, the reply loop, and the interface we expose to clients who run their own model against our stack.
So the check moved down one level, into the database itself, as a trigger on the send queue table. Any row, from any door, for any kind of touch, gets compared against that client's exclusions before it can enter a live status. Writing a forbidden company into the queue now raises a database error, which means the rule holds even for a query typed by hand at two in the morning.
The same trigger carries a second rule that belongs in the same family: one person per company per client, for cold invitations. A second invitation into a company that already has one reads as a mailshot to the person receiving it, whatever the copy says. The measured effect is clean. In the three weeks before the check moved into the database, nine companies received invitations to two different people. In the 27 days since, across 2,406 companies with at least one invitation, that number is zero.
A rule that depends on attention holds most of the time, and those nine companies are what the gaps look like from the receiving end. A rule the table enforces holds on every row, including the ones written by a path you add next month. That is the idea worth copying from this piece, and it is the same guarantee clients get when they drive the stack themselves on the MCP tier.
Matching company names is where it quietly fails
Now the part that gets less attention than it deserves. You have the list. You have the check in the right place. The comparison itself is still a string match between two company names, typed by different people, at different times, with different endings on them.
Normalise first: lowercase, strip the legal suffixes and the punctuation, so that a listed name catches the same company written three ways. Then choose how strict to be, and understand the cost of each choice.
Strict equality on the normalised name is what our trigger uses. On one account it currently matches 22 people in our database. Loosening it to a substring comparison in both directions raises that to 41. The extra nineteen look like a win until you read them. A five letter company name on the list matched three entirely unrelated employers that happen to contain those five letters as a fragment. On another account, a single listed company appeared to match 1,727 people, which is most of that pool.
That second number has a plain cause, and it is the one to watch for. A substring comparison in the reverse direction treats an empty employer field as a fragment of everything. One list entry, one empty field, and the check claims a match against the entire database. Strict equality on that same data matches zero, which is the honest answer.
Our working compromise: strict equality in the enforcing check, generous substring matching only in the report a human reads before a batch goes out, and name variants added to the list by hand as they turn up. The variants are cheap. One client's list carries a subsidiary and its parent as separate rows for exactly this reason.
What to build first
One hole sits outside the list entirely, and it is bigger than the list. The check compares employers. A person whose employer field is empty cannot be compared to anything, so the check passes them silently.
In one of our own sending pools, 1,726 of 2,818 people carry no company name at all, because they were collected from engagement with posts rather than from a company search. Ninety nine of them have already received a touch from us. Every one of those touches went out with the exclusion check returning a clean pass, because there was nothing to compare. The fix is upstream of the list: resolve the employer at enrichment time, using the outbound data sources you already pay for, and hold back anyone whose employer stays unknown when the account has a list worth honouring.
For scale, here is what the whole apparatus rejects. Of 707 candidates we recorded and dropped since the end of July, 50 were dropped as off limits for the client, 35 as competitors, and 84 as already touched or duplicated. A further 527 people sit in the database marked excluded rather than deleted, because a name you rejected once is a name you want to recognise the next time it appears.
If you are starting from nothing this week, do it in this order. Ask your client for a customer export and an open pipeline export, both as files, both dated. Ask the founder for their LinkedIn connections export and pull the companies they are already talking to. Put the result in the same database your sending queue lives in, with a source line on every row. Move the comparison into the queue itself so every path is covered, in front of each of the five steps of outbound. Then go look at how many of your leads have an empty employer field, because that number is your actual exposure, and it is usually the one nobody has measured.
Where the check lives, and who owns the list, changes with how much of the work you run yourself. How it works shows the pipeline the check sits in, and our service levels describe the split we use.
Questions buyers ask
What should go on a B2B outbound exclusion list?
Four families of names: current customers, live pipeline including dormant deals, companies from the founder's own conversations, and people who asked to be removed. Every row should carry the sentence and the date that put it there. Pipeline moves fastest, so it needs the most frequent refresh.
How do I build a do-not-contact list for cold outreach?
Build it from files, not from memory: a customer export and an open pipeline export, both dated. Add the founder's LinkedIn connections export, which in one case put 386 companies from live threads on the list in a single query. Refresh the list every time a new batch is prepared.
Where should the exclusion check run in an outbound system?
In the database, as a check on the send queue itself, so every path into the queue is covered. When we moved the check there, companies receiving invitations to two different people went from nine in three weeks to zero in the next 27 days, across 2,406 companies.
Why do contacts on my exclusion list still receive outreach?
Two common causes. Company names are typed differently in different systems, so normalise them and add variants by hand. And a person with an empty employer field passes the check silently: in one pool, 1,726 of 2,818 people had no company name, so resolve the employer at enrichment time.