What Is B2B Identity Resolution? A Technical Primer
Every B2B company runs on a fiction: that the person in the CRM, the person who filled out the demo form, the person on the support ticket, and the person in the billing system are four separate people.
They are usually one person. Your systems just cannot prove it.
That gap is what identity resolution closes. This post covers what the problem actually is at a technical level, why the obvious solutions break down at production scale, and how to build a resolution layer that returns a canonical record instead of a guess.
It is written for engineers and data leads who are evaluating whether to build this internally or call it as a service. There is working code in Python and Node throughout.
The problem, stated precisely
You have fragments. Each system holds a partial view of the same entity:
- Your signup form captured
[email protected]and nothing else. - Salesforce has "Jane Doe, VP Operations, Northwind Logistics Inc."
- Your product analytics has a user ID and a company domain.
- Your CRM has a separate account record for "Northwind Logistics LLC" created by a different rep eighteen months ago.
- Your support tool has "[email protected]", a legacy address that still forwards.
Five records. One person. One company. No shared key.
Identity resolution is the process of taking any one of those fragments and returning the complete, canonical entity behind it, along with a measure of how confident that match is.
Two things make this genuinely hard, and neither is obvious until you have tried it.
Entities are not stable. Jane gets promoted. Northwind gets acquired and rebrands. Jane leaves for a competitor. The record that was correct in March is wrong in September, and nothing in your system knows that.
Identifiers are not unique. There are thousands of Jane Does. There are dozens of companies called Northwind. Names are not keys, and treating them as keys is the root cause of most enrichment failures.
Why string matching fails
The first thing every team builds is a fuzzy matcher. Normalize the strings, compute a similarity score, match above a threshold. It works in the test suite and then it does not work in production.
Here is why, with real failure classes:
Legal suffix noise. "Northwind Logistics", "Northwind Logistics Inc", "Northwind Logistics, Inc.", "Northwind Logistics LLC", and "Northwind Logistics Incorporated" are one company and five strings. Stripping suffixes helps until you hit two genuinely different companies that differ only by suffix.
Domain is not identity. Enterprises run dozens of domains. Acquisitions bring more. Regional subsidiaries have their own. Meanwhile hundreds of thousands of distinct companies share gmail.com, and marketing agencies share a single domain across client work. Domain is a strong signal and a terrible primary key.
Subsidiary collapse. Alphabet, Google, YouTube, and DeepMind resolve to different answers depending on whether you are doing revenue attribution or territory assignment. A matcher that cannot express corporate hierarchy will silently pick one and be wrong for half your use cases.
Name transliteration. The same person appears as different strings across scripts and locales. Latin transliteration is not deterministic, so two systems will produce two spellings of the same name and neither is wrong.
Temporal drift. This is the one that eventually forces a rewrite. Your matcher was right. Then the person changed jobs. Fuzzy matching has no concept of time, so it cannot tell you that a correct match has become a stale one.
The lesson is that similarity is not identity. A resolution layer needs to return an entity, not a score against a string.
Anatomy of a resolution
A resolution takes an input fragment, and returns a canonical record plus a confidence signal.
The Renidly API exposes this as four practical entry points, because in practice you only ever have one of four things:
| You have | You want | Endpoint |
|---|---|---|
| A work email address | The person and their company | GET /api/emails/v1/reverse |
| A profile handle or a stable ID | The full professional record | GET /api/data/v1/people/profile |
| A name and a company domain | The work email address | GET /api/emails/v1/find |
| A company slug or stable ID | The full organization record | GET /api/data/v1/companies/company |
All three products authenticate with one header on every request:
1X-renidly-apikey: <your-api-key>And every endpoint returns the same envelope, whether it succeeded or failed:
1{
2 "success": true,
3 "statusCode": 200,
4 "message": "Profile retrieved successfully",
5 "errors": null,
6 "data": { }
7}Branch on success, not on the HTTP status. This matters more than it sounds, and the next section explains why.
Stable identifiers are the actual product
This is the part most teams underweight, and it is the difference between a resolution layer that holds up and one that quietly rots.
Every entity carries an opaque, prefixed, permanent identifier:
| Prefix | Entity |
|---|---|
prsn_ | Person |
org_ | Organization |
inst_ | Educational institution |
skl_ | Skill |
Example: prsn_06d0d44dogo2m, org_5ehe8408s7tme.
These IDs are deliberately meaningless. You cannot parse them, guess them, enumerate them, or derive anything from them. That is the point. They are pure references.
The alternative is storing a public handle or a company slug, both of which are human-readable and both of which can change. When someone updates their profile handle, every record you keyed on that handle breaks, and it breaks silently. You do not get an error. You get a 200 response with success: false and a not-found code, and if your integration only checks HTTP status you will write a null over a good record.
The correct pattern is resolve once, store the ID, use the ID forever:
1from renidly import Renidly
2
3renidly = Renidly() # reads RENIDLY_API_KEY from the environment
4
5# First contact: resolve the handle you were given
6person = renidly.data.people.retrieve(handle="ryanroslansky")
7
8if person:
9 # Persist the stable id, not the handle
10 store_person_id(internal_user_id, person.id) # e.g. "prsn_06d0d44dogo2m"1import { Renidly } from "renidly";
2
3const renidly = new Renidly(); // reads RENIDLY_API_KEY from the environment
4
5const person = await renidly.data.people.retrieve({ handle: "ryanroslansky" });
6
7if (person) {
8 await storePersonId(internalUserId, person.id);
9}Every subsequent refresh uses the ID, and the ID never goes stale.
There is a second class of opaque token worth the same discipline: pagination cursors, which look like cur_5ehe8408s7tme. Pass them back exactly as received. Do not parse them, do not increment them, do not attempt to construct one to skip ahead. They are black boxes and treating them otherwise produces 400 responses at best.
Path one: an email address in, a person and company out
This is the highest-leverage resolution in B2B because it is the one your signup form gives you for free. One field, and you can populate an entire account record before the user reaches the second screen.
1from renidly import Renidly, RenidlyError
2
3renidly = Renidly()
4
5result = renidly.emails.reverse("[email protected]")
6
7if result.found and result.confidence in ("high", "medium"):
8 person = result.person
9 company = result.current_company
10
11 print(person.full_name, person.title)
12 print(company.name, company.website, company.staff_count)1import { Renidly } from "renidly";
2
3const renidly = new Renidly();
4
5const result = await renidly.emails.reverse("[email protected]");
6
7if (result.found && ["high", "medium"].includes(result.confidence)) {
8 const { person, currentCompany } = result;
9 console.log(person.fullName, person.title);
10 console.log(currentCompany.name, currentCompany.website);
11}Three behaviors to design around.
It is business addresses only. Public mailbox providers, role accounts like info@ or support@, disposable addresses, and relay addresses are rejected with 422 before any work runs. Nothing is charged. This is a feature, not a limitation: it means you can send your entire signup table at it without paying to discover that half of it is Gmail. Filter on your side anyway if you want to save the round trip, but you will not be billed either way.
Confidence is four-valued, not boolean. You get high, medium, low, or none. Route on it. A high match can write directly to the CRM. A medium match should populate fields but flag the record for review. A low match should go to a human queue. Treating all matches as equal is how bad data gets into a system that people trust.
Partial results are real results. The response carries person and current_company independently, and either can be null. A resolution that identifies the company but not the individual is still useful for routing and scoring. Handle it rather than discarding it.
Path two: verify before you act
Resolution tells you who someone is. Verification tells you whether the address still works. They are different questions and you usually need both.
1check = renidly.emails.verify("[email protected]")
2
3print(check.deliverable) # True / False
4print(check.reason) # mailbox_accepts, catch_all, risky, ...
5print(check.catch_all) # True when the domain accepts everything1const check = await renidly.emails.verify("[email protected]");
2
3console.log(check.deliverable, check.reason, check.catchAll);The reason field is an enum, and each value implies a different action:
reason | What it means | What to do |
|---|---|---|
mailbox_accepts | The mailbox exists and accepts mail | Send |
mailbox_rejected | The mailbox does not exist | Suppress permanently |
catch_all | The domain accepts everything, so the specific mailbox is unconfirmable | Send with caution, monitor bounce rate |
risky | Deliverable but with warning signals | Route to a low-volume segment |
invalid_syntax | Malformed address | Fix at the point of capture, this is a form validation bug |
disposable | Throwaway provider | Suppress, and consider blocking at signup |
relay | Forwarding or masking service | Suppress from cold outreach |
no_mx | The domain has no mail exchanger configured | Suppress permanently |
The catch_all case is the one that costs teams money. On a catch-all domain, every address returns deliverable, including addresses that do not exist. If you treat catch-all as a clean verify and load it into a sequence, your bounce rate climbs weeks later and by then it has affected your sending reputation. Segment it separately from the start.
Path three: a name and a domain in, an email out
The reverse direction. You know who you want to reach and where they work, and you need the address.
1found = renidly.emails.find(
2 first_name="Jane",
3 last_name="Doe",
4 domain="northwind-logistics.com", # bare hostname, no scheme, no @
5)
6
7if found.found and found.confidence == "high":
8 queue_for_outreach(found.email)1const found = await renidly.emails.find({
2 firstName: "Jane",
3 lastName: "Doe",
4 domain: "northwind-logistics.com",
5});
6
7if (found.found && found.confidence === "high") {
8 await queueForOutreach(found.email);
9}The domain parameter is strict: a bare hostname containing a dot. Passing https://northwind-logistics.com or [email protected] returns a 400 with the offending field named in errors. This is worth handling explicitly, because user-supplied company fields are full of full URLs and your validation layer should normalize before the call rather than after the error.
Confidence here is two-valued (high or low), which is a narrower signal than reverse resolution provides. Treat low as a candidate rather than an answer.
Freshness is a parameter, not a property
Most enrichment vendors give you one freshness setting and you live with it. A resolution layer that takes it as a per-request parameter is a materially different tool, because different jobs have genuinely different requirements.
Two modes:
Dataset read. Query a clean, deduplicated, queryable set of records. Fast, consistent, and the right default for bulk work, analytics, and anything where you are processing thousands of records and a small amount of staleness is acceptable. This is the Data API at /api/data/v1.
On-demand resolution. Resolve one subject and return the freshest available snapshot. The right choice when a human is waiting, when the answer feeds a decision that is expensive to get wrong, or when you specifically need the current state of something that changes. This is the Live API at /api/v2.
They share the key, the envelope, and the credit balance, so you mix them per call rather than choosing an architecture:
1# Bulk nightly refresh: dataset read, tuned for throughput
2for row in renidly.data.people.search(
3 organization_slugs="northwind-logistics",
4 current_only=True,
5).auto_paging_iter():
6 upsert(row)
7
8# A rep just opened the account: resolve fresh
9snapshot = renidly.live.people.enrich(handle="jane-doe")1// Bulk nightly refresh
2for await (const row of renidly.data.people.search({
3 organization_slugs: "northwind-logistics",
4 current_only: true,
5})) {
6 await upsert(row);
7}
8
9// Interactive path
10const snapshot = await renidly.live.people.enrich({ handle: "jane-doe" });One naming detail that will save you an afternoon: Data API filter parameters keep their snake_case names in both languages, while Live API filter parameters keep their camelCase names in both languages. The method names follow the language convention (snake_case in Python, camelCase in Node) but the filter keys follow the API. This is deliberate and consistent once you know it.
Not-found is an answer, and it is a billed one
This is the single most common integration bug, so it is worth stating plainly.
A single lookup that resolves nothing returns HTTP 200, not 404. On the Data API it carries success: false and a not-found error_code (1010 for a person, 1020 for an organization, 1030 for a skill, 1040 for an institution). On the Live API it carries a not-found message.
In both cases the call is billed, because a real lookup was performed on your behalf. A definitive "this does not exist" is a result, and producing it costs the same work as producing a record. That is exactly why the status is 200: the request succeeded, and the answer happens to be empty.
Two consequences worth designing around.
Branch on success, not on HTTP status. If your client treats 200 as a hit, a miss will look like a success and you will write null into a populated field.
Budget for your miss rate, not your hit rate. If you are enriching a list where sixty percent resolves, you are paying for one hundred percent of the lookups. Measure your resolution rate on a sample before you size a batch job, because the cost model is per lookup attempted, not per record returned.
The SDKs surface the miss cleanly: retrieve() returns None in Python and null in Node when nothing resolved. If you would rather have it raise, set raise_on_not_found (Python) or throwOnNotFound (Node) on the client config.
Cost is observable per call
Enterprise buyers care about this more than throughput, because unpredictable spend is what kills renewals.
The model is simple:
- You pay for completed lookups. A lookup that runs and returns nothing is a completed lookup and is billed, because the work was performed either way.
- You do not pay for requests that never ran. Validation errors, auth failures, rate-limit rejections, and transient errors are never charged.
- Identical repeat requests are served from a short-lived cache at no cost. You do not opt in and you do not choose; it is automatic per call.
- Most endpoints cost one credit. The exceptions are published at
GET https://renidly.com/api/panel/credits/routes/costs/, which is public and needs no key. Any route absent from that list costs one credit, andcredits_cost: 0means free.
Every SDK response carries a .meta object with the billing outcome for the call that produced it:
1p = renidly.data.people.retrieve(handle="ryanroslansky")
2
3print(p.meta.credit_consumed) # e.g. 1.0
4print(p.meta.remaining_balance) # e.g. 19813.01const p = await renidly.data.people.retrieve({ handle: "ryanroslansky" });
2
3console.log(p.meta.creditConsumed, p.meta.remainingBalance);The .meta object sits outside the response data, so person.headline is your data and person.meta.credit_consumed is your billing telemetry, with no collision. It is present on single objects, on list pages, and on every item within a page. During auto-pagination each page is a separately billed request, so each item reports its own page's cost.
Cached responses report 0. Endpoints billed per result report the real amount, so a prospects call returning eighteen addresses reports eighteen.
Emit these to your metrics pipeline from day one. Cost per resolved record is the number your finance team will ask for, and reconstructing it later from invoices is miserable.
Putting it together: a resolution service
Here is the shape of a production resolution endpoint. Single email in, canonical record out, with confidence-based routing and typed error handling.
1from renidly import (
2 Renidly,
3 RenidlyConfig,
4 InvalidRequestError,
5 RateLimitError,
6 InsufficientCreditsError,
7 RenidlyError,
8)
9
10renidly = Renidly(config=RenidlyConfig(
11 timeout=15,
12 max_retries=3,
13 auto_rate_limit=True,
14))
15
16
17def resolve_signup(email: str) -> dict:
18 try:
19 result = renidly.emails.reverse(email)
20 except InvalidRequestError as e:
21 return {"status": "invalid", "fields": e.field_errors}
22 except RateLimitError as e:
23 return {"status": "retry", "after": e.retry_after}
24 except InsufficientCreditsError:
25 alert_ops("renidly balance exhausted")
26 return {"status": "degraded"}
27 except RenidlyError as e:
28 log.warning("resolution failed", code=e.error_code, msg=e.message)
29 return {"status": "error"}
30
31 if not result.found:
32 return {"status": "unresolved"}
33
34 record = {
35 "person": result.person,
36 "company": result.current_company,
37 "confidence": result.confidence,
38 "cost": result.meta.credit_consumed,
39 }
40
41 if result.confidence == "high":
42 record["route"] = "auto_write"
43 elif result.confidence == "medium":
44 record["route"] = "write_and_flag"
45 else:
46 record["route"] = "human_review"
47
48 return {"status": "resolved", **record}1import {
2 Renidly,
3 InvalidRequestError,
4 RateLimitError,
5 InsufficientCreditsError,
6 RenidlyError,
7} from "renidly";
8
9const renidly = new Renidly(undefined, {
10 timeout: 15_000,
11 maxRetries: 3,
12 autoRateLimit: true,
13});
14
15export async function resolveSignup(email: string) {
16 let result;
17
18 try {
19 result = await renidly.emails.reverse(email);
20 } catch (e) {
21 if (e instanceof InvalidRequestError) {
22 return { status: "invalid", fields: e.fieldErrors };
23 }
24 if (e instanceof RateLimitError) {
25 return { status: "retry", after: e.retryAfter };
26 }
27 if (e instanceof InsufficientCreditsError) {
28 await alertOps("renidly balance exhausted");
29 return { status: "degraded" };
30 }
31 if (e instanceof RenidlyError) {
32 log.warn("resolution failed", e.errorCode, e.serverMessage);
33 return { status: "error" };
34 }
35 throw e;
36 }
37
38 if (!result.found) return { status: "unresolved" };
39
40 const route =
41 result.confidence === "high" ? "auto_write"
42 : result.confidence === "medium" ? "write_and_flag"
43 : "human_review";
44
45 return {
46 status: "resolved",
47 person: result.person,
48 company: result.currentCompany,
49 confidence: result.confidence,
50 cost: result.meta.creditConsumed,
51 route,
52 };
53}Note auto_rate_limit / autoRateLimit. Turning it on lets the SDK hold you under your per-minute ceiling using a sliding sixty-second window, which is considerably better than discovering the limit through 429 responses in production. Standard keys read the limit from your tier automatically. Enterprise keys need an explicit rate_limit_per_minute / rateLimitPerMinute because the limit is negotiated rather than tiered.
Four anti-patterns
Storing handles as foreign keys. Covered above, and it is the most expensive mistake on this list because it fails silently and you discover it during a quarterly data audit.
One resolution call per record in a loop. If you are processing a list, use the batch endpoints. They accept up to 1000 items, return a job_id immediately, resolve at a controlled pace in the background, and let you stream results as they land. Firing a thousand parallel single calls will hit your rate limit and produce a worse outcome slower.
Ignoring confidence. A resolution layer that writes every match at equal trust is a data quality incident waiting for a trigger. The confidence field exists so you can route, not so you can log it.
Resolving the same record repeatedly because you did not store the result. Repeat identical calls are free from cache, so this is not a billing catastrophe, but it is latency you are paying for nothing. Store the canonical ID and the resolution timestamp, then refresh on a schedule rather than on every read.
Where to start
If you are evaluating this, the fastest meaningful test is not a profile lookup. It is running your existing signup table through reverse resolution and measuring two things: what percentage resolves at high confidence, and how the company records compare to what your CRM currently believes.
That comparison is usually the moment the build-versus-buy conversation ends, because it quantifies the staleness you already have rather than the accuracy you might gain.
1pip install renidly1npm install renidlyBoth SDKs cover every endpoint, handle auth, retries, pagination, batch jobs, and typed errors, and stay correct as the API evolves. There is a free tier with 100 credits and no card required, which is enough to run a real sample of your own data rather than someone else's demo record.
The Quickstart takes about five minutes. If you are working through a procurement process or need volume pricing and an SLA, talk to us directly.