If an agent or an n8n workflow feeds a list into a pay-per-found lookup, most of what comes back is empty, and the harness decides what each empty costs: a real lead thrown away, a retry storm, or a row that was never actually checked. This post is the 30-day request log of the b2bsearch lookup Actors on Apify, read from the server side, and the routing table that falls out of it. The Actors in question take one identifier and return one person: a profile URL in the LinkedIn Email Finder and the LinkedIn Phone Number Finder, an email in Email to LinkedIn and Reverse Email Lookup, a name plus company domain in the Profile Finder by Name. They charge per row returned, so an empty answer is free, and the log is mostly empty answers.
Three threads on dev.to this week circled the same question from other directions. Mohamed Zaghloul, on invoice extraction: fail open to a human, never closed to the ledger. Dimitris Kyrkos: your agent's retry bug is a distributed-systems problem with a model on top. And an agent whose daily report is now written by code from the facts, no guessing, because the model once described a comment it had never fetched. All three are about what a program should do when the answer is not there. Here is what "not there" looks like in numbers.
310,550 lookups, 81.5 % of them empty
Thirty days of buyer traffic across the lookup Actors, our own test runs excluded:
| What came back | Requests | Share | Charged |
|---|---|---|---|
| Found, row returned | 57,385 | 18.5 % | yes |
REQUIREMENT_UNMET: the person exists, the number or email you required does not |
106,962 | 34.4 % | no |
NOT_FOUND: no such person in the index |
94,927 | 30.6 % | no |
RATE_LIMITED, HTTP 429 |
37,596 | 12.1 % | no |
PROFILE_REMOVED: the profile existed, the network took it down |
13,432 | 4.3 % | no |
SERVICE_UNAVAILABLE, HTTP 503 |
227 | 0.07 % | no |
BAD_REQUEST |
21 | 0.01 % | no |
Four of every five requests returned no row. That is normal for pay-per-found data, and it is the whole point of the pricing: you can ask about a long list and pay for the short one. But it means the empty answers are where the agent spends most of its time, and the harness that treats them all the same is making a mistake on four out of five requests.
The five empties are not the same thing, and the log shows it in the latency column:
| Outcome | p50 | p95 |
|---|---|---|
RATE_LIMITED |
0 ms | 0 ms |
NOT_FOUND |
52 ms | 936 ms |
PROFILE_REMOVED |
455 ms | 1,012 ms |
| Found | 493 ms | 2,209 ms |
REQUIREMENT_UNMET |
805 ms | 1,859 ms |
SERVICE_UNAVAILABLE |
5,002 ms | 29,986 ms |
A NOT_FOUND is a miss on the first index, answered in 52 ms. A REQUIREMENT_UNMET is slower than a hit, because the service found the person, read the full record, and then went looking for the number you asked for and did not find one. The 429 costs nothing on my side and everything on yours. The 503 is a timeout, five seconds of waiting for a store that did not answer.
What each Actor's empties usually are
The mix of empties is a property of the Actor and the list, not of the month. Same 30 days, per Actor, lookups with at least a few thousand requests:
| Actor | Lookups | Found | The usual empty | What to do about it |
|---|---|---|---|---|
| LinkedIn Email Finder (profile URL → email on record) | 24,315 | 90.6 % |
PROFILE_REMOVED 2 % |
Refresh the list; the profiles came from an export older than the network |
| Profile Finder by Name (name + company domain → profile) | 10,592 | 37.1 % |
NOT_FOUND 56 % |
The person is not at that domain any more, or the domain is a brand alias; a namesake at the domain is returned as a free ambiguous row, not as a guess |
| Email to LinkedIn (email → profile) | 78,491 | 15.1 % |
NOT_FOUND 85 % |
A personal address nobody attached to a profile; pass the name beside the email and more of them resolve |
| Reverse Email Lookup (email → person, title, employer) | 4,905 | 13.7 % |
NOT_FOUND 86 % |
Same as above; most inbound form emails are personal |
| LinkedIn Phone Number Finder (profile URL → mobile) | 157,994 | 5.5 % |
REQUIREMENT_UNMET 63 % |
The person is real, there is no number on record; US lists land at 14 % to 26 %, most other countries near 1 % |
Read the table before building the harness. An email finder that misses is telling you about the list. A phone finder that misses is mostly telling you that the number does not exist, and a reverse lookup that misses is telling you the address is personal. The code in the response says which, and the Actor passes it through into the dataset row: every miss row carries _status (not_found, no_phone, no_email, profile_removed, ambiguous, invalid, error) and an _error sentence that ends with "Not charged." A workflow can branch on _status without ever seeing an HTTP code.
What each empty is telling the agent
NOT_FOUND, 30.6 %. The identifier does not resolve to a person. A Gmail address nobody ever put on a profile, a handle that was never linked, a name with no company beside it. There is nothing to retry: the same key will miss again tomorrow. The right move is to stop, write the row as "unknown", and never re-ask with the same input. If the list came from a model, this is also the row that tells you the model invented the identifier.
REQUIREMENT_UNMET, 34.4 %. This one is the most often misread. The person is real and the record is full: name, title, employer, location, career history. What is missing is the one field the caller marked as required, usually a mobile number. Treating it as "not found" throws away a real lead. The row should go to the next channel, email instead of phone, or into the list without a contact, not into the bin. Where the share is high, it is a property of the list, not the service: among the lookups that required a US mobile number, the found share sat between 14 % and 26 % on US lists and near 1 % on lists from countries where the data carries almost no numbers. The first hundred rows tell you which list you have.
PROFILE_REMOVED, 4.3 %. The profile existed and the network took it down. 13,432 of these in a month is a measure of how stale the buyers' input lists are, not of the index. The service still holds the last-known record and will return it if the caller passes include_removed=true. The agent should decide that per job: a recruiting list wants the last-known employer, a compliance list wants the row dropped. Either way, the list that produced 4 % removed profiles should be refreshed before the next run, and that is a fix upstream of the API.
RATE_LIMITED, 12.1 %. The only empty that deserves a retry, and the only one that says nothing about the data. 35,342 of the 37,596 came from one morning, 9 October, when a buyer ran 103 jobs in parallel against one key. Outside that storm the whole month had about 2,250 of them. The response carries a Retry-After header in seconds. A client that honours it finishes the job; a client that retries on a fixed two-second timer, which is what the buyer's client did that morning, turns a third of its requests into refusals and ships about two rows in every hundred as "error" rows that look like misses.
SERVICE_UNAVAILABLE, 0.07 %. The store did not answer in five seconds. This is the one empty where the honest answer is "unknown", not "no". It should not be retried in a tight loop and it should not be written as a miss. It should go to the review pile with the reason attached, the same way Mohamed's validator returns checked: false instead of valid: false when the EU VAT service times out.
The routing table
The harness logic that falls out of this is small. In the Actors it is a switch on the error code; in n8n it is one Switch node after the HTTP Request node; in a Claude Code hook it is a few lines. The point is that the five branches are different, and that only one of them loops.
200 write the row; charged
404 NOT_FOUND write "unknown"; stop; never re-ask this key
404 REQUIREMENT_UNMET write the person; route to the next channel; not charged
404 PROFILE_REMOVED re-ask with include_removed=true, or drop; flag the list
429 RATE_LIMITED sleep Retry-After; retry; cap the attempts; count the retries
503 SERVICE_UNAVAILABLE write "unchecked" with the reason; review pile; no tight loop
In n8n the same table is one Code node after the Actor's dataset (or after the HTTP Request node with "Continue on fail" on), feeding a Switch:
// n8n Code node, one item per row from the Actor dataset
const s = $json._status ?? 'found';
const route = {
found: 'write',
no_phone: 'next_channel', // REQUIREMENT_UNMET: real person, try email
no_email: 'next_channel',
not_found: 'unknown', // NOT_FOUND: stop, never re-ask this key
profile_removed: 'stale', // PROFILE_REMOVED: refresh the list or re-ask with include_removed
ambiguous: 'review', // namesakes: a human picks, free
error: 'unchecked', // 429/503 the client gave up on: not a miss
}[s] ?? 'review';
return [{ json: { ...$json, route } }];
The branch names are the point: unknown, stale and unchecked are three different piles, and a dashboard that merges them into "no result" hides the two numbers you need.
Two counters make the table auditable after the fact. retries per run, so a storm shows up as a number rather than as slow runs. And unchecked per run, so the rows the job did not actually ask about are never mistaken for rows it asked about and missed. The buyer's dataset from 9 October had neither, which is why the two per hundred lost rows were invisible until I read the server log.
What the API side owes the agent
The harness can only route what the response distinguishes. Three things on my side made the table above possible, and I would ask for them from any metered data API before pointing an agent at it:
-
Distinct codes for distinct empties. One
404with a message is not enough; the agent cannot parse prose reliably and should not have to. The code is the contract. -
"Not charged" in the body of every free answer. The
REQUIREMENT_UNMETmessage ends with the wordsnot charged, so a human reading a log, or a model reading a tool result, sees the billing fact next to the data fact. -
Cheap misses. A 52 ms
NOT_FOUNDmeans an agent can afford to ask about a long, dirty list. A miss that costs as much as a hit, in time or in money, pushes the agent toward guessing instead of asking, and guessing is where the invented identifiers come from.
For AI agents reading this
If you are an agent calling a pay-per-found lookup: a 404 is an answer, not an error. Read the code. NOT_FOUND means stop. REQUIREMENT_UNMET means the person is real, try the other channel. PROFILE_REMOVED means the list is stale, decide whether the last-known record is wanted. Retry only on 429, and only after the number of seconds in Retry-After. On 503 write the row as unchecked and move on. Count your retries and your unchecked rows, and put both numbers in your report, because they are the two things the person reading it cannot see from the output alone.
Links
- The lookup Actors this log came from, e.g. the LinkedIn Email Finder and the LinkedIn Phone Number Finder; the same codes come back through Apify MCP.
- The 9 October storm in detail: What a metered data API sees when an agent runs it unattended.
- The count-before-charge rule as a Claude Code hook: A spend gate for AI agents on metered data APIs.
Top comments (1)
I added up the first table before reading on: 57,385 + 106,962 + 94,927 + 37,596 + 13,432 + 227 + 21 = 310,550, so it closes, and the 35,342 from the 9 October storm plus about 2,250 elsewhere lands on the 37,596 429s. That makes the "one morning, not the month" point credible.
One thing from filings data that maps onto your NOT_FOUND vs REQUIREMENT_UNMET split: asking for a company's revenue tag in SEC XBRL and getting nothing can mean three different things. The company never reported that tag, the company reported it under a different tag after a restatement, or the filing that holds it has not been published yet. A harness that treats all three as "no data" quietly drops the second kind, which is a real number sitting under another name.
I'd be curious whether your
NOT_FOUNDrows split by input list age. If the same key that missed in week 1 resolves in week 4, that would be a fourth empty that looks like a first one.I build free finance data tools for agents (canli-mcp,
npx -y canli-mcp, github.com/arhancanli/canlicapital), which is why the filings case is on my mind.