I run a visa agency in Bali and build Apify Actors on the side. One of them reads a public LinkedIn profile and returns the whole thing — headline, current role, experience with dates, education, skills — plus the person's work e-mail, with the mailbox confirmed over SMTP before the address is delivered. The row is charged as a profile with an e-mail only when a mail server said the mailbox exists; otherwise the address stays in emailGuess at the plain profile price.
That billing rule made something visible I would never have noticed otherwise: the bill for an identical input is not stable from day to day.
A health check runs the same three demo profile URLs every night — satyanadella, justinwelsh, jeffweiner08. Eight of those runs between 21 and 27 October 2026 are sitting in my dataset list. Here is every one.
| Run (UTC) | Build | Satya Nadella | Jeff Weiner | Justin Welsh | E-mails | Bill |
|---|---|---|---|---|---|---|
| 21 Oct 09:54 | 0.1.41 |
satya@ safe
|
jeff@ safe
|
catch-all | 2 | $0.01824 |
| 22 Oct 00:23 | 0.1.43 |
satya@ safe
|
jeff@ safe
|
catch-all | 2 | $0.01824 |
| 22 Oct 03:51 | 0.1.45 |
satya@ safe
|
jeff@ safe
|
catch-all | 2 | $0.01824 |
| 22 Oct 09:52 | 0.1.47 |
satya@ safe
|
jeff@ safe
|
catch-all | 2 | $0.01824 |
| 23 Oct 00:24 | 0.1.50 |
satya@ safe
|
jeff@ unknown |
catch-all | 1 | $0.01368 |
| 24 Oct 00:23 | 0.1.52 | unknown → uchicago.edu unknown |
jeff.weiner@ unknown |
catch-all | 0 | $0.00912 |
| 26 Oct 00:51 | 0.1.52 |
satya@ safe
|
jeff.weiner@ unknown |
catch-all | 1 | $0.01368 |
| 27 Oct 00:14 | 0.1.52 |
satya@ safe
|
jeff@ unknown |
catch-all | 1 | $0.01368 |
Same three URLs, same settings, three different totals, a 2× spread. The build number moved across the window, so let me be strict about that: the e-mail path has not changed since 19 October — those builds touched the README, the example input and the run-end alert module. The last three rows are all build 0.1.52, and they still disagree: $0.00912, $0.01368, $0.01368. (My account is on Apify's Bronze tier — an e-mail row is $0.0076, a plain profile $0.00304; the totals come from the free run-summary row written at the end of each dataset.)
Nobody changed jobs. What changed was one word in a verdict.
invalid and unknown are not two shades of "no"
Each person gets at most two mailbox checks, best candidate first, and the walk stops at the first safe. Six of the eight runs look like this for Satya Nadella:
"emailVerification": { "provider": "reoon", "status": "safe", "score": 98, "checks": 2,
"tried": [ { "email": "satya.nadella@microsoft.com", "status": "invalid" },
{ "email": "satya@microsoft.com", "status": "safe" } ] }
first.last is the most common corporate layout, so it goes first, and microsoft.com says flatly there is no such mailbox. invalid is information — it eliminates one candidate and nothing else — so the walk continues to the next layout, the plain first name, and that one exists. Delivered: satya@microsoft.com, confidence verified.
On 24 October the identical first address came back unknown instead:
"email": null,
"emailGuess": "satya.nadella@microsoft.com",
"emailStatus": "mailbox-unknown",
"emailVerification": { "status": "unknown", "score": null, "checks": 2,
"tried": [ { "email": "satya.nadella@microsoft.com", "status": "unknown" },
{ "email": "satyanadella@uchicago.edu", "status": "unknown" } ] }
Look at where the second check went. Not to satya@microsoft.com — to a university. unknown means the server would not say, and a server that will not say cannot confirm anyone at that domain, so my code treats the domain as exhausted and rolls the remaining check to the person's next current company. His profile lists one: Member of the Board of Trustees, University of Chicago, since 2018. So the day's last check went to an unpaid board seat at a school, came back unknown too, and the row shipped with no address and emailGuess holding the exact string six other runs had proved does not exist.
The whole mechanism is four lines:
const STOP = new Set(['catch_all', 'unknown', 'inbox_full', 'spamtrap', 'disposable']);
…
if (r.status === 'safe') return out;
if (r.status === 'catch_all' || r.status === 'unknown') { domainVerdict.set(domain, r.status); return { ...out, email: list[0] }; }
if (STOP.has(r.status)) return out;
invalid falls through all three and the loop takes another candidate at the same company. unknown returns immediately. One word from a mail server decides whether the next check is spent on another layout at the right company or on a different company entirely — a control-flow difference dressed up as a status string, and getting it wrong costs the whole e-mail.
The part that bites at scale: a flaky verdict is remembered
Look again at the middle line: domainVerdict.set(domain, r.status). A domain that answered catch_all or unknown is remembered for the rest of the run, and everyone else at that company is answered from memory, checks: 0, no probe sent.
I wrote that on purpose. Checks are a paid API call out of my margin, not the buyer's bill, and firing hundreds of mailbox probes at one domain is how a verification provider's IPs stop being trusted there. For a genuine catch-all it is exactly right: nothing can ever be confirmed at justinwelsh.me, which answered catch_all with a score of 75 in all eight runs above, so checking a second person there is money set on fire.
For a transient unknown it is the opposite of right. If person #1 of your 400 Microsoft rows draws the 24-October answer, the domain is written off for the whole run and the other 399 come back as free unverified guesses, each carrying domainKnownFromThisRun: true. The run looks healthy. The status message says 400 rows delivered. Your bill is 2.5× smaller than usual — the most misleading possible signal, because it shrank for the worst reason.
If you consume SMTP verdicts in a pipeline of any size:
-
invalidis durable — cache it and never offer that address again. -
unknownis a timestamp, not a fact: ask again later, never "this person has no e-mail". - Never upsert on the e-mail column alone. The same person is
email: "satya@microsoft.com"on Monday andemail: null, emailGuess: "satya.nadella@microsoft.com"on Saturday; a naive upsert replaces a verified address with a guess known to bounce. - Keep
emailGuessandalternatives, not justemail. On a bad day they are the only record of anything.
A field that looks like a fact and isn't
One more trap I walked into myself. On the good days Satya's row says "emailPattern": "first". On 24 October it says "emailPattern": "first.last", with "emailPatternSource": "default". Neither is a claim about Microsoft: emailPattern is whichever candidate the walk stopped on, and when nothing was confirmed it is just the head of the prior list. Building a layout-per-domain cache from these rows is a tempting thing to do — only trust emailPattern where confidence is verified or emailPatternSource is not default.
Same for patternEvidenceCount, 0 in all eight runs: microsoft.com, gatesfoundation.org and nextplayventures.com publish no personal address a crawler can read, so there was nothing to learn and the prior took over.
What I actually changed
Not the verdict handling — stopping at unknown is still right for the domain, because a server that will not answer about one mailbox will not answer about the next either. The fix belongs a level up: re-check later, keep history.
What I did change on 23 October was the example input. A first-time visitor on Apify's free plan gets two mailbox checks for the whole run, so the old three-profile demo could easily show them zero verified addresses. It now ships two profiles that confirm in one check each:
curl -X POST "https://api.apify.com/v2/acts/lergassy~linkedin-profile-email-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"profileUrls":["https://www.linkedin.com/in/williamhgates","garrytan"],"findEmail":true}'
That run, on 27 October, took 8.8 seconds and returned bill.gates@gatesfoundation.org and garry.tan@ycombinator.com, both mailbox-verified, one check each, $0.0152 for the two rows. Some domains are boringly stable, and a demo should use them. It is LinkedIn Profile Scraper with Email if you want the rows instead of the reasoning.
Where it does not fit: there is no login, so the roughly one profile in five that LinkedIn hides from anonymous readers goes through an unblocking route, and a few per cent still come back as free error rows. If your list is all large enterprises, expect a wall of catch-all domains and plan on guesses. And if you need an address you can trust on a specific day for a specific person, nobody in this category can give you that — including me. What a mailbox check buys is a verdict with a timestamp on it. Store the timestamp.
Top comments (0)