A work email address is the most underused field in B2B software.
It is the one thing you get from almost every inbound touchpoint. A demo form. A trial signup. A support ticket. A webinar registration. A calendar invite from someone you have never met. In most systems it sits there as a login credential and a delivery target, and nothing more.
It is actually a key. [email protected] identifies a specific person at a specific company, and if you can resolve it, you can populate a full account record before that person finishes filling out the form.
This post covers how reverse email lookup works, what it can and cannot tell you, and how to wire it into the three places it pays for itself fastest. Code is in Python and Node throughout.
What reverse lookup returns
One input: a business email address. Two outputs, each independently resolvable.
The person. Full name, headline, summary, current title, profile URL, picture, location, industry, follower count, premium status, open-to-work status, plus arrays for experience[] and education[].
The company. Name, slug, website, profile URL, logo, description, industry, size band, staff count, follower count, location, and specialities.
Plus a confidence value of high, medium, low, or none, and a found boolean.
The critical design point is that person and current_company are separate nullable objects. You can get a company without a person. That is a partial result, and partial results are useful. Knowing that an inbound signup came from a 4,000-person logistics company in Rotterdam is enough to route the lead correctly even if you never identify the individual.
Teams routinely discard these. Do not.
1from renidly import Renidly
2
3renidly = Renidly() # reads RENIDLY_API_KEY from the environment
4
5result = renidly.emails.reverse("[email protected]")
6
7print(result.found) # bool
8print(result.confidence) # "high" | "medium" | "low" | "none"
9print(result.person) # object or None
10print(result.current_company) # object or None1import { Renidly } from "renidly";
2
3const renidly = new Renidly(); // reads RENIDLY_API_KEY from the environment
4
5const result = await renidly.emails.reverse("[email protected]");
6
7console.log(result.found, result.confidence);
8console.log(result.person, result.currentCompany);Business mailboxes only, and why that is the right constraint
Reverse lookup accepts professional working mailboxes. It rejects five categories before doing any work, returning 422:
- Public mailbox providers (Gmail, Outlook, Yahoo, and similar)
- Role accounts (
info@,support@,sales@,hello@, and similar) - Disposable and throwaway providers
- Relay and address-masking services
- Syntactically invalid addresses
This looks like a limitation and is actually a correctness guarantee. Consider what a "successful" resolution of [email protected] would even mean. There are many Jane Does with Gmail addresses. Any system that returns a confident person record for a public mailbox is guessing, and a guess written into your CRM at high confidence is worse than no data, because your team will act on it.
Role accounts fail for a different reason. [email protected] is not a person. It is a queue, often staffed by a rotating team. Resolving it to whichever individual happens to own it this quarter produces a record that is wrong by design.
The practical implication for your architecture: filter obvious public providers on your side before the call if you want to save the round trip, but you do not need to build an exhaustive blocklist. The 422 is a clean, fast, structured rejection. Catch it and route those addresses to your consumer-signup path instead.
1from renidly import Renidly, InvalidRequestError, RenidlyError
2
3def resolve_or_classify(email: str):
4 try:
5 return renidly.emails.reverse(email)
6 except InvalidRequestError:
7 # Not a professional working mailbox
8 return None1import { Renidly, InvalidRequestError } from "renidly";
2
3async function resolveOrClassify(email: string) {
4 try {
5 return await renidly.emails.reverse(email);
6 } catch (e) {
7 if (e instanceof InvalidRequestError) return null;
8 throw e;
9 }
10}Confidence is the whole design
If you take one thing from this post: confidence determines write behavior, not just logging.
Most teams treat enrichment as binary. Data came back, write it. That is how a CRM accumulates records nobody trusts, and once trust is gone, reps go back to manual research and your enrichment spend produces nothing.
Four tiers, four behaviors:
| Confidence | Meaning | Recommended action |
|---|---|---|
high | Strong, unambiguous resolution | Write directly to the record, no review |
medium | Good resolution with some ambiguity | Write to the record, flag for periodic review |
low | Plausible candidate | Hold in a staging field, route to human review |
none | No usable resolution | Do not write, do not overwrite existing values |
The none case has a subtlety worth spelling out. Never let an empty resolution overwrite a populated field. If a rep manually entered a title six months ago and today's resolution returns nothing, the correct outcome is that the manual value survives. Enrichment should be additive to unknown fields and corrective to stale ones, never destructive to known ones.
1def apply_resolution(record_id: str, result) -> str:
2 if not result.found or result.confidence == "none":
3 return "no_write"
4
5 person = result.person
6 company = result.current_company
7
8 if result.confidence == "high":
9 write_fields(record_id, person, company, review=False)
10 return "written"
11
12 if result.confidence == "medium":
13 write_fields(record_id, person, company, review=True)
14 return "written_flagged"
15
16 stage_for_review(record_id, person, company)
17 return "staged"1async function applyResolution(recordId: string, result): Promise<string> {
2 if (!result.found || result.confidence === "none") return "no_write";
3
4 const { person, currentCompany } = result;
5
6 if (result.confidence === "high") {
7 await writeFields(recordId, person, currentCompany, { review: false });
8 return "written";
9 }
10
11 if (result.confidence === "medium") {
12 await writeFields(recordId, person, currentCompany, { review: true });
13 return "written_flagged";
14 }
15
16 await stageForReview(recordId, person, currentCompany);
17 return "staged";
18}Resolution and verification answer different questions
Reverse lookup tells you who an address belongs to. It does not tell you whether that address still receives mail. Those are separate calls because they are separate questions, and conflating them is a common and expensive mistake.
An address can resolve perfectly and be undeliverable. Jane left the company four months ago. The record is accurate about who the address belonged to. The mailbox is closed.
An address can also be deliverable and resolve to nothing, which is common at small companies and new domains.
If you are about to send mail, do both:
1result = renidly.emails.reverse(email)
2check = renidly.emails.verify(email)
3
4if result.found and check.deliverable and not check.catch_all:
5 send(email, personalize_from=result.person)1const [result, check] = await Promise.all([
2 renidly.emails.reverse(email),
3 renidly.emails.verify(email),
4]);
5
6if (result.found && check.deliverable && !check.catchAll) {
7 await send(email, { personalizeFrom: result.person });
8}The catch_all flag deserves its own paragraph, because it is the one that damages sending reputation weeks after the mistake. On a catch-all domain, the mail server accepts everything, including addresses that do not exist. Every verify returns deliverable. If you treat that as clean and load it into a sequence, your bounces arrive later, in volume, and by then they have affected your domain reputation.
Segment catch-all separately from the start. verify exposes catch_all as its own boolean precisely so you can.
The full reason enum from verify, and what each implies:
reason | Action |
|---|---|
mailbox_accepts | Send |
mailbox_rejected | Suppress permanently |
catch_all | Separate segment, monitor bounce rate closely |
risky | Low-volume segment only |
invalid_syntax | Fix at the point of capture, this is a form bug |
disposable | Suppress, and consider blocking at signup |
relay | Suppress from cold outreach |
no_mx | Suppress permanently |
Three places this pays for itself
1. Signup enrichment, synchronous
The highest-value placement, because it changes what happens next in the funnel.
A visitor enters a work email. Before they reach step two, you know their title, their company, its headcount, and its industry. That drives immediate branching: enterprise-shaped signups get routed to sales, self-serve-shaped signups get the product tour, and neither has to answer six qualification questions.
The constraint is latency. Someone is waiting. Keep the timeout tight and always have a fallback path.
1from renidly import Renidly, RenidlyConfig, RenidlyError
2
3renidly = Renidly(config=RenidlyConfig(timeout=5, max_retries=1))
4
5def enrich_signup(email: str) -> dict:
6 try:
7 result = renidly.emails.reverse(email)
8 except RenidlyError:
9 return {"enriched": False}
10
11 if not result.found:
12 return {"enriched": False}
13
14 company = result.current_company
15 return {
16 "enriched": True,
17 "confidence": result.confidence,
18 "title": result.person.title if result.person else None,
19 "company": company.name if company else None,
20 "headcount": company.staff_count if company else None,
21 "route": "sales" if company and company.staff_count and company.staff_count > 500 else "self_serve",
22 }1const renidly = new Renidly(undefined, { timeout: 5_000, maxRetries: 1 });
2
3export async function enrichSignup(email: string) {
4 let result;
5 try {
6 result = await renidly.emails.reverse(email);
7 } catch {
8 return { enriched: false };
9 }
10
11 if (!result.found) return { enriched: false };
12
13 const company = result.currentCompany;
14 return {
15 enriched: true,
16 confidence: result.confidence,
17 title: result.person?.title ?? null,
18 company: company?.name ?? null,
19 headcount: company?.staffCount ?? null,
20 route: (company?.staffCount ?? 0) > 500 ? "sales" : "self_serve",
21 };
22}Never block signup on enrichment. If the call is slow or fails, the user completes the flow and you enrich asynchronously. A failed enrichment should cost you a data point, not a conversion.
2. Backfilling an existing database
You have tens of thousands of email-only records. Batch is the correct tool, and looping single calls is not.
Reverse lookup does not have a dedicated batch endpoint, so the pattern is a bounded worker pool with the SDK's rate limiter engaged. Turn on auto_rate_limit and let the client hold you under your per-minute ceiling using a sliding window, rather than discovering the limit through 429 responses in production.
1from concurrent.futures import ThreadPoolExecutor
2from renidly import Renidly, RenidlyConfig, RenidlyError
3
4renidly = Renidly(config=RenidlyConfig(
5 auto_rate_limit=True,
6 rate_limit_safety=0.8, # stay at 80% of the ceiling
7 max_retries=3,
8))
9
10def resolve_one(row):
11 try:
12 r = renidly.emails.reverse(row.email)
13 except RenidlyError as e:
14 return {"id": row.id, "status": "error", "code": e.error_code}
15 return {
16 "id": row.id,
17 "status": "found" if r.found else "unresolved",
18 "confidence": r.confidence,
19 "person": r.person,
20 "company": r.current_company,
21 "cost": r.meta.credit_consumed,
22 }
23
24with ThreadPoolExecutor(max_workers=8) as pool:
25 for out in pool.map(resolve_one, rows):
26 persist(out)1import pLimit from "p-limit";
2import { Renidly, RenidlyError } from "renidly";
3
4const renidly = new Renidly(undefined, {
5 autoRateLimit: true,
6 rateLimitSafety: 0.8,
7 maxRetries: 3,
8});
9
10const limit = pLimit(8);
11
12await Promise.all(
13 rows.map((row) =>
14 limit(async () => {
15 try {
16 const r = await renidly.emails.reverse(row.email);
17 await persist({
18 id: row.id,
19 status: r.found ? "found" : "unresolved",
20 confidence: r.confidence,
21 person: r.person,
22 company: r.currentCompany,
23 cost: r.meta.creditConsumed,
24 });
25 } catch (e) {
26 if (e instanceof RenidlyError) {
27 await persist({ id: row.id, status: "error", code: e.errorCode });
28 } else throw e;
29 }
30 }),
31 ),
32);Set rate_limit_safety below 1.0 when the same key serves production traffic, so a backfill cannot starve your interactive path. Enterprise keys need an explicit rate_limit_per_minute because the limit is negotiated rather than read from a tier.
Run a sample of 500 records before the full job. You need to know your resolution rate on your own data, and the next section explains why that number is the one that determines the cost.
3. Support and account routing
A ticket arrives from an address nobody recognizes. Resolve it and you know whether it is a 50-seat customer or a 10,000-employee enterprise before anyone reads the first line.
This is the highest-margin placement because the volume is low and the value of correct routing is high. A misrouted enterprise ticket costs more than a year of enrichment on the whole queue.
Modeling the cost properly
The number that determines your spend is your resolution rate, not your record count.
A lookup that runs and returns nothing is a completed lookup. Producing a definitive "no match" requires the same work as producing a record, and it bills accordingly. That is why a miss returns HTTP 200 rather than 404: the request succeeded, and the answer is empty.
So the arithmetic is not "cost per enriched record." It is:
1total cost = records attempted × cost per lookup
2effective cost per usable record = total cost ÷ (records attempted × resolution rate)If you send 10,000 addresses and 55% resolve, you pay for 10,000 lookups and get 5,500 usable records. Your real cost per record is roughly double the headline rate. Teams that model the headline rate and budget accordingly get an unpleasant surprise at the first invoice.
Do not take a vendor's word for the resolution rate, including ours. Run a sample of your own data and measure it. Data from a 2019 trade-show list resolves very differently from last quarter's inbound.
Two mechanisms let you measure this precisely rather than estimate it.
Per-call telemetry. Every SDK response carries a .meta object sitting outside the response data, so there is no field collision:
1r = renidly.emails.reverse(email)
2print(r.meta.credit_consumed) # what this specific call cost
3print(r.meta.remaining_balance) # balance after it1const r = await renidly.emails.reverse(email);
2console.log(r.meta.creditConsumed, r.meta.remainingBalance);Emit both to your metrics pipeline from the first call you ever make. Cost per resolved record is the number your finance team will ask for at renewal, and reconstructing it from invoices after the fact is miserable.
Live per-route costs. Rather than hardcoding prices that can change, read them:
1costs = renidly.account.route_costs()
2balance = renidly.account.balance()1const costs = await renidly.account.routeCosts();
2const balance = await renidly.account.balance();Route costs are published at GET https://renidly.com/api/panel/credits/routes/costs/, which is public and requires no key, so you can price an integration before you sign up for one. Wire a balance check into your alerting so a backfill cannot silently exhaust the pool that your production signup path depends on.
Adjacent patterns worth knowing
Reverse the other direction. If you have a name and a company domain but no address, emails.find(first_name=..., last_name=..., domain=...) goes the other way. The domain parameter is strict: a bare hostname with a dot, so northwind-logistics.com and not https://northwind-logistics.com or [email protected]. Normalize before the call, because user-entered company fields are full of full URLs.
Start from a profile URL. emails.find_by_url(url) in Python, emails.findByUrl(url) in Node, takes a professional profile URL or its bare public slug and returns the email plus the resolved first name, last name, domain, and company.
Enumerate a domain. emails.prospects(domain, kind) pages through known addresses at a company. kind is required: full returns everything known, verified_only returns deliverable addresses only. Pagination uses opaque cursors, so pass next_cursor back exactly as received and never construct one yourself. This endpoint bills per email returned rather than per call, so page deliberately and stop when you have enough.
1cursor = None
2while True:
3 page = renidly.emails.prospects("northwind-logistics.com", "verified_only", cursor=cursor)
4 for p in page.prospects:
5 handle(p.email, p.first_name, p.last_name)
6 if not page.next_cursor:
7 break
8 cursor = page.next_cursor1let cursor: string | undefined;
2while (true) {
3 const page = await renidly.emails.prospects(
4 "northwind-logistics.com",
5 "verified_only",
6 { cursor },
7 );
8 for (const p of page.prospects) handle(p.email, p.firstName, p.lastName);
9 if (!page.nextCursor) break;
10 cursor = page.nextCursor;
11}Errors you should handle explicitly
The SDKs raise typed errors, and each one implies a different response:
| Error | Meaning | Response |
|---|---|---|
InvalidRequestError | Bad input, read field_errors / fieldErrors | Fix and do not retry |
PermissionDeniedError | Not permitted for this address or key | Skip permanently, record the exclusion |
InsufficientCreditsError | Balance exhausted | Alert operations, degrade gracefully |
RateLimitError | Per-minute ceiling hit, carries retry_after | Back off, or enable auto_rate_limit |
ServiceUnavailableError | Transient | Retry with backoff |
APIConnectionError | Network or timeout | Retry, then fall back |
PermissionDeniedError is the one to handle carefully. Individuals can opt out of resolution. When that happens, the correct behavior is to record the exclusion permanently and never retry that address. Retrying an opted-out record on every batch run is both wasteful and the wrong posture. Build the suppression list on the first 403 and honor it.
What to do this week
The fastest way to know whether this is worth building is not a demo record. It is your own data.
Take 500 rows from your existing database where you have an email but sparse firmographics. Run them through reverse resolution. Measure three things:
- Resolution rate. What percentage came back
foundwithhighormediumconfidence? - Correction rate. Of those, how many disagree with what your CRM currently believes about title or company?
- Effective cost. Total credits consumed divided by usable records.
The second number is usually the one that ends the internal debate, because it quantifies staleness you already have rather than accuracy you might gain. Most teams are surprised by it.
1pip install renidly1npm install renidlyBoth SDKs cover every endpoint and handle auth, retries, pagination, batch jobs, rate limiting, and typed errors. The free tier includes 100 credits with no card required, which is enough to run a real sample of your own data.
The Quickstart takes about five minutes. For volume pricing, an SLA, or a procurement conversation, get in touch.
