GuidesBusiness outcomes

Business outcomes

A business outcome is your own fact about a case: this case was resolved without an escalation, CSAT was 4, handle time was 6m12s, the refund was reversed. It lives in your helpdesk, your CRM, or your warehouse — not in the trace — and it almost always arrives after the agent has finished running.

Neens accepts that fact, keys it to the case the agent actually handled, and keeps it as evidence alongside the trace. Nothing here is inferred: a judge’s opinion that an answer looked helpful is a score; the customer clicking 4 stars is an outcome. Only one of those is the business result, and this page is about the second one.

No LLM is involved anywhere in this path. Your outcome is carried faithfully — never summarized, never guessed. A key that matches two cases stays ambiguous instead of picking one, and a missing value is rejected instead of stored as a zero.

At a glance

WhereThe Data sources tab of the Business KPIs page (connectors, correlation settings, and the coverage panel; connecting a feed is admin-only). Individual outcomes also appear on the trace/conversation detail page.
Two ways inPush — POST /outcomes from your ETL or app backend. Pull — a scheduled helpdesk connector.
Key APIPOST /outcomes · GET /outcomes · GET /outcomes/coverage · GET /outcomes/kinds · GET/PUT /outcomes/settings · POST /outcomes/reconcile · GET /outcomes/{id}/revisions
Auth to pushThe same nk_live_ agent API key you already use to send traces — no separate credential for your ETL job.
Auth to configureAdmin (reading outcomes only needs read access; changing correlation settings or running a reconcile is admin-only).
Needs an LLM connection?No. This path makes no model calls at all.

The case grain

Neens keys every outcome to a case, which is conversation_id when the trace has one and the trace’s own id when it doesn’t — exactly the rollup you already see on the Sessions page (see Traces & sessions). A multi-turn conversation is one case, not one case per turn, so “handle time was 6m12s” attaches to the whole exchange rather than to a single model call.

When an outcome matches, Neens records both the case key (conversationId) and the case’s first trace (sessionId), so you can jump from the outcome straight into the conversation that produced it.

Choose a correlation type

correlationType tells Neens how to find the case your correlationKey refers to. There are three, and picking the right one is the single biggest lever on your match rate.

correlationTypecorrelationKey is…Use it when
conversation (default)The case key — your agent’s conversation_id, or a single trace’s id when there is no conversationYour agent already uses the ticket/chat id from your system of record as its conversation id. This is the cleanest option — prefer it.
sessionOne specific trace idYou want the fact attached to a single agent run rather than the whole conversation.
externalAn id your agent wrote into the trace’s metadata — a ticket id, a case id, a CRM idYour agent’s conversation id and your helpdesk’s ticket id are different values, but the agent emits the ticket id as trace metadata.

For external, Neens walks a list of dotted paths into each session’s metadata JSON and takes the first non-empty scalar it finds. Out of the box those paths are:

ticket_id · case_id · external_id · crm_id · attributes.ticket_id · tags.ticket_id

Change them per agent under the Data sources tab of Business KPIs, or with PUT /outcomes/settings (see Settings).

⚠️

external matching is a scan, not an index lookup: Neens walks the agent’s newest sessions that carry metadata, up to a fixed ceiling (20,000 by default). If the scan hits that ceiling without finding the key, the outcome says so in its match detail — so you can tell “no such key” from “we didn’t look far enough”. conversation and session are indexed lookups and have no such bound.

Push an outcome

Get an agent API key

Use the same nk_live_… key you send traces with — posting an outcome needs write access to the agent, which an agent key already has. See API keys.

export NEENS_BASE_URL="https://neens.example.com"   # your Neens origin
export NEENS_API_KEY="nk_live_..."                  # your agent API key

POST the fact

Send one outcome, or a batch of up to 500 in outcomes[] — a larger batch is rejected with a 422. Field names are camelCase.

curl -sf -X POST "$NEENS_BASE_URL/api/outcomes" \
  -H "Authorization: Bearer $NEENS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "zendesk_etl",
    "outcomes": [
      {
        "correlationKey": "ZD-10231",
        "correlationType": "external",
        "kind": "resolution",
        "value": true,
        "occurredAt": "2026-07-26T10:04:00Z",
        "metadata": {"queue": "billing"},
        "idempotencyKey": "zd-10231-resolution-v1",
        "externalId": "10231"
      },
      {
        "correlationKey": "ZD-10231",
        "correlationType": "external",
        "kind": "csat",
        "value": 4,
        "occurredAt": "2026-07-26T11:20:00Z",
        "idempotencyKey": "zd-10231-csat-v1"
      },
      {
        "correlationKey": "ZD-10231",
        "correlationType": "external",
        "kind": "resolution_time",
        "value": 372,
        "unit": "s",
        "occurredAt": "2026-07-26T10:04:00Z",
        "idempotencyKey": "zd-10231-rt-v1"
      }
    ]
  }'

Read the response

The response is a per-batch report. 201 when at least one record was written, 200 when everything deduplicated.

{
  "accepted": 3,
  "created": 3,
  "revised": 0,
  "deduplicated": 0,
  "rejected": [],
  "matched": 3,
  "unmatched": 0,
  "ambiguous": 0,
  "outcomes": [
    {
      "id": "bo_ab12c3d45e6f7890",
      "seriesKey": "9f3c…",
      "projectId": "proj_x",
      "correlationType": "external",
      "correlationKey": "ZD-10231",
      "kind": "resolution",
      "kindLabel": "Resolved",
      "source": "zendesk_etl",
      "valueType": "boolean",
      "value": true,
      "unit": null,
      "occurredAt": "2026-07-26T10:04:00+00:00",
      "receivedAt": "2026-07-26T10:07:41.118932+00:00",
      "metadata": {"queue": "billing"},
      "revision": 1,
      "isCurrent": true,
      "supersededAt": null,
      "supersededBy": null,
      "matchState": "matched",
      "sessionId": "ses-1a2b3c",
      "conversationId": "conv-77f0",
      "matchedAt": "2026-07-26T10:07:41.118932+00:00",
      "matchAttempts": 1,
      "matchDetail": null,
      "matchDeadline": "2026-07-29T10:04:00+00:00",
      "connectionId": null,
      "externalId": "10231",
      "createdBy": "[email protected]",
      "createdAt": "2026-07-26T10:07:41.118932+00:00"
    }
  ]
}

Partial success is normal and correct. One bad row never bounces the batch: it lands in rejected with an honest reason ("value is required (a null value is a rejection, not a zero).") and its siblings are still written. A nightly export shouldn’t fail because one ticket had a malformed CSAT.

Request fields

FieldRequiredNotes
correlationKeyyesMax 512 characters.
correlationTypenoconversation (default) · session · external.
kindyesA slug from the catalogue — or your own (see below).
valueyesnull is a rejection, not a zero.
unitnoDefaults per kind. Durations accept ms, s/seconds, m/minutes, h/hours.
valueTypenonumeric · boolean · duration · currency · categorical. Required only for a kind outside the catalogue (otherwise inferred from the value’s type).
occurredAtnoWhen it happened upstream. Defaults to now when omitted. ISO-8601, or epoch seconds/milliseconds. A value Neens cannot parse is a rejection, not a silent fallback to now — otherwise a mis-formatted date would misfile the fact into the wrong reporting window. More than 24h in the future is also rejected (an outcome records something that already happened, and a future stamp would never expire and never be purged).
metadatanoA JSON object, capped at 16384 bytes (16 KiB) per record; over the cap rejects that record, not the batch.
idempotencyKeynoMax 256 characters. See Idempotency.
externalIdnoThe record id in your system (the Zendesk ticket id, the Salesforce case id). Stored for traceability; it is not used for matching.
sourcenoA slug identifying who asserted this — push by default. Set it once at the top level for the whole batch, or per record.

A top-level source applies to every record in the batch; a per-record source overrides it.

The outcome catalogue

GET /outcomes/kinds returns the platform catalogue plus the valid correlation types, match states and value types. These are the curated kinds the UI offers:

kindLabelValue typeDefault unitDirection
resolutionResolvedboolean—higher is better
containmentContainedboolean—higher is better
escalationEscalatedboolean—lower is better
reopenedReopenedboolean—lower is better
resolution_timeResolution timedurationmslower is better
first_response_timeFirst response timedurationmslower is better
handle_timeHandle timedurationmslower is better
csatCSATnumericscorehigher is better
npsNPSnumericscorehigher is better
refund_amountRefund amountcurrencyusdlower is better
case_costCase costcurrencyusdlower is better
transfer_countTransfersnumericcountlower is better
outcome_labelOutcome labelcategorical—not directional
How each value type is stored
  • boolean — accepts true/false, 1/0, and the strings true/t/yes/y/1 and false/f/no/n/0. Anything else is rejected; a boolean outcome is never a count.
  • duration — normalized to milliseconds on the way in, whatever unit you send, so a connector reporting seconds and an ETL reporting minutes stay comparable.
  • currency — the unit must be a three-letter code (usd, eur); it defaults to usd.
  • numeric — stored as a float, with your unit kept as-is (score, count, …).
  • categorical — the string itself, up to 512 characters. Not a number, so no arithmetic.

A null value is always a rejection. Storing 0 for “we don’t know” is how a containment metric quietly becomes a lie.

A kind outside the catalogue is accepted. Send "kind": "renewal_saved" with an explicit valueType (or a value whose Python/JSON type makes it obvious) and Neens stores it, catalogues it under a humanized label, and counts it in coverage. The curated list is what the UI suggests, not a whitelist.

Idempotency and restatement

These are two different things, and the distinction is what keeps your history truthful.

Idempotency is “you already told me this”. Restatement is “you’ve changed your mind”.

A series is the identity of one fact about one case: (project, correlationType, correlationKey, kind, source). A series has one or more revisions, and exactly one of them is current. Nothing is ever overwritten and nothing is deleted — the superseded revision stays as evidence.

Neens decides which of the two happened in this order:

Same idempotencyKey?

If a record with that key already exists in the agent for the same series, the new one is deduplicated — the existing outcome is returned unchanged. This is the strongest guarantee, and the one your ETL should reach for: retrying a whole nightly batch costs nothing.

A key is scoped to the agent, so it must be unique per (correlationKey, kind, source). Reusing one key for several different facts about the same case is rejected, not silently dropped:

// REJECTED — one key, three different kinds. Append the kind instead ("zd-10231-csat").
[{"correlationKey": "ZD-10231", "kind": "resolution", "value": true,  "idempotencyKey": "zd-10231"},
 {"correlationKey": "ZD-10231", "kind": "csat",       "value": 4,     "idempotencyKey": "zd-10231"}]

Same content?

Otherwise Neens content-hashes the value, unit, occurrence time and metadata. If that matches the series’ current revision, it’s deduplicated too — a byte-identical re-poll must not manufacture a change that never happened.

Otherwise, a new revision

The current revision is superseded and revision N+1 becomes current. The response counts it as revised (or created, if the series is new).

So a reopened ticket looks like this — same key, same kind, same source, a different idempotency key:

curl -sf -X POST "$NEENS_BASE_URL/api/outcomes" \
  -H "Authorization: Bearer $NEENS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "source": "zendesk_etl",
    "correlationKey": "ZD-10231",
    "correlationType": "external",
    "kind": "resolution",
    "value": false,
    "occurredAt": "2026-07-27T09:15:00Z",
    "metadata": {"reopenReason": "customer replied: charge still on the statement"},
    "idempotencyKey": "zd-10231-resolution-v2"
  }'

In the UI, the Business outcomes section on the trace’s detail page marks that outcome Revised (rev 2) and expands its Revision history to show the value it used to hold. Over the API the whole series is readable:

curl -sf "$NEENS_BASE_URL/api/outcomes/bo_ab12c3d45e6f7890/revisions" \
  -H "Authorization: Bearer $NEENS_API_KEY"
{
  "seriesKey": "9f3c…",
  "total": 2,
  "revisions": [
    {"revision": 1, "value": true,  "isCurrent": false, "supersededAt": "2026-07-27T09:16:02+00:00", "supersededBy": "bo_7f9b46b1aebf4013", "…": "…"},
    {"revision": 2, "value": false, "isCurrent": true,  "supersededAt": null, "supersededBy": null, "…": "…"}
  ]
}
⚠️

source is part of the series identity, on purpose. Zendesk saying “resolved” and a warehouse ETL saying “resolved” are two independent assertions about the same case, and collapsing them would let one silently overwrite the other. If you want one to restate the other, send both under the same source.

Late arrival: matched, parked, expired

An outcome routinely lands before its trace does — a helpdesk fires the moment a ticket closes, while the agent’s spans are still in flight. Neens never drops those.

Every record is correlated at write time, so a same-day outcome is matched immediately and never waits for a sweep. Whatever doesn’t match is parked, not discarded:

matchStateMeaningWhat happens next
matchedResolved to exactly one case.Done.
unmatchedNo case for that key — yet.Retried by the reconcile sweep until the match deadline.
ambiguousThe key resolved to more than one case. Neens never guesses.Retried, and stays ambiguous until the keys stop colliding.
expiredStill unresolved past the match deadline.Retried no more. Never deleted — it still counts in coverage.

The match deadline is occurredAt + the match window (72 hours by default), which you can override per agent under the Data sources tab of Business KPIs. A background sweep periodically re-attempts the oldest parked rows first and stamps every attempt — so a permanently unmatchable row shows as “tried 40 times” rather than looking like a fresh arrival.

To run it now for the current agent, click Reconcile now on the Match coverage panel, or call the API (admin only):

curl -sf -X POST "$NEENS_BASE_URL/api/outcomes/reconcile" \
  -H "Authorization: Bearer $SESSION_TOKEN"
{"examined": 12, "matched": 4, "ambiguous": 1, "expired": 2, "stillUnmatched": 5}

Read the coverage panel

GET /outcomes/coverage is the surface this whole feature exists for. A containment rate computed off a 60%-matched set of outcomes is confidently wrong, and nothing else in the product would tell you.

curl -sf "$NEENS_BASE_URL/api/outcomes/coverage?since=2026-07-01" \
  -H "Authorization: Bearer $NEENS_API_KEY"
{
  "summary": {
    "total": 96, "matched": 86, "unmatched": 7, "ambiguous": 1, "expired": 2,
    "matchRate": 0.8958, "unresolvedRate": 0.1042
  },
  "byKind": [
    {"kind": "resolution", "total": 28, "matched": 18, "unmatched": 7, "ambiguous": 1, "expired": 2, "matchRate": 0.6429, "unresolvedRate": 0.3571}
  ],
  "bySource": [
    {"source": "zendesk_etl", "total": 78, "matched": 68, "…": "…"}
  ],
  "byCorrelationType": [
    {"correlationType": "conversation", "total": 66, "matched": 66, "matchRate": 1.0, "…": "…"}
  ],
  "recentUnmatched": [
    {"id": "bo_…", "kind": "resolution", "correlationKey": "ZD-77104", "correlationType": "external",
     "occurredAt": "2026-07-26T…", "matchAttempts": 3,
     "matchDetail": "external key 'ZD-77104' not found in any session's metadata"}
  ],
  "oldestUnmatchedAt": "2026-07-20T08:12:00+00:00"
}

Read it in this order:

  1. summary.matchRate — the headline. Every downstream number is only as trustworthy as this.
  2. byCorrelationType — usually where the problem is. A conversation row at 1.0 next to an external row at 0.55 tells you the correlation type, not the data, is wrong.
  3. byKind / bySource — narrows it to one exporter or one fact.
  4. recentUnmatched — up to 20 parked outcomes with the actual reason each one failed. This is the fastest path to a fix. The sample is redacted, so a ticket subject or a requester email in metadata never leaks into a shared screenshot.
⚠️

matchRate is null, not 1.0, when there are no outcomes yet. Reporting “100% matched” for an empty denominator is exactly the failure this surface exists to prevent, so the API returns null and the panel says no outcomes yet.

Filter coverage with since, until, kind and source to check one exporter in isolation.

Why your match rate is not 100%

A brownfield deployment typically sits somewhere in the 85–95% range, and that’s fine — as long as you know why. Every parked outcome carries a matchDetail explaining itself; here is what each state means and what actually fixes it.

SymptomWhat it meansWhat to change
unmatched — “no case with conversation key ‘X‘“You sent a conversation key that isn’t any case’s conversation_id (nor any trace id).Check what your agent actually sets as conversation_id. If your helpdesk ticket id and the agent’s conversation id are different values, you want correlationType: "external", not conversation.
unmatched — “external key ‘X’ not found in any session’s metadata”The agent never wrote that id into the trace, or wrote it under a path Neens isn’t looking at.Instrument the agent to emit the id (ticket_id is the first default path), or add the path you do emit under the Data sources tab of Business KPIs → external key paths.
unmatched — “external key ‘X’ not found within the newest N sessions”The scan ceiling was hit before the key was found — the case may simply be older than the window Neens scanned.Narrow the export so it only sends recent cases. Do not read this as “no such key” — Neens is explicitly telling you it stopped early.
unmatched on brand-new outcomes only, clearing laterNormal late arrival: the outcome beat the trace.Nothing. The sweep picks it up automatically.
unmatched and it never clearsYour system of record exports cases the agent never handled — phone and email tickets, for instance.Filter them out of the export (e.g. only tickets whose channel is the bot), or accept the gap knowingly: coverage now shows it rather than hiding it.
ambiguous — “external key ‘X’ matches N cases”One id appears in the metadata of more than one case. Usually the agent reuses a ticket id across conversations, or a generic key like crm_id matches a customer rather than a case.Use a key that is unique per case, or narrow the external key paths so a broader field (crm_id) can’t win. Neens will not pick one for you.
expiredStill unresolved past occurredAt + the match window. Retrying stopped.If the traces genuinely arrive late, raise the match window per agent under the Data sources tab of Business KPIs. Otherwise treat it as a real gap — it’s a case whose trace never came.
Coverage says no outcomes yetNothing has been recorded for this filter window.Widen since/until, or check that your ETL is actually posting (GET /outcomes?limit=1).

Fixed the cause? Parked rows are re-attempted automatically, or immediately with POST /outcomes/reconcile. Rows already marked expired are not retried — re-post them if they matter.

Agent settings

Both settings live on the Data sources tab of Business KPIs, under Correlation settings, as External key paths (one per line) and Match window (hours) — leave either blank to inherit the platform default. GET /outcomes/settings shows the effective configuration and the defaults it falls back to; PUT /outcomes/settings changes it (admin only).

curl -sf -X PUT "$NEENS_BASE_URL/api/outcomes/settings" \
  -H "Authorization: Bearer $SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"externalKeyPaths": ["ticket_id", "attributes.zendesk_ticket"], "matchWindowHours": 168}'
SettingMeaning
externalKeyPathsDotted paths walked into the trace’s metadata for an external match, in order; the first non-empty scalar wins. Empty ⇒ the platform defaults.
matchWindowHoursHow long a parked outcome stays retryable. null ⇒ the platform default (72). Accepted range 0–8760.

Browse recorded outcomes

curl -sf -G "$NEENS_BASE_URL/api/outcomes" \
  -H "Authorization: Bearer $NEENS_API_KEY" \
  --data-urlencode "kind=csat" \
  --data-urlencode "matchState=matched" \
  --data-urlencode "limit=50"
Query parameterNotes
kind, source, matchState, correlationKeyExact match.
conversationId, sessionIdThe resolved case / trace — use these to list a single conversation’s outcomes.
since, untilBounds on occurredAt.
includeSupersededfalse by default — only current revisions. Set true to see the full history inline.
limit, offsetlimit defaults to 50, max 500.

GET /outcomes/{id} returns one outcome; GET /outcomes/{id}/revisions returns its whole series oldest → newest.

What’s next

  • Connect your helpdesk — pull outcomes from Zendesk, Intercom, Salesforce or Jira Service Management on a schedule instead of pushing them.
  • Business KPIs — turn these outcomes into containment rate, resolution time and cost per case, measured per case and badged with their provenance and coverage. Once a number is one you report on, promote it to a KPI: a target, a direction, an owner and a review cadence on top of the same measure. Because outcomes arrive late, every KPI value carries its coverage and an asOf — and a window with no decided cases reads unknown, never missed. Late arrivals are also why a KPI’s recorded daily history re-checks its most recent days: an outcome that lands on Thursday corrects the figure already recorded for Monday.
  • Traces & sessions — the case grain outcomes are keyed to.
  • Fix outcomes — the other outcome surface: whether a merged fix actually reduced a failure in production.