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.
| Architecture | Use when | Latency budget | Failure cost |
|---|---|---|---|
| Asynchronous | Data lands in the CRM for later use | None | Nothing user-facing |
| Synchronous | The next screen changes based on the result | 400 to 800 ms | A stalled form |
| Hybrid | You want branching but cannot risk the stall | 400 ms, then abandon | Falls 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.
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 )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.
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 result1import { 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:
- 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.
- A timeout well below your request timeout. If your server times out at 30 seconds, enrichment gets 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.
1renidly = Renidly(config=RenidlyConfig(timeout=3, max_retries=0))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.
Do a cheap local pre-filter for the obvious cases, then let the API handle the rest:
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 domain1const 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.
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"}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.
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:
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.
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)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, confidencehigh, full person and companyfound: true, confidencemedium, person only, company nullfound: true, confidencelow, company only, person nullfound: falseInvalidRequestErrorfor an unsupported addressPermissionDeniedErrorfor an opted-out addressRateLimitErrorwithretry_aftersetAPIConnectionErroron 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
- Ship the asynchronous worker. No latency risk, immediate CRM value, and it produces the resolution rate data you need for everything else.
- Measure for two weeks. Resolution rate, confidence distribution, cost per usable record, p95 latency.
- 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.
- 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.
1pip install renidly1npm install renidlyBoth 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.

