Developers › Reference & tools

API reference

Developer Reference API Reference Every endpoint the API publishes, generated from the OpenAPI specification so this page and the spec cannot disagree. Ingestion endpoints authenticate with an X-API-Key; key management is admin-only and uses your LeadLogiX login session.

Every endpoint

Every endpoint Generated from the spec, like everything on this page. Method Path What it does Auth Source: openapi.yaml ( ) — the same file your codegen reads. There are no SDKs; generate a client from it ( how ). Every status code and code these endpoints return, and their rate limits, are on Ingestion errors & limits .

Which group do you need?

Which group do you need? Five groups. Most integrations touch one. Group Reach for it when The guide that explains it Ingestion You want conversations scored. This is the one almost everybody needs — notify, then poll. Ingest a session Identity You want to know whether a caller is already known, or whether two sessions are the same person — and to follow the pointers back. Recognise a returning customer for the live case; Identity & Profiles API for the reference Profiles You want to assert that conversations belong to one person for a reason LeadLogiX cannot see. Rarely needed — profiles are built automatically. Identity & Profiles API Keys Managing ingestion keys from your own tooling. Admin login session, not an API key. API Playground for the manual path Notices Contract changes announced ahead of time, machine-readable. Poll it in CI. Changelog Reading scored leads is not REST: an OAuth application uses MCP → Hosted server . GET /api/leads , if you find it, is the dashboard's own endpoint and refuses an ingestion key.

Ingestion

Ingestion Notify LeadLogiX a session is ready, then poll its status. POST /api/v1/sessions — Notify that a session is ready to score. Queues a finished session for processing. Returns 202 immediately. Re-notifying the same sessionId is idempotent — it never re-queues. Add `X-LeadVal-Sandbox: true` (plus your login token as a Bearer) to run in the isolated sandbox namespace. ⚠ BEFORE YOU NOTIFY: WRITE THE CONVERSATION TO THE DOCUMENT STORE **ONCE**, NOT IN CHUNKS AS THE SESSION RUNS. LeadLogiX reads the assembled document after this call, so anything the store did not keep is simply absent from the lead — and the store drops the messages of a write that arrives while it is still indexing the previous one, while still answering 200. Measured on synthetic documents: one write carrying 8 messages keeps 8; one write every 12 s keeps 8; one every 3 s keeps 4–5; writes back-to-back keep 1–2 — present on every run, varying with load. A connector cannot detect the loss from any response, so buffer the transcript, send it in a single write with the complete metadata block, and then notify. If you must stream, leave at least 12 seconds between writes and treat that as a floor with no margin. ⚠ TWO RELATED STORE BEHAVIOURS WITH THE SAME REMEDY. A write carrying only metadata and no messages is refused with HTTP 400, so a final "end of session" write that carries no speech stores nothing. And a metadata key written after the document reports `done` becomes visible and is then discarded once re-indexing settles — so waiting for a late key is waiting for something that may be taken back. The single write at session end avoids all three. ⚠ THE 202 IS A QUEUE HAND-OFF, NOT AN ACCEPTANCE OF THE DOCUMENT. Conformance is judged later, against the document as assembled. Poll the status endpoint; a session missing a required metadata key surfaces it there within seconds as `processing.awaitingMandatoryKeys` long before any verdict exists. X-LeadVal-Sandbox (header) Set `true` to run in the isolated sandbox namespace. ⚠ SANDBOX NEEDS **TWO** CREDENTIALS, WHICH IS UNUSUAL AND EASY TO MISS. The request must carry the ingestion key in `X-API-Key` **and** a signed-in person's LeadLogiX login session token in `Authorization: Bearer <token>`. The token's user must belong to the same workspace as the key. Without the token the call is `401 UNAUTHENTICATED`; with a token from another workspace it is `403 TENANT_MISMATCH`. That is deliberate: the sandbox executes the real pipeline, so it is gated on a human session rather than on a key alone. Unattended automation therefore cannot use sandbox mode — omit the header and run for real, or drive the sandbox from the API Playground. { "sessionId": "conv_9F3A21", "processor": 2 } { "status": "ACCEPTED", "sessionId": "string", "alreadySubmitted": false, "processing": "…" } GET /api/v1/sessions/{sessionId}/status — Poll processing status and conformance. Returns processing state and conformance notes. Never returns PII, score, or price — safe to log or surface. Poll until processing.state is terminal (PROCESSED, DEGRADED, or FAILED). ⚠ `processing.awaitingMandatoryKeys` IS A DIAGNOSIS, NOT A VERDICT. While the source conversation is still being assembled, a document whose content has been written but whose metadata has not yet landed publishes the missing required keys here, under `missingMandatoryKeys` and `mandatoryKeys` — the SAME two key names the terminal `rejection` object uses. A client that pattern-matches on `missingMandatoryKeys` without checking WHICH object it is in will abandon a session that is about to succeed. The session is still QUEUED; keep polling. The keys usually arrive. ⚠ AND THAT WAIT IS BOUNDED IN THE HUNDREDS OF SECONDS, NOT IN SECONDS. A session whose required metadata never appears is held for roughly 200 seconds across four re-reads before it is rejected, so a genuinely non-conformant session now reaches its terminal state minutes after notify rather than immediately. `awaitingMandatoryKeys` appears within seconds and names the key, which is why it exists: it is the fast half of a slow verdict. ⚠ `processing.deficiencies` NAMES WHAT FAILED, ON DEGRADED AND FAILED. A sentence such as "Lead degraded processing failed after 5 reprocessing attempt(s)" does not say whether the document was deficient or our pipeline was, so the stage codes are reported structurally: CLEARING_NARRATIVE_EXTRACTION_FAILED, IDENTITY_RESOLUTION_FAILED, COREFERENCE_TRANSCRIPT_TURN_LOSS and their siblings. Branch on the codes, never on the sentence. An empty array means the failure was not attributable to a named stage — it does not mean nothing was wrong. ⚠ A FAILED SESSION IS RETAINED FOR MINUTES AND THEN DELETED, AFTER WHICH THIS ENDPOINT ANSWERS 404. A failure record is diagnostic, so it carries a short deletion deadline (platform setting `analyticsBbnSettings.purgeRetentionFailureMinutes`, seeded 60) rather than the multi-year retention a processed lead gets, and a cleanup pass that runs every five minutes deletes it once that deadline passes. From then on the status call returns `404 SESSION_NOT_FOUND` — the identical code to "this workspace never notified that session", because nothing remains to tell them apart. So a 404 is NOT proof of non-delivery, and a rejection you did not read inside the window cannot be re-derived here later. Record `rejection` when you first see it. That retention is a live setting an operator can change with no deploy; changes are announced on `GET /api/v1/notices`. sessionId (path, required) X-LeadVal-Sandbox (header) Set `true` to run in the isolated sandbox namespace. ⚠ SANDBOX NEEDS **TWO** CREDENTIALS, WHICH IS UNUSUAL AND EASY TO MISS. The request must carry the ingestion key in `X-API-Key` **and** a signed-in person's LeadLogiX login session token in `Authorization: Bearer <token>`. The token's user must belong to the same workspace as the key. Without the token the call is `401 UNAUTHENTICATED`; with a token from another workspace it is `403 TENANT_MISMATCH`. That is deliberate: the sandbox executes the real pipeline, so it is gated on a human session rather than on a key alone. Unattended automation therefore cannot use sandbox mode — omit the header and run for real, or drive the sandbox from the API Playground. { "sessionId": "string", "ingestedAt": [ "string", "null" ], "processorMode": [ "integer", "null" ], "processorLabel": "REALESTATE", "conformance": { "level": "string", "reason": "string", "evaluatedAt": [ "string", "null" ], "notes": [ { "code": [ "string", "null" ], "path": [ "string", "null" ], "message": [ "string", "null" ] } ] }, "processing": { "state": "string", "reprocessingCandidate": false, "waitingForSource": false, "awaitingMandatoryKeys": { "missingMandatoryKeys": [ "string" ], "mandatoryKeys": [ "string" ], "message": "string" }, "lastAwaitingMandatoryKeys": { "missingMandatoryKeys": [ "string" ], "mandatoryKeys": [ "string" ], "observedAt": [ "string", "null" ], "message": "string" }, "errorMessage": [ "string", "null" ], "deficiencies": [ "string" ], "rejection": { "code": "INGEST_CONTRACT_VIOLATION", "missingMandatoryKeys": [ "string" ], "mandatoryKeys": [ "string" ], "rejectedAt": [ "string", "null" ], "message": [ "string", "null" ] } } }

Identity

Identity Advanced — decide whether a session belongs to a returning visitor. POST /api/v1/identity/lookup — Does this workspace already know this person?. For an AI agent in the first seconds of a live conversation. Send whatever identifying signals you have — at minimum a device fingerprint, plus an email, phone or name if the conversation has surfaced one — and LeadLogiX searches your own processed leads for the same person. Unlike the two `resolve` endpoints, you do NOT supply candidates: this searches for them. It returns POINTERS, never customer data — a lead id per match, and a customer profile id where one exists. Follow them with `GET /api/v1/leads/{leadId}/context` and `GET /api/v1/profiles/{profileId}`, with the same ingestion key — an API key is not an OAuth token and cannot be exchanged for one, so the MCP tools are not reachable with it. If you DO hold an OAuth token, the `get_lead` and `get_customer_profile` tools are the same composition behind a different door. A preliminary answer by design. Device signals alone can never produce `EXACT_MATCH`: without a matching name, email, phone or voice signature the verdict is clamped to `POSSIBLE_MATCH`. ⚠ Read `nameUse` on the match row you are acting on — `PERMITTED` or `WITHHOLD` — rather than deriving permission from `verdict`, `band` or `corroboratedBy`. `nameUseReason` says why it was withheld. The same field with the same two values appears on every conversation returned by the `get_customer_profile` tool, so one rule covers both surfaces. ⚠ The TOP-LEVEL `constraintsFired` unions every candidate scored, near misses included, so it can name a clamp that a DIFFERENT person's row fired — a clean `EXACT_MATCH` returned beside `SHARED_CONTACT_NO_NAME` is that, not a contradiction. Each match row carries its own `constraintsFired`. `decision.activeGuards` lists the protections the workspace's tuning can fire at all. `customerProfileId` is commonly `null`: profiles exist only once two or more conversations have been merged, so a first-time or never-merged customer has none. The `leadId` is always present, so there is always something to fetch. Access is OFF by default and is enabled by LeadLogiX per workspace on request — until then, calls return `403 IDENTITY_API_NOT_ENABLED`. Included in your plan; not separately billed. { "verdict": "string", "confidence": [ "number", "null" ], "constraintsFired": [ "string" ], "catalogVersion": [ "string", "null" ], "resolutionProfileId": [ "string", "null" ], "candidatesConsidered": 0, "truncated": false, "matches": [ { "leadId": "string", "conversationId": "string", "customerProfileId": [ "string", "null" ], "processorMode": [ "integer", "null" ], "band": [ "string", "null" ], "posterior": [ "number", "null" ], "lastSeenAt": [ "string", "null" ], "corroboratedBy": [ "string" ], "nameUse": "string", "nameUseReason": [ "string", "null" ], "constraintsFired": [ "string" ], "channels": [ { "id": "string", "result": "string" } ] } ], "nearMisses": [ { "leadId": "string", "conversationId": "string", "customerProfileId": [ "string", "null" ], "processorMode": [ "integer", "null" ], "band": [ "string", "null" ], "posterior": [ "number", "null" ], "lastSeenAt": [ "string", "null" ], "corroboratedBy": [ "string" ], "nameUse": "string", "nameUseReason": [ "string", "null" ], "constraintsFired": [ "string" ], "channels": [ { "id": "string", "result": "string" } ] } ], "nearMissesTruncated": false, "decision": { "band": [ "string", "null" ], "sufficiency": [ "object", "null" ], "clamps": [ "string" ], "resolutionProfile": [ "string", "null" ] }, "evidenceParsed": [ "string" ], "retrieval": { "tokensSupplied": 0, "tokensQueried": 0, "tokensCapped": false, "candidatesConsidered": 0, "truncated": false } } GET /api/v1/leads/{leadId}/context — Follow a lookup pointer to the lead behind it. The return leg of `identity/lookup`. That endpoint answers with POINTERS — a `leadId`, sometimes a `customerProfileId` — and this is how a caller holding an ingestion key dereferences one. An OAuth caller uses the `get_lead` MCP tool instead; both compose through the same functions, so the pay-per-lead paywall, the role filter and the personal-data scrub behave identically whichever credential you hold. Returns only YOUR workspace's leads: the workspace comes from the key and is re-checked against the document, so a lead id from anywhere else is refused rather than read. Gated on the same Identity Resolution grant that let you obtain the pointer. ⚠ Read `contactAvailability` before saying anything about the person. `NOT_RECORDED` is the only value that means "we do not have their details". `WITHHELD_UNPURCHASED` means this workspace HAS them and has not bought this lead yet — offer to unlock it rather than denying knowledge of a returning customer. `AVAILABLE` means the contact block is populated. leadId (path, required) { "lead": "object" } GET /api/v1/profiles/{profileId} — Follow a lookup pointer to the merged customer behind it. The profile half of the return leg. Takes the `customerProfileId` an `identity/lookup` match carried — always `cprof_…` — and returns that person's history across every conversation LeadLogiX has linked to them. ⚠ Three different things get called a "profile id" and passing the wrong one is answered by name rather than as an ordinary not-found: `cprof_…` is the merged CUSTOMER and the only one this takes; `cprofile_…` is an internal tag no API returns; and a resolution profile (`forensic`, `preliminary`) is a scoring catalog, not a person. A bare 404 for those would read as "this customer has no profile", which is the opposite conclusion. A profile whose every member conversation has been deleted is `404` rather than an empty success: it exists and has nothing left to say. A profile that was MERGED into another is not served either. The answer is `404` with code `PROFILE_MERGED` and a `mergedInto` field carrying the `cprof_…` id of the profile it was merged into — the same person, whose history now lives there; ask again with that id. Any other profile that is not currently available answers exactly like an unknown id. profileId (path, required) { "profile": "object" } POST /api/v1/identity/resolve — Lightweight returning-visitor check. Compares a current session against candidate prior sessions and returns a match verdict (same person?), a confidence, and a decision band. Stateless — you supply the candidates. The detailed `evidence` schema is provided by EVAS on request (support@evas.live). Most integrations don't need this — the standard pipeline already recognizes returning visitors. Access is OFF by default and is enabled by LeadLogiX per workspace on request — until then, calls return `403 IDENTITY_API_NOT_ENABLED`. { "advisory": "string" } POST /api/v2/identity/resolve — Full returning-visitor resolution (up to 20 candidates). A more thorough resolution than v1. Supply the current session plus up to 20 candidates. Same auth and error model as v1. Evidence schema via EVAS. Needs the same per-workspace access grant as v1 (`403 IDENTITY_API_NOT_ENABLED` until LeadLogiX enables it).

Profiles

Profiles Advanced — merge conversations into a customer profile (async, off by default). POST /api/v1/profiles/merge — Merge conversations into a customer profile (async). Submits a new conversation plus the earlier conversations you believe belong to the same person. Zero-trust: you pass only conversation IDs (never internal tags or an asserted result) — the identity engine re-fetches and re-verifies every pair, merging only what the evidence confirms. Returns `202` with a `requestId` and a `statusUrl` to poll. Most integrations don't need this — the standard pipeline builds Customer Profiles on its own. Access is OFF by default and is enabled by LeadLogiX per workspace on request — until then, calls return `403 PROFILES_API_NOT_ENABLED`. Only conversations that were scored for a line of business can join a profile. A conversation that was processed for identity only, or for automation only, produces no line-of-business record and is refused. ⚠ WHICH conversation is ineligible decides what you get back, and this paragraph used to describe only one of the two. A **candidate** is refused softly: the request is accepted, and that conversation is reported in the status response with `action: NOT_ELIGIBLE` and `reason: PIPELINE_DEPTH_MODE`. The **anchor** — the `conversationId` the merge is founded on — cannot be refused softly, because with no eligible anchor there is no profile to found and therefore no status document to report into: the call answers `400 PROFILE_ANCHOR_INELIGIBLE` and says so. Before 2026-09-10 it answered `400 VALIDATION_ERROR` with a message about the candidate array, which was the wrong array to go looking at. { "requestId": "string", "status": "QUEUED", "statusUrl": "string" } GET /api/v1/profiles/merge/{requestId}/status — Poll a profile-merge outcome. Returns the request's status (`QUEUED` → `RUNNING` → `DONE`, or `REJECTED`). On completion it returns a safe profile reference — a public profile id and a live dashboard link — plus per-conversation outcomes. `DONE` means the merge completed; the AI-written summary builds asynchronously, so `summaryState` tells you whether it's `READY` yet and the link always shows the current state. A request that doesn't exist or belongs to another workspace returns the same `404` (no cross-workspace enumeration). requestId (path, required) { "requestId": "string", "status": "DONE", "profile": [ "object", "null" ], "memberOutcomes": [ { "conversationId": "string", "action": "string", "linkType": [ "string", "null" ], "posterior": [ "number", "null" ], "reason": [ "string", "null" ] } ] }

Keys

Keys Mint and manage ingestion keys (admin login, not an API key). POST /api/tenant/keys/generate — Mint a new ingestion API key (admin). Authenticated with your admin **login session** token (Bearer), NOT an ingestion key — you don't need a key to create one. Easiest in Settings → API Playground. The value is shown once. { "generatedApiKey": "val_key__…" } DELETE /api/tenant/keys/{keyId} — Revoke a key immediately (admin). `keyId` is the key's id (its hash) as shown in the Playground — not the cleartext value. keyId (path, required) POST /api/tenant/keys/{keyId}/status — Activate or deactivate a key (admin). keyId (path, required)

Notices

Notices What changed in this contract, and when. No authentication. GET /api/v1/notices — Contract change notices, newest first. Every change that alters what your code must send, or what it can expect back. ⚠ NO AUTHENTICATION. It carries strictly less than `openapi.yaml`, which is also public, and requiring a credential to answer "did anything change" is backwards for the question you most need to answer before you have one. ⚠ READ `revisionStatus`, NOT `revision`. Marking a fix with a deployed revision id is our answer to "shipped is not deployed" — but the changes that hurt most are DATA: a mandatory ingestion key added to the live `sla_signals` registry, a calibration enforcement flip. Neither has a revision to cite, and both change the contract. "No deploy required" is a reason a change needs MORE announcement, not less. This endpoint exists for that class — which is exactly why `revision: null` could not be the field to read: it meant BOTH "there is no revision and there never will be" and "this is code that has not shipped yet", and a caller polling "is it live?" must wait on the second and never on the first. `revisionStatus` separates them, in four values: · `DEPLOYED` — `revision` names the revision this shipped on. · `PENDING` — merged, not yet deployed. A later entry will name the revision. ⚠ **`PENDING` CAN LAG REALITY BY ONE DEPLOY, STRUCTURALLY.** An entry naming a revision can only be written once that revision exists, so it always ships in the NEXT one — which means a change can be LIVE while its entry still reads `PENDING`. Resolve it without asking us: compare `servingRevision` against the revision named in the most recent `DEPLOYED` entry, and if `servingRevision` is newer, anything still marked `PENDING` between them may already be live. A property of an append-only log, not a lapse. · `NOT_APPLICABLE` — a DATA change. There is no revision and there never will be. Stop asking. · `UNDECLARED` — this log did not say. Treat it as UNKNOWN, never as `NOT_APPLICABLE`. Entries written before the field existed carry it. ⚠ `NOT_APPLICABLE` and `UNDECLARED` are trivially confusable and mean opposite things to a poller: "never, stop waiting" versus "unknown, keep asking". If you branch on one value only, branch on `DEPLOYED`. ⚠ `servingRevision` on the envelope is the revision ACTUALLY ANSWERING THIS REQUEST. Compare it against a notice's own `revision` to tell "merged" from "live" yourself, rather than taking our word for it. It is reported on the 503 branch too, because a build fault is only actionable if you know which build. ⚠ `notices[0]` IS NOT THE NEWEST ENTRY. Undated standing sections lead the list, so index 0 is normally the standing-commitments preamble, whose `date`, `class`, `actionRequired` and `revision` are all null. Select on `date != null` before taking a first element. ⚠ A MISSING ARTIFACT ANSWERS 503, NEVER AN EMPTY LIST. "No notices" and "the build did not produce the file" are different answers, and a CI job diffing this must not read a build fault as "nothing changed". Cached for 300 seconds. The human-readable original is `docs/INTEGRATOR_NOTICES.md`, which is NOT served — that is why this endpoint exists. { "success": true, "count": 49, "source": "docs/INTEGRATOR_NOTICES.md", "servingRevision": "leadval-00284-9fh", "notices": [ { "date": [ "string", "null" ], "title": "string", "class": "additive, non-breaking", "actionRequired": "none", "revision": "leadval-00276-tzh", "revisionStatus": "DEPLOYED", "body": "string" } ] }