Developers › Recipes
Developer Guide Recognise a returning customer Your AI agent picks up a conversation. Has this person spoken to you before? This walks the whole flow end to end: ask LeadLogiX in the first seconds, read the answer without over-claiming, and pull the history into the model's context.
What you are building Three steps. Ask with POST /api/v1/identity/lookup on your ingestion key and receive a verdict and pointers; decide from nameUse on the row you act on, with no call; fetch the history by following the pointer — with the same key on your own workspace, or with the get_lead and get_customer_profile MCP tools and an OAuth token when you are an application installed on a customer's workspace. Ask — one HTTP call, ingestion key POST /api/v1/identity/lookup with whatever identifying signals you have. Returns a verdict and pointers — never customer data. Decide — no call, just reading the response The verdict says whether there is a match; nameUse on the row you act on says whether you may greet them by name. Fetch — follow the pointer The customer profile if the person's conversations have been merged, otherwise the lead. This is where the actual content lives. The credential follows whose workspace you are reading, not which step you are on. Your own: the key you already have, for every step. A customer's, because you are building something they install: an OAuth token their admin consented to, for every step. Before you start: An ingestion API key (val_key__…) — the same one you use to notify sessions. Identity access enabled for the workspace. Off by default; ask LeadLogiX. Without it every call returns 403 IDENTITY_API_NOT_ENABLED. Your agent collecting device information at the start of a conversation. Everything else is optional. For step 3, nothing extra if you are reading your own workspace — the same key follows the pointers. For step 3 as an INSTALLED APPLICATION on somebody else's workspace: an OAuth token with leads:read, plus profiles:read for merged histories, plus identity:lookup if you want to ask the question over MCP too.
Step 1 — ask Send as much as you have. A device fingerprint alone is enough to call; an email, phone or name makes the answer much stronger. Send it once, early, and never block on it: on 429 or any error, carry on as though the person were new — a stalled greeting is worse than a generic one. curl -X POST https://YOUR-HOST/api/v1/identity/lookup \ -H "X-API-Key: val_key__..." \ -H "Content-Type: application/json" \ -d '{ "evidence": { "fingerprint": { "staticTraits": { "canvasFingerprintHash": "…", "webglVendorHash": "…", "browserUserAgent": "Mozilla/5.0 (...)", "timezoneIana": "America/New_York" }, "transientMetadata": { "ipAddress": "203.0.113.7" } }, "phones": ["+1 (202) 555-0140"], "emails": ["jordan@example.com"], "names": { "claimedFirst": "Jordan" } } }' import requests evidence = { "fingerprint": { "staticTraits": { "canvasFingerprintHash": "…", "webglVendorHash": "…", "browserUserAgent": "Mozilla/5.0 (...)", "timezoneIana": "America/New_York", }, "transientMetadata": {"ipAddress": "203.0.113.7"}, }, "phones": ["+1 (202) 555-0140"], "emails": ["jordan@example.com"], "names": {"claimedFirst": "Jordan"}, } r = requests.post("https://YOUR-HOST/api/v1/identity/lookup", headers={"X-API-Key": "val_key__..."}, json={"evidence": evidence}, timeout=3) look = r.json() if r.ok else None # on any error, treat the caller as new A session you notified a moment ago is not searchable yet A lead becomes searchable once LeadLogiX has extracted a usable fingerprint, email or phone from it — not when it is scored, so identity-only records ( processorMode: 1 ) are found too. Sandbox runs never are, and failed runs are absent because nothing was assembled to index. When testing, poll GET /api/v1/sessions/:id/status until PROCESSED before you look the person up.
Step 2 — decide verdict What happened What to do EXACT_MATCH Matched, and a name, email, phone or voice signature agreed. Recognised customer. Fetch the history and use it. PROFILE_MERGE_DETECTED Two or more different customers matched. Fetch both for context, but every row returns nameUse: "WITHHOLD" with nameUseReason: "AMBIGUOUS_IDENTITY" — LeadLogiX cannot say which of them this is. Ask a disambiguating question. POSSIBLE_MATCH Matched, but nothing PERSON-LEVEL corroborated it — the same device, or one phone a household shares. Warm the context. Do not use a name you were not told in this conversation. SESSION_FORK The same identity in two places too far apart to be one person. A security signal. Attach no history. INSUFFICIENT_EVIDENCE Candidates existed but none had enough comparable signals. Treat as new. You may call again later once more has surfaced. NEW_PROFILE Nothing matched. Treat as new. Greet by name only when nameUse is PERMITTED on the row you act on Not from the verdict, the band, the highest posterior or corroboratedBy . A phone and a device shared by a household corroborate an address, not a person — the agent that derives permission from anything else greets a stranger by somebody else's name. nameUse is per row, and get_customer_profile returns the same field with the same two values on each conversation, so one rule covers both surfaces.
Reading the response Every field you will branch on, and what it does NOT mean. Field What it is Read it as verdict The engine's decision across every candidate Branch on it with the table above. The VERDICT INSUFFICIENT_EVIDENCE means candidates WERE retrieved and none cleared the evidence floor — a real answer. It is not the ERROR INSUFFICIENT_EVIDENCE_FOR_LOOKUP (400), which means you sent nothing searchable; fix the request for that one. matches[] Candidates scored at or above the bar, best first The rows you act on. Empty on NEW_PROFILE. matches[].leadId A pointer to the lead Always present — there is always something to fetch. matches[].customerProfileId A pointer to the merged customer null more often than not, and that is not an error: a profile exists only after two or more conversations were merged into one person. Fetch the lead instead. matches[].processorMode The line of business of that record 1 is an identity-only record: the device is recognised, but there is no score or narrative behind it. matches[].nameUse , nameUseReason PERMITTED or WITHHOLD , and why Whether you may address the person you are speaking to by a name they have not given you in this conversation. It is not a redaction rule for an operator reading their own records — what an operator may see is decided by the credential's scope ( leads:read.contact , profiles:read.contact ) and the lead's contactAvailability . Applying it to a records question produces answers that state a name and refuse to state it in one breath. matches[].corroboratedBy Which person-level channels agreed Tone, never permission. Device, network, timezone and location never appear in it. [] means the match rests on device signals alone — what a shared or public computer looks like — and a device fingerprint can never produce EXACT_MATCH : the engine clamps it, and you cannot opt out. An agreeing email or voice signature corroborates a person and reaches EXACT_MATCH ; ["contact-phone"] on its own does not. matches[].constraintsFired Why THIS row was held back SHARED_CONTACT_NO_NAME : one phone and one device shared, nobody named — a household. SHARED_SURNAME_NO_FORENAME : a surname matched on a shared device and nobody gave a first name; any first-name agreement lifts it, a nickname included (“Jon” against a stored “Jonathan” reaches EXACT_MATCH ), so ask for a first name, not a surname you already have. matches[].posterior The pair's probability Never a licence to pick one of several people: on PROFILE_MERGE_DETECTED the highest posterior is a coin flip presented as recognition. top-level constraintsFired Every clamp fired by EVERY candidate scored Not about your row: it unions near misses too, so a clean EXACT_MATCH can arrive beside a SHARED_CONTACT_NO_NAME somebody else's row fired. Read the row's own array, or just nameUse . nearMisses[] Candidates retrieved and scored BELOW the bar Never merged into matches . A pair that disagreed, or one with too little to judge — that one carries posterior: null , and the null is meaningful. The list to read before asking “have we spoken before?” rather than asserting it. retrieval.candidatesConsidered How many candidates were compared NEW_PROFILE with 0 does not mean “this person is new”: NOBODY was retrieved — your evidence carried no retrievable trait, or no other indexed lead shares one. retrieval.truncated The candidate set was capped An absent match under truncation is not evidence of absence. Send a stronger identifying signal and ask again. decision.activeGuards The guards this workspace's tuning runs Without SHARED_CONTACT_NO_NAME , household confusion was not screened for , so an unclamped match means less. Let your caution follow the rules in force.
Step 3 — fetch the history You hold Follow a leadId with Follow a customerProfileId with An ingestion key — the same one step 1 used GET /api/v1/leads/ {leadId} /context GET /api/v1/profiles/ {profileId} An OAuth token , because you are a third party on somebody else’s workspace the get_lead tool the get_customer_profile tool Both doors compose the same answer through the same functions: the pay-per-lead paywall, the role filter and the personal-data scrub behave identically whichever credential arrived, so an integration that starts on a key and later ships as an OAuth application does not re-learn what a lead looks like. A key cannot be exchanged for an OAuth token — there is no client-credentials grant. The tools themselves are on MCP → Hosted server . In what you fetch Read it as contactAvailability Four values, four different things to say out loud. NOT_RECORDED is the only one meaning you do not have their details. WITHHELD_UNPURCHASED : the workspace HAS them and has not bought this lead — offer to unlock it rather than telling a returning customer you have no record. WITHHELD_NO_PERMISSION : they exist and this authorization does not include them. AVAILABLE : the block is populated. Without it a masked lead and an anonymous one are byte-identical. verticalRollups[] A profile spanning two lines of business refuses identity.role , identity.budget and identity.financingStatus — the vocabularies are disjoint. Read the rollup, which quotes per line. Its budget and financingStatus are served only when exactly one conversation in that line could have produced them, and are null otherwise; role always survives. signals.verticalSignals[] The per-line read. Entries may carry recommendedChannel and followUpWindowHours , which appear on no other surface. signals.nonVerticalConversations[] Conversations that contributed no line-of-business signal — modes 0 and 1 among them, which also cannot join a profile. NOT_FOUND after a positive lookup is normal on the OAuth path — do not retry The lookup answers on your API key; MCP answers on the OAuth user, who may be restricted to particular tags, so a lead the lookup can see is not automatically one that user may read. On the API-key path the boundary is the workspace: another workspace's lead is 403 , one that does not exist is 404 .
Doing the same thing as an installed application Same question, same answer, different door. If you are building something other LeadLogiX customers install, you never hold their key. The identity_lookup tool on the hosted MCP endpoint asks the identical question and returns the identical answer: send email , phone , firstName , lastName or fingerprint — any one is enough, as flat singulars (sending the wire name emails is refused by name rather than silently dropped). It is the one permission where personal data travels towards LeadLogiX, which is why the consent screen flags it as personal data although the answer contains none. It needs the identity:lookup scope AND the workspace grant — without either, the tool is simply absent With either missing the tool is absent from tools/list rather than present and refusing, so “I cannot see the tool” is the expected symptom, not a bug. Revoking the workspace grant reaches tokens already issued.
The whole thing, in code // 1 ── ask, in the first seconds of the conversation const res = await fetch(`${LEADVAL}/api/v1/identity/lookup`, { method: "POST", headers: { "X-API-Key": INGEST_KEY, "Content-Type": "application/json" }, body: JSON.stringify({ evidence: { fingerprint, phones, emails } }), }); // Never block the conversation on this. if (!res.ok) return null; const look = await res.json(); // 2 ── decide if (look.verdict === "NEW_PROFILE" || look.verdict === "INSUFFICIENT_EVIDENCE" || look.verdict === "SESSION_FORK") return null; // SESSION_FORK: security signal const top = look.matches[0]; // ⚠ ONE FIELD, ON THE ROW YOU ARE ACTING ON. Do not derive this from the // verdict, the band or corroboratedBy — and do NOT read the top-level // look.constraintsFired as being about THIS row: that array unions EVERY // candidate scored, near misses included, so it can name a clamp that a // different person's row fired. Each row carries its own constraintsFired. const greetByName = top.nameUse === "PERMITTED"; // 3 ── fetch, with the same key (your own workspace). As an installed OAuth app, // call the get_customer_profile / get_lead MCP tools instead. const path = top.customerProfileId ? `/api/v1/profiles/${top.customerProfileId}` : `/api/v1/leads/${top.leadId}/context`; const history = await (await fetch(`${LEADVAL}${path}`, { headers: { "X-API-Key": INGEST_KEY }, })).json(); return { history, greetByName, // For tone when you may NOT use a name: "SHARED_CONTACT_NO_NAME" is a // household, "INSUFFICIENT_EVIDENCE" means the comparison never happened. reason: top.nameUseReason, }; import requests def recognise(fingerprint, phones, emails): # 1 ── ask, in the first seconds of the conversation try: res = requests.post(f"{LEADVAL}/api/v1/identity/lookup", headers={"X-API-Key": INGEST_KEY}, json={"evidence": {"fingerprint": fingerprint, "phones": phones, "emails": emails}}, timeout=3) except requests.RequestException: return None # Never block the conversation on this. if not res.ok: return None look = res.json() # 2 ── decide if look["verdict"] in ("NEW_PROFILE", "INSUFFICIENT_EVIDENCE", "SESSION_FORK"): return None # SESSION_FORK: security signal top = look["matches"][0] # ⚠ ONE FIELD, ON THE ROW YOU ARE ACTING ON. Never derive it from the verdict, # the band or corroboratedBy, and never read the top-level constraintsFired # as being about THIS row — it unions every candidate scored. greet_by_name = top["nameUse"] == "PERMITTED" # 3 ── fetch, with the same key (your own workspace). As an installed OAuth # app, call the get_customer_profile / get_lead MCP tools instead. if top.get("customerProfileId"): path = f"/api/v1/profiles/{top['customerProfileId']}" else: path = f"/api/v1/leads/{top['leadId']}/context" history = requests.get(f"{LEADVAL}{path}", headers={"X-API-Key": INGEST_KEY}, timeout=5).json() # For tone when you may NOT use a name: "SHARED_CONTACT_NO_NAME" is a # household, "INSUFFICIENT_EVIDENCE" means the comparison never happened. return {"history": history, "greet_by_name": greet_by_name, "reason": top.get("nameUseReason")}
Test your integration A device you have never sent Expect 200 , NEW_PROFILE , matches: [] . A 400 means your fingerprint is not reaching evidence.fingerprint . A session you have put through the pipeline Ingest, wait for PROCESSED , then look up the same fingerprint. Expect POSSIBLE_MATCH , that lead's id, and corroboratedBy: [] . Add the EMAIL from that conversation Expect EXACT_MATCH with corroboratedBy: ["contact-email"] . Now try the PHONE instead, still with no name Expect POSSIBLE_MATCH , corroboratedBy: ["contact-phone"] , the row’s own constraintsFired: ["SHARED_CONTACT_NO_NAME"] , and nameUse: "WITHHOLD" with nameUseReason: "SHARED_CONTACT_NO_NAME" . That is correct, not a wiring bug — a phone plus a device with nobody named is a household. The real wiring symptom is corroboratedBy staying EMPTY : the channel never arrived. Then check the row, not the response With more than one candidate indexed, confirm you read matches[0].constraintsFired , not the top-level array — a clean EXACT_MATCH beside a top-level SHARED_CONTACT_NO_NAME is somebody else's row, not a contradiction. Prove nothing personal came back Search the whole response for the customer's name, email and phone. All three must be absent — this endpoint returns pointers only. Errors: 400 INSUFFICIENT_EVIDENCE_FOR_LOOKUP is an integration bug (nothing searchable was sent), 403 IDENTITY_API_NOT_ENABLED means stop calling and ask LeadLogiX, and 429 means honour Retry-After and carry on as though the caller were new. A handshake is a lookup plus one read-back from a shared budget, so plan for half the lookup limit in handshakes. The rest: Ingestion errors & limits . You'll know it worked: A lookup answers in well under a second and your agent greets a returning customer with context it did not have to ask for — without ever naming someone it was not told about.