Developers › Ingestion API

Session object

Developer Reference Session object The two payloads you work with: the notify envelope you send, and the status document you poll. Both are intentionally minimal. You are Read Writing the notify call Notify envelope — two fields, and the sessionId rules are stricter than they look Writing the poller Processing states — which are terminal, or you poll forever on DEGRADED Looking at a rejected session Session metadata contract and Contract rejections Writing a conversation record Transcript grammar and Container tags Building it for the first time Ingest a session , which walks the flow; this page is the field-level detail behind it

Notify envelope

Notify envelope POST /api/v1/sessions — the entire body. Field Type Required Rules sessionId string Required ≤ 128 chars. Most characters are fine — control characters, path traversal (../), percent-encoding and markup are rejected, so ids like sess_2026-08-09.a1 are valid. It must still contain at least one letter or digit after punctuation is removed: an id such as "---" or "___" is refused with 422 ENVELOPE_INCOMPLETE. processor integer Required The line of business (or pipeline depth) this session belongs to — see Processor modes below for the current set. A strict integer: strings and floats are rejected. The accepted set is a registry and grows, so read the 422 PROCESSOR_UNSUPPORTED response, which enumerates every mode this server accepts, rather than hard-coding a list. Idempotent by sessionId The sessionId is the dedup key — read the next callout for exactly how two ids are compared. Re-notifying returns 202 with alreadySubmitted: true and never disturbs the existing lead's state or queue position, so a retry after a timeout is safe. ID normalisation silently merges sessions: demo-1, DEMO_1 and demo.1 are one Dedup does not compare the id you sent. It compares the id with every character that is not a letter or digit removed, and the rest upper-cased. So sess-1 , sess_1 , Sess.1 and SESS1 are one session. The first is queued and scored; every later one returns 202 ACCEPTED and is never fetched and never scored — the status code alone cannot tell you that happened. Make your ids unique after that projection. A random or sequential alphanumeric suffix is safe ( conv_9F3A21 , chat2026081401 ). Distinguishing two sessions only by a separator or by letter case is not — chat-1 versus chat_1 is the usual way this happens. Two signals tell you that you collided. The notify response carries alreadySubmitted: true — treat it as an alarm, not as a benign retry, unless you know you retried. And the status document echoes the id of the session that actually holds the slot, so if GET /api/v1/sessions/SESS1/status comes back with "sessionId": "sess-1" , an earlier conversation owns it. The key is scoped to your workspace, and sandbox and live are counted separately — so your testing cannot collide with your production traffic, and another workspace's ids can never collide with yours.

Status document

Status document GET /api/v1/sessions/:sessionId/status { "sessionId": "conv_9F3A21", "ingestedAt": "2026-07-25T14:03:11.000Z", "processorMode": 2, "processorLabel": "REALESTATE", "conformance": { "level": "ACCEPTED_WITH_NOTES", "evaluatedAt": "2026-07-25T14:03:20.000Z", "notes": [ { "code": "METADATA_SHAPE_INCOMPLETE", "path": "speakers", "message": "…" } ] }, "processing": { "state": "PROCESSED" } } Field What it is Read it as sessionId The id of the session that holds this slot Echoes the session that owns the dedup key — if it differs from the id you asked about, an earlier conversation took it ( ID normalisation ). ingestedAt When the notify was accepted ISO-8601. processorMode , processorLabel The line of business or pipeline depth An unfamiliar label is an unknown line of business — never real estate. processing.state The completion signal — see Processing states The only field to gate on. processing.waitingForSource The record is still being assembled Present with QUEUED . A wait, not a failure. processing.awaitingMandatoryKeys , processing.lastAwaitingMandatoryKeys Which mandatory keys are late — now, and the last time A diagnosis, not a verdict — Ingest a session , step 4. processing.rejection Why a contract violation was refused Only on FAILED ; missingMandatoryKeys names the keys. Branch on its code . processing.errorMessage A sanitised reason for any other failure Read it on FAILED when there is no rejection — a refused document is reported here, not in the notes. conformance.level A report on the payload — see Conformance levels Never the outcome of the session. conformance.notes[] code , path , message Match on the code; the message is prose and may be reworded. conformance.evaluatedAt , conformance.reason When it was graded; why it never will be null with a reason on NOT_EVALUATED — settled, not pending. By design the status document returns no names, phone numbers, scores, prices or vault contents, so it is safe to log or surface in your own dashboards.

Processing states

Processing states processing.state State Meaning Terminal? QUEUED Accepted, waiting for a worker. May include waitingForSource: true while the conversation is still being assembled. No PROCESSING A worker has claimed the session and is running the pipeline. No PROCESSED Finished successfully. The lead is scored and available. Yes DEGRADED Finished, but a component fell back — the lead is flagged for reprocessing. Yes FAILED Could not be processed. Includes a sanitized errorMessage . Yes

Session metadata contract

Session metadata contract What the conversation record must carry before you notify. The notify call is two fields. Everything the pipeline actually reads lives on the conversation record your Agent writes, and that record has a contract. Get a mandatory key wrong and the session is rejected outright — it is never scored, and it never reaches your Clearing Pool. Key Tier What it is sessionId Mandatory The canonical id. Must equal the id you notify with. processor Mandatory The line of business — the SAME integer you send as processor on the notify call, and the two must agree. Lets the statistical machine compare this lead only against leads from the same line. content Mandatory The conversation itself — the turns, as text. started Mandatory When the conversation began. embedding Mandatory Where it happened — the placement/host context object. fingerprint Expected Device/session signals. Absent, LeadLogiX cannot recognise a returning visitor. speakers Expected Who was in the conversation. Drives speaker attribution. agent Expected Which Agent handled it. Drives per-agent analytics. totalDuration Expected Conversation length. Feeds pacing and the sub-twenty-second filter. Eleven further keys are optional and purely additive — consent , ended , endReason , lastDuration , segments , prompt , function , experiment , leadvalSpecVersion and the two emb* context keys. The marketing-attribution fields — pageUrl , referrer , utm , clickIds — are optional too, but they live inside the mandatory embedding object rather than beside it. Omitting any of them costs you the feature it feeds; the session is never rejected for it. One optional key is not free: leadvalSpecVersion Leaving it out never rejects anything, but it always adds a SPEC_VERSION_MISSING note — which is enough to move conformance.level from CONFORMANT to ACCEPTED_WITH_NOTES . Send leadvalSpecVersion: "1.0" and the note goes away. It is also how LeadLogiX knows which contract generation your connector was built against, which is worth more than the note costs. Three ways to fail one key, and all three count A key is unusable if it is absent , present with the wrong type (a JSON object sent as a string that does not unwrap), or present, right type, and empty of the fields that give it meaning. A downstream consumer cannot tell those apart — none of them yields a value — so all three are reported, and for a mandatory key all three reject. Expected is not optional-with-a-nicer-name A missing expected key never rejects the session. It produces a conformance note and silently costs you a capability: no fingerprint means every visit looks like a first visit, so returning-customer recognition and profile stitching cannot run for that session. Structured values are stringified exactly once Metadata values are stored as strings, so a structured value — fingerprint , speakers , embedding — must be the result of exactly one serialisation of the source object. Never re-serialise a value that is already a string, and do not let a transport layer add an escaping pass. The stored string must parse in a single strict pass, with no literal \n or \t escapes between JSON tokens. Multi-escaped payloads are recorded as violations and may fail to parse at all, discarding every signal they carried.

Transcript grammar

Transcript grammar content — the shape the parser actually reads. You don't write this text — storage assembles it You send structured messages to raw lead storage — each with a role , an optional name , and its content . Storage joins them into the single transcript below, and that is what LeadLogiX reads. This section exists so you can tell what your messages become — the only thing you control directly is the name field, which is where the speaker id and the tool name go. content is a sequence of turns joined by a delimiter: a blank line, a run of hyphens, and a blank line. Each turn is a header line followed by its body, and the header declares the role. Storage always writes exactly three hyphens, but the reader is deliberately wider than that: two or more hyphens count, and spaces or tabs are allowed around them and on the blank lines. Write the three-hyphen form; assume the wider form when you decide what to allow through from a visitor. content [USER (spk:bbc4783ec6876ae80ebf7d7d8c75fb6b "Kendar")] Hello! I'm Kendar. What can you show me here? --- [ASSISTANT (Sarah)] Hi, I'm here to help. What are you looking for? --- [TOOL (open_listing)] {"mlsNumber":"73530260","boardId":68} --- [TOOL (open_listing)] {"status":"ok","data":{"listPrice":749000}} The last two turns are a tool call and its result . They look identical on purpose — storage renders both as [TOOL (name)] , and LeadLogiX tells them apart from the shape of the payload, not the header. Send them as two plain tool messages, adjacent and in order. Role Header carries Why it matters USER spk:<id> and optionally a claimed name in quotes The speaker id is the identity ; a quoted name is only a claim , verified independently. Name-only headers are accepted but leave multi-speaker sessions unresolvable — and a user claiming the agent's name becomes undetectable. ASSISTANT The agent's name MUST equal agent.agentAddressName . That equality is how agent identity is told apart from a user's claim. TOOL The tool name Both the invocation and its result. One JSON object per turn — never a batch. Always set name to the tool: without it the payload falls back to shape-detection and you lose what you already knew. SYSTEM — ⚠ Storage discards system messages — they never reach LeadLogiX. Anything LeadLogiX must see belongs in session metadata instead. Three fields to leave alone — each loses data silently All three were measured on live sessions. Every one returns success and produces a document, and none of them produces a conformance note or any signal on the status endpoint — the checks that run over content only confirm that each turn opens with a valid header, and all three of these mistakes leave the header valid and damage the body. To catch them, read the assembled record's content while you are building the connector and check it against the example above; once you are live, the symptom is a lead that is thinner than the conversation was. 1 · tool_call_id — does not add a correlation id. Storage renders it as an extra [Tool Response: …] line inside the tool body, which stops that body being a single JSON object, so every fact in that payload is dropped. Pairing does not need it: calls and results are matched by order and payload shape. 2 · tool_calls[] on an assistant message — produces no tool turn at all. The invocation is folded into the assistant's own spoken text as -> name({args}) , losing the call and corrupting the language analysis run over what the agent said. Send every tool call as its own tool message. 3 · a timestamp appended to name — there is no timestamp field, and appending one puts it inside the parentheses where it is not read. On an assistant message it also breaks the agent-name match, so your agent stops being recognised as itself. Sanitise a visitor's claimed name before you send it Storage passes ] , ) and " through unescaped, and each one breaks the header. A ] is the worst: the turn loses its speaker id and the name, and the leftover header text spills into the message body as though the visitor had said it. Strip those three characters from any name you did not generate yourself — an empty name is handled cleanly, a half-broken one is not. A visitor's message is rendered verbatim — neutralise the separator and declare every voice Whatever a visitor types is written into content exactly as typed, which means the turn separator and the header line are not reserved characters — they are just text a visitor can produce. Two habits close this, and both are yours to implement because nothing downstream can distinguish a separator you sent from one the visitor typed. 1 · Neutralise the separator sequence in user-authored text. Before you send a user message, break any line that is only hyphens when it sits between blank lines — indent it, prefix it, or collapse the blank line above it. Remember the reader is wider than the writer: two hyphens with padding counts. Left alone, a visitor pasting a markdown horizontal rule ends their turn there; the words after it are folded back into the same turn so nothing is lost, but a visitor who follows the rule with a [USER (spk:… "Name")] or [ASSISTANT (…)] line gets a real, separate turn attributed to someone who never spoke. 2 · Declare every speaker in speakers . This is the defence that actually holds, and it is why speakers is more than analytics. LeadLogiX compares each USER turn's spk: id against the ids you declared, and a turn whose id you never declared is withheld from the participant model entirely — it becomes no participant, no contact vector and no stakeholder, and is recorded as an undeclared speaker instead. Send an id for every genuine voice, including the visitor. Send speakers or you get no filtering at all. With no declared ids there is nothing to compare against, so every speaker in the transcript is admitted — which is correct for older records that never declared any, and is exactly the gap a forged turn walks through. If your emitter sends spk: ids on its turns, it must also declare them. Habit 2 covers USER turns only — a speaker id is a USER concept, so a forged [ASSISTANT (…)] line is not caught by it. That one is caught by habit 1, and by the rule that an assistant header must match agent.agentAddressName . Do both; neither is sufficient alone. Four things that corrupt the record No cross-turn stamps — never repeat a correlation id or transport artifact on turns it does not belong to; it pollutes the utterance text and corrupts linguistic analysis. No processed user text — no spell-correction, translation, redaction or summarising of what a person said. No synthetic turns — never insert a greeting or confirmation that did not happen. No secrets — redact at your boundary and record the redaction visibly ( "apiKey":"[REDACTED]" ) rather than quietly reshaping the payload.

Container tags

Container tags The label that decides whether your document is ever opened. A container tag decides two things: which workspace may retrieve the document, and which cohort the session joins when baselines are computed. Getting it wrong is the most common way a correct integration produces nothing at all. Position Tier Tag Who sets it You write it? 1st Workspace tag_<workspace>_<8 hex> Minted when your API key is created Always — first 2nd Global pool global_anonymous_pool Platform constant, identical for every workspace Always — after your workspace tag Later Profile cprofile_… Added by LeadLogiX after processing, when a session joins a customer profile Never Order matters: your workspace tag first Write the tags in exactly this order: "containerTags": ["tag_evasglobal_9c4e17ab", "global_anonymous_pool"] The document store makes a document searchable only under its first tag, and LeadLogiX looks up your history — the similar past leads a new lead is compared against, and the earlier sessions that let it recognize a returning visitor — under your workspace tag. A document with any other tag first still processes, but it is never compared with your history, and your later sessions are never compared with it. The pool tag is still required; it simply comes second. A new session whose first tag is not your workspace tag is refused. GET /api/v1/sessions/:id/status returns a terminal { state: "FAILED" } whose errorMessage begins Container tag order violation and names the tag that must come first. Changing the tags afterwards does not help — an update does not reorder them — so rewrite the conversation with the tags in order, then notify again once the refusal has been deleted . A failed session is kept for a short retention period (60 minutes by default, removed within five minutes after that); until then, notifying the same session answers alreadySubmitted: true with the same refusal and nothing is read again. After it, the status call answers 404 and a notification is a new session. A session whose document was already read and admitted is not re-checked if it is reprocessed. You do not construct your workspace tag — you read it out of your API key. The key is a composite, and the tag sits between the workspace id and the final entropy segment. Deriving it means a copy-paste error between two settings cannot happen: // val_key__<tenantId>__<containerTag>__<entropy> // The tag is part of the credential. Derive it; never store it separately. // Take everything BETWEEN the workspace id and the trailing entropy segment: // "__" is legal inside a tag, so splitting and taking [2] can truncate it. const parts = apiKey.split("__"); const containerTag = parts.slice(2, -1).join("__"); // "tag_evasglobal_9c4e17ab" # val_key__<tenantId>__<containerTag>__<entropy> # The tag is part of the credential. Derive it; never store it separately. # Take everything BETWEEN the workspace id and the trailing entropy segment: # "__" is legal inside a tag, so splitting and taking [2] can truncate it. parts = api_key.split("__") container_tag = "__".join(parts[2:-1]) # "tag_evasglobal_9c4e17ab" A document without your tag is, to your key, a document that does not exist Before a single line of transcript is read, LeadLogiX requires the tag bound to your API key to appear in the document's tags, compared by exact string equality — no trimming, no case folding. If it does not, the document is treated as not found : the transcript is never read, no conformance is ever evaluated, and no lead is scored. Retrieval by session id is account-global, so this is the boundary that stops one workspace's key from reading another's conversations — and it answers exactly as for a document that was never written, so a key cannot even learn that someone else's document exists. It is reported, not silent — after the readiness wait. The session record is created when you notify, so the outcome has somewhere to land. While it waits, GET /api/v1/sessions/:id/status shows waitingForSource: true ; after the readiness budget (8 checks, about ten minutes) it returns a terminal { state: "FAILED" } whose errorMessage names your workspace tag and both possible causes. Verbatim, so you can match on it: No document for this session was found in this workspace after 8 readiness checks: it was never written to the archive, or it was written without this workspace's tag ("tag_…"). Write it with that tag first in its containerTags, then notify again. What you will not see is a conformance note. Those checks run on a document LeadLogiX declined to open, so no report exists and conformance.level is the terminal NOT_EVALUATED , with reason: "NO_CONFORMANCE_REPORT_RECORDED" . That is a settled answer, not a "not yet" — read processing.errorMessage on a FAILED session, not the notes. While it waits, a document not written yet and one written without your tag look the same — QUEUED with waitingForSource: true — so if that lasts more than a minute or two, check the document's tags first. Extra tags are harmless — after the first two — and inert Your own tags — a campaign id, an environment marker — may ride along after your workspace tag and the pool tag, and are ignored. Put one first and the session is refused. They also segment nothing: history lookups run under your workspace tag only. Use metadata for dimensions you want to filter on. A document with no tags is refused rather than waved through; an untagged document cannot be attributed to anyone. Writing to the document after you notify can strip its customer-profile link After processing, LeadLogiX may add a cprofile_… tag linking the session to a customer profile. Because writes upsert, a later write that re-sends only your own two tags can strip the tag LeadLogiX added . Finish writing, then notify — never the other way around. Copy the tag; never rebuild it from the workspace name The tag_<workspace>_<hex> shape is a minting convention, not a validated rule. LeadLogiX compares your tag to the key's tag; it parses neither. A workspace provisioned by hand can hold a tag in another shape entirely and work fine — which is exactly why the rule is copy, not reconstruct.

Minimum fingerprint

Minimum fingerprint Enforced only when a fingerprint is present. fingerprint is expected, not required — a session without one is never rejected. But a fingerprint that is present must carry a minimum set, or it is recorded as METADATA_SHAPE_INCOMPLETE . The minimum is deliberately built from fields that exist regardless of incognito mode, browser family or device class : Field Why it is always obtainable fingerprintId The identifier your emitter computes on every capture. staticTraits.browserUserAgent Sent on every HTTP request; no browser can suppress it. staticTraits.timezoneIana Available in every JS runtime. staticTraits.languagePreferences navigator.language is universal. transientMetadata.ipAddress Observed server-side; a client cannot withhold it. capturedAt or timestamp The capture time, under either key. Everything else is explicitly not required Canvas, audio, WebGL, font and plugin hashes; hardware memory, client hints and network info; battery level; local IP; media-device hashes — all optional, because a privacy-respecting browser legitimately withholds them. Requiring any of them would reject users for their browser choice rather than integrations for their mistakes, so the contract never does. Send what you have.

Contract rejections

Contract rejections When a session is missing required context. If the conversation record is missing keys the pipeline requires, the session fails with a structured rejection naming them. This is the only feedback channel for a contract violation — the keys are listed so your Agent can fix exactly what was wrong rather than guess from prose: { "processing": { "state": "FAILED", "rejection": { "code": "INGEST_CONTRACT_VIOLATION", "missingMandatoryKeys": ["started", "embedding"], "rejectedAt": "2026-07-25T14:03:20.000Z", "message": "…" } } } A rejected session is discarded, not retried Re-notifying the same sessionId will not reprocess it — the id is the dedup key and it is already spent. Fix the conversation record, then notify under a new sessionId .

Conformance levels

Conformance levels conformance.level — a report on the payload, not the outcome of the session. Level What it actually means What it does NOT mean CONFORMANT The payload checks produced zero notes. Not a quality verdict, and not a target. A perfectly good session lands on ACCEPTED_WITH_NOTES whenever the written summary has not landed yet — see Verifying your integration . ACCEPTED_WITH_NOTES The payload checks produced at least one note. That is the whole rule — the level is a count of notes, nothing more. Not a statement that the session was accepted , despite the name. A session rejected for a missing mandatory key reports this level too, with the fatal METADATA_KEY_MISSING notes sitting alongside the advisory ones. Read processing.state for the verdict. PENDING Not evaluated yet . The session is still in flight — processing.state beside it is QUEUED or PROCESSING . Keep polling; a grade is coming. Not a grade, and never permanent. It is never returned beside a terminal processing.state — if the session has finished and no report exists, you get NOT_EVALUATED instead. NOT_EVALUATED No grade is coming, ever. The session reached a terminal processing.state and no report was produced. reason names why — AUTOMATION_ONLY_NO_DOCUMENT_FETCH (processor 0 never opens the source document, and the contract is evaluated against that document's metadata) or NO_CONFORMANCE_REPORT_RECORDED (the session finished without one being written, which is what a document refused at fetch looks like). evaluatedAt is present and null : the question was settled, and the answer is that nothing was evaluated. Not a failure and not a rejection. A mode-0 session reporting it alongside { state: "PROCESSED" } ran exactly as designed. Read reason , then stop polling — it will not change. How to read a note Notes describe the payload, never the lead: they never lower a score and never change a price. Match on the code — the message is prose and may be reworded. Almost every code is an advisory improvement for your Agent team. The exception is the three METADATA_* codes — METADATA_KEY_MISSING , METADATA_VALUE_TYPE_INVALID and METADATA_SHAPE_INCOMPLETE — which are advisory on an expected key and fatal on a mandatory one , matching the three-ways-to-fail rule above. When one of them lands on a mandatory key, processing.state is FAILED and processing.rejection.missingMandatoryKeys names the keys. One question, one field Did it finish? processing.state . Is my payload clean? conformance.notes . Why was it refused? processing.rejection , or processing.errorMessage for a failure outside the metadata contract. Deriving any of the three from another is where integrations go wrong.