Renidlyrenidly
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Products
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Use cases
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Docs
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Renidlyrenidly
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Products
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Use cases
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Docs
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Renidlyrenidly
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Products
Data API
Query the clean B2B graph: people, companies, schools, job changes.
Live API
The freshest profile, company, or job posting, resolved on demand.
Email API
Verify, find, and resolve any work email.
Use cases
Recruiting & HR platforms
Verify candidates in seconds with quality scores and live profile data.
Sales intelligence
Enrich CRM accounts with verified firmographics built for lead scoring.
Market research & analytics
Track 60K+ active job postings, posting velocity, and geographic trends.
Business development
Map stakeholders, org structure, and warm-intro paths to any decision maker.
Outbound & cold email
Find and verify every prospect’s work email before a sequence ever sends.
Inbound lead enrichment
Reverse-lookup each signup into a person and company the moment it lands.
Docs
Documentation
Getting started, core concepts, guides.
Quickstart
First resolution in 5 minutes.
API Reference
Interactive endpoint playground.
Pricing
Sign inStart Free
Blog/Guides
Guides

Building a Reverse Email Lookup Into Your Signup Flow

Aug 5, 202610 min read
Share

Contents

  • Decide the architecture first
  • Pattern one: asynchronous
  • Pattern two: hybrid, with a hard abandon
  • Pattern three: synchronous, and when it is justified
  • Validate before you call
  • Do not send enrichment data to the browser
  • Cache your own results
  • Make the worker idempotent
  • Instrument it from the first call
  • Test without spending credits
  • Progressive profiling: use what you already resolved
  • The build order

The pitch for signup enrichment is easy. Someone types a work email, and before they finish the form you know their title, their company, its headcount, and its industry. You skip four qualification questions, route enterprise signups to sales, and hand the rep a full account record instead of an email address.

The implementation is where it goes wrong. The common failure is not a bad API call. It is an enrichment step wired into the critical path of signup, where a slow response costs you a conversion that was worth far more than the data.

This is a build guide. It covers the three architectures, how to choose between them, the latency budget, and the specific mistakes that surface in production rather than in staging. Code is Python and Node.

Decide the architecture first

Everything downstream depends on one question: does the signup flow change based on what enrichment returns?

If yes, enrichment is on the critical path and you need a synchronous or hybrid design with a hard latency budget. If no, run it asynchronously and stop worrying about latency entirely.

Most teams assume yes and should have answered no.

ArchitectureUse whenLatency budgetFailure cost
AsynchronousData lands in the CRM for later useNoneNothing user-facing
SynchronousThe next screen changes based on the result400 to 800 msA stalled form
HybridYou want branching but cannot risk the stall400 ms, then abandonFalls back to default path

Start asynchronous. Move to hybrid only when you have a concrete branching decision that measurably improves conversion. Pure synchronous is rarely the right answer, because it makes your signup availability depend on a third party's availability.

Pattern one: asynchronous

The user submits. You accept, create the account, and return immediately. Enrichment happens on a worker.

This is the correct default. It has no latency budget, no timeout tuning, and no user-facing failure mode. If enrichment is down for an hour, your signup flow does not notice.

python
1from renidly import Renidly, RenidlyConfig, RenidlyError, PermissionDeniedError
2
3renidly = Renidly(config=RenidlyConfig(
4    timeout=20,
5    max_retries=3,
6    auto_rate_limit=True,
7))
8
9
10def enrich_account(account_id: str, email: str) -> None:
11    """Runs on a worker. Never called from the request path."""
12    try:
13        result = renidly.emails.reverse(email)
14    except PermissionDeniedError:
15        mark_permanently_excluded(account_id)
16        return
17    except RenidlyError as e:
18        log.warning("enrichment failed", account=account_id, code=e.error_code)
19        return
20
21    record_cost(result.meta.credit_consumed)
22
23    if not result.found or result.confidence == "none":
24        mark_unresolved(account_id)
25        return
26
27    write_enrichment(
28        account_id,
29        person=result.person,
30        company=result.current_company,
31        confidence=result.confidence,
32    )
ts
1import { Renidly, RenidlyError, PermissionDeniedError } from "renidly";
2
3const renidly = new Renidly(undefined, {
4  timeout: 20_000,
5  maxRetries: 3,
6  autoRateLimit: true,
7});
8
9export async function enrichAccount(accountId: string, email: string) {
10  let result;
11  try {
12    result = await renidly.emails.reverse(email);
13  } catch (e) {
14    if (e instanceof PermissionDeniedError) {
15      await markPermanentlyExcluded(accountId);
16      return;
17    }
18    if (e instanceof RenidlyError) {
19      log.warn("enrichment failed", { accountId, code: e.errorCode });
20      return;
21    }
22    throw e;
23  }
24
25  await recordCost(result.meta.creditConsumed);
26
27  if (!result.found || result.confidence === "none") {
28    await markUnresolved(accountId);
29    return;
30  }
31
32  await writeEnrichment(accountId, {
33    person: result.person,
34    company: result.currentCompany,
35    confidence: result.confidence,
36  });
37}

Note the separate PermissionDeniedError branch before the general handler. Individuals can opt out of resolution, which surfaces as a 403. That is a permanent state, not a transient failure, and the correct behavior is to record the exclusion and never enqueue that address again. If you let it fall through to the generic error path, your retry logic will keep re-requesting an address that will never resolve, on every backfill, forever.

Pattern two: hybrid, with a hard abandon

You want the branching benefit but refuse to let a third party stall your form. Give enrichment a short window on the request path. If it answers, branch. If it does not, abandon the wait, take the default path, and let the worker finish the job in the background.

The key detail is that abandoning the wait must not abandon the enrichment. The call is billed either way, so let the result land.

python
1import asyncio
2from renidly import AsyncRenidly, RenidlyConfig, RenidlyError
3
4renidly = AsyncRenidly(config=RenidlyConfig(timeout=5, max_retries=1))
5
6ENRICH_BUDGET_SECONDS = 0.4
7
8
9async def signup(email: str) -> dict:
10    account_id = await create_account(email)
11
12    task = asyncio.create_task(_resolve_and_store(account_id, email))
13
14    try:
15        result = await asyncio.wait_for(asyncio.shield(task), ENRICH_BUDGET_SECONDS)
16    except (asyncio.TimeoutError, RenidlyError):
17        # Budget blown or call failed. The task keeps running.
18        return {"account_id": account_id, "next": "default"}
19
20    if not result or not result.found:
21        return {"account_id": account_id, "next": "default"}
22
23    company = result.current_company
24    headcount = company.staff_count if company else None
25
26    return {
27        "account_id": account_id,
28        "next": "sales_handoff" if headcount and headcount >= 500 else "self_serve",
29        "company": company.name if company else None,
30    }
31
32
33async def _resolve_and_store(account_id: str, email: str):
34    result = await renidly.emails.reverse(email)
35    await write_enrichment(account_id, result)   # always persists
36    return result
ts
1import { Renidly, RenidlyError } from "renidly";
2
3const renidly = new Renidly(undefined, { timeout: 5_000, maxRetries: 1 });
4
5const ENRICH_BUDGET_MS = 400;
6
7export async function signup(email: string) {
8  const accountId = await createAccount(email);
9
10  // Kick off enrichment; it persists regardless of whether we wait for it.
11  const work = resolveAndStore(accountId, email).catch((e) => {
12    if (e instanceof RenidlyError) return null;
13    throw e;
14  });
15
16  const timeout = new Promise<null>((r) => setTimeout(() => r(null), ENRICH_BUDGET_MS));
17  const result = await Promise.race([work, timeout]);
18
19  if (!result?.found) return { accountId, next: "default" };
20
21  const headcount = result.currentCompany?.staffCount ?? 0;
22
23  return {
24    accountId,
25    next: headcount >= 500 ? "sales_handoff" : "self_serve",
26    company: result.currentCompany?.name ?? null,
27  };
28}
29
30async function resolveAndStore(accountId: string, email: string) {
31  const result = await renidly.emails.reverse(email);
32  await writeEnrichment(accountId, result);
33  return result;
34}

asyncio.shield in the Python version is load-bearing. Without it, the timeout cancels the underlying task and you pay for a call whose result you throw away. The Node version gets this for free because Promise.race does not cancel the loser.

Set the budget from your own p95, not from a round number. If enrichment answers within 400 ms for 95% of calls, the budget is 400 ms and one signup in twenty takes the default path. That is an acceptable trade. Budgeting at p50 means half your users get the default path and the branching logic barely runs.

Pattern three: synchronous, and when it is justified

Genuinely blocking is defensible in one case: a high-touch enterprise demo request where the entire point of the form is instant routing, volume is low, and a slow response is better than a wrong one.

If you build it, three non-negotiables:

  1. A circuit breaker. After N consecutive failures, stop calling and go straight to the default path until a probe succeeds. Without this, an outage turns into a full signup outage.
  2. A timeout well below your request timeout. If your server times out at 30 seconds, enrichment gets 3.
  3. A default path that works. The form must complete correctly with zero enrichment data. Test this by pointing the client at a dead host.
python
1renidly = Renidly(config=RenidlyConfig(timeout=3, max_retries=0))
ts
1const renidly = new Renidly(undefined, { timeout: 3_000, maxRetries: 0 });

Note max_retries=0 here. On the critical path, retries multiply your worst case. Three retries with backoff against a 3 second timeout is a 12+ second stall. Retry on the worker, not in front of the user.

Validate before you call

Reverse lookup accepts professional working mailboxes and rejects public providers, role accounts, disposable addresses, relay services, and malformed input with a 422. That is a clean structured rejection you can catch, but on the critical path it is still a round trip you can skip.

// Try it yourself

Stop stitching vendors together.

One endpoint resolves any email, domain, company or profile. Start with 100 free credits — no card required.

Get 100 free credits

Do a cheap local pre-filter for the obvious cases, then let the API handle the rest:

python
1ROLE_PREFIXES = {
2    "info", "support", "sales", "hello", "admin", "contact",
3    "help", "team", "office", "billing", "noreply", "no-reply",
4}
5
6FREE_PROVIDERS = {
7    "gmail.com", "outlook.com", "hotmail.com", "yahoo.com",
8    "icloud.com", "aol.com", "proton.me", "protonmail.com",
9    "gmx.com", "mail.com", "yandex.com", "live.com", "msn.com",
10}
11
12
13def worth_resolving(email: str) -> bool:
14    try:
15        local, domain = email.strip().lower().rsplit("@", 1)
16    except ValueError:
17        return False
18    if domain in FREE_PROVIDERS:
19        return False
20    if local in ROLE_PREFIXES:
21        return False
22    return "." in domain
ts
1const ROLE_PREFIXES = new Set([
2  "info", "support", "sales", "hello", "admin", "contact",
3  "help", "team", "office", "billing", "noreply", "no-reply",
4]);
5
6const FREE_PROVIDERS = new Set([
7  "gmail.com", "outlook.com", "hotmail.com", "yahoo.com",
8  "icloud.com", "aol.com", "proton.me", "protonmail.com",
9  "gmx.com", "mail.com", "yandex.com", "live.com", "msn.com",
10]);
11
12export function worthResolving(email: string): boolean {
13  const [local, domain] = email.trim().toLowerCase().split("@");
14  if (!local || !domain) return false;
15  if (FREE_PROVIDERS.has(domain)) return false;
16  if (ROLE_PREFIXES.has(local)) return false;
17  return domain.includes(".");
18}

Keep this list short and do not try to make it exhaustive. There are thousands of public mailbox providers and maintaining a complete blocklist is not a good use of anyone's time. The point is to skip the round trip for the 30% of signups that are obviously consumer addresses, not to replicate the API's validation.

One product decision this surfaces: if a meaningful share of your signups are public mailbox addresses, that is worth knowing on its own. It usually means either your ICP is smaller than you think, or your form is not asking for a work email clearly enough.

Do not send enrichment data to the browser

This is the security mistake I see most often, and it is easy to make because the data is right there in the handler that renders the response.

If your signup endpoint returns the enriched company object to the client, you have built a public lookup API. Anyone can submit an arbitrary email to your signup form, watch the network tab, and read the resolved person and company back. You are now paying to serve free enrichment to anyone who finds it, and exposing data about people who never interacted with your product.

The rule is that enrichment stays server-side. The client receives a decision, not a record.

python
1# Wrong: leaks the full record
2return {"account_id": account_id, "enrichment": result.person}
3
4# Right: return only the branch
5return {"account_id": account_id, "next": "sales_handoff"}
ts
1// Wrong
2return { accountId, enrichment: result.person };
3
4// Right
5return { accountId, next: "sales_handoff" };

If the UI genuinely needs to display something, like prefilling a company name the user can edit, return only that one field and nothing else. A prefilled company name is a nice touch. Returning headcount, industry, the person's full employment history, and their profile picture is a data exposure with a nice touch attached.

Related: never put the API key in client-side code. The SDK reads RENIDLY_API_KEY from the environment for exactly this reason. A key in a bundle is a key someone else is spending.

Cache your own results

Repeat identical calls are served from a short-lived cache on the API side, but you should still keep your own resolution table. Two reasons: your cache outlives theirs, and a local hit costs zero latency rather than a network round trip.

The unit to cache is the domain, separately from the person. Company firmographics for northwind-logistics.com are identical for every signup from that domain, and at a company with 200 employees signing up over a year, that is 200 resolutions of the same company.

python
1def resolve_with_cache(email: str):
2    domain = email.rsplit("@", 1)[1].lower()
3
4    cached_company = company_cache.get(domain)          # your table
5    result = renidly.emails.reverse(email)
6
7    if result.current_company:
8        company_cache.put(domain, result.current_company, ttl_days=30)
9
10    return result, cached_company or (result.current_company if result else None)

Set a real TTL. Company records go stale, headcount changes, and acquisitions happen. Thirty to ninety days is a reasonable window for firmographics. Person records go stale faster, because people change jobs, so refresh those on a shorter cycle or on access.

Store the stable identifiers alongside the cached values. Person records carry prsn_ IDs and organizations carry org_ IDs, and those never change even when handles and slugs do. Refresh against the ID, never against the handle you originally saw.

Make the worker idempotent

Queues retry. Your enrichment worker will run twice on the same account, and if it is not idempotent you get duplicate writes, doubled cost tracking, and occasionally a record that flips between two states.

Key on the account plus the email, record the attempt before the call, and check it after:

python
1def enrich_account(account_id: str, email: str) -> None:
2    key = f"enrich:{account_id}:{email.lower()}"
3
4    if not claim_once(key, ttl_hours=24):
5        return   # already done or in flight
6
7    try:
8        result = renidly.emails.reverse(email)
9    except RenidlyError:
10        release(key)   # allow a retry
11        raise
12
13    write_enrichment(account_id, result)
14    mark_complete(key)

Release the claim on transient failures so a retry can proceed, but hold it on permanent ones like 403 opt-outs and 422 rejections. Those will not succeed on a second attempt, and retrying them is spend with a guaranteed zero return.

Instrument it from the first call

Four metrics. Emit them from day one, because reconstructing this later from invoices is miserable.

python
1metrics.increment("enrichment.attempted")
2metrics.increment(f"enrichment.outcome.{outcome}")        # found / unresolved / error
3metrics.increment(f"enrichment.confidence.{result.confidence}")
4metrics.gauge("enrichment.credits", result.meta.credit_consumed)
5metrics.gauge("enrichment.balance", result.meta.remaining_balance)
6metrics.timing("enrichment.latency_ms", elapsed_ms)
ts
1metrics.increment("enrichment.attempted");
2metrics.increment(`enrichment.outcome.${outcome}`);
3metrics.increment(`enrichment.confidence.${result.confidence}`);
4metrics.gauge("enrichment.credits", result.meta.creditConsumed);
5metrics.gauge("enrichment.balance", result.meta.remainingBalance);
6metrics.timing("enrichment.latency_ms", elapsedMs);

The .meta object sits outside the response data, so result.person is your data and result.meta.credit_consumed is your billing telemetry with no field collision.

Two alerts worth having on day one:

Balance floor. remaining_balance below a threshold pages someone. A backfill job that drains the pool your signup path depends on is a production incident, and it will happen at least once.

Resolution rate drop. If your found rate falls sharply week over week, something changed. It might be your traffic mix, it might be a form change that started accepting consumer addresses, it might be a bug in your pre-filter. Either way you want to know from a dashboard rather than from a rep complaining about empty records.

Test without spending credits

Your test suite should not make network calls. Stub at the SDK boundary and assert on your branching logic, which is where the bugs actually live.

Cases worth having explicit tests for:

  • found: true, confidence high, full person and company
  • found: true, confidence medium, person only, company null
  • found: true, confidence low, company only, person null
  • found: false
  • InvalidRequestError for an unsupported address
  • PermissionDeniedError for an opted-out address
  • RateLimitError with retry_after set
  • APIConnectionError on timeout

The partial-result cases in the middle are the ones teams skip and the ones that break in production. A company object with a null person is a normal, useful response, and code that assumes both are present will throw on a null attribute access at 2 a.m.

For an end-to-end smoke test, use a single real call against a known address in a staging job, not in CI. CI runs hundreds of times a day and each run would be billed.

Progressive profiling: use what you already resolved

Once enrichment is working, the payoff is form fields you no longer need to ask for.

The naive version prefills the company name. The better version removes questions entirely. If you resolved headcount, do not ask for company size. If you resolved industry, do not ask for industry. Every field removed is measurable conversion.

Be careful with two things.

Let people correct it. Resolution at medium confidence is right most of the time and wrong sometimes. A prefilled field the user can edit is helpful; a hidden field they cannot see or fix is a support ticket.

Do not act on low. A low-confidence resolution should never silently route someone to a sales handoff or a pricing tier. Stage it, let a human confirm, or ask the question you were going to skip.

The build order

  1. Ship the asynchronous worker. No latency risk, immediate CRM value, and it produces the resolution rate data you need for everything else.
  2. Measure for two weeks. Resolution rate, confidence distribution, cost per usable record, p95 latency.
  3. Only then decide whether the branching is worth a hybrid design. If your resolution rate is 40%, synchronous branching helps under half your signups and the complexity is probably not justified.
  4. Add progressive profiling last, once you trust the data enough to remove form fields based on it.

Most teams do this in reverse, start with a synchronous call in the request handler, and discover the problem during their first traffic spike.

bash
1pip install renidly
bash
1npm install renidly

Both SDKs cover every endpoint and handle auth, retries, pagination, batch jobs, rate limiting, and typed errors. Async clients are available in both (AsyncRenidly in Python, and every Node method is already async). The free tier includes 100 credits with no card required, which is enough to run your real signup table through and get a resolution rate before you write any integration code.

The Quickstart takes about five minutes. For volume pricing, an SLA, or a procurement conversation, get in touch.

Try Renidly free

One API to enrich, search and verify any B2B identity. 100 credits on us.

Start Free
  • 100 free credits on signup
  • No credit card required
  • Credits never expire
// Keep reading

Related articles

All articles →
Reverse Email Lookup: Turning a Work Address Into a Person and CompanyFeatured
//Guides

Reverse Email Lookup: Turning a Work Address Into a Person and Company

A work email address is the most underused field in B2B software. It is the one thing you get from almost every inbound…

Aug 2, 202610 min readRead
What Is B2B Identity Resolution? A Technical Primer
//Guides

What Is B2B Identity Resolution? A Technical Primer

What Is B2B Identity Resolution? A Technical Primer Every B2B company runs on a fiction: that the person in the CRM, the person…

Jul 31, 202613 min readRead
Get started in minutes

Ship your first resolution today.
resolution today.

Get your API key on signup. Pay only for what you call.

Start
Free

100 credits on signup

Scale
From $50

Pay-as-you-go top-ups

Volume
Custom

Negotiated + SLA

Try for freeSee full pricing
  • No credit card
  • No subscription
  • Credits never expire
  • 24/7 support
Renidlyrenidly

The identity layer modern teams build on. One API to enrich, search, and verify any email, domain, company, or profile. Powered by real-time lookup and a large-scale identity graph.

Start FreeSign in
Product
  • Data API
  • Live API
  • Email API
Developers
  • Documentation
  • Quickstart
  • Integrations
  • API Reference
Company
  • Pricing
  • Blog
  • Trust Center
  • Contact
Legal
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Refund Policy
  • Data Processing Agreement
  • Security Policy
  • Do Not Sell / Opt Out
© 2026 Renidly · operated by Droven Data Strategy LLC. All rights reserved.
GDPRCCPA
renidly