# Deduplication & Outcomes (/docs/concepts/deduplication-and-outcomes)



Two trust-plane concepts sit on either side of a <Term name="Route" />, guarding
the quality of the work that clears. **Deduplication** runs *before* routing to
suppress duplicate inbound <Term name="Work" />, so a buyer is never charged
twice for the same lead. **Outcomes** are recorded *after* routing to feed
learning, attribution, and — the *settled* stage — billing. This page covers
both.

## Deduplication [#deduplication]

The whole mechanism is a key, a window, and a branch. What matters is that a
duplicate is not a single outcome — the same hit takes one of three very
different paths, and only `reject` stops the routable from clearing:

```mermaid
flowchart TD
  in([routable arrives]) --> key[CEL key, normalized and hashed]
  key --> idx{seen in window?}
  idx -->|no| store[record key with TTL] --> route([continue to routing])
  idx -->|yes| act{action}
  act -->|reject| err([error, not routed])
  act -->|merge| upd[update original] --> err2([duplicate absorbed])
  act -->|flag| mark[tag metadata] --> route
```

Deduplication answers a narrow question at intake: &#x2A;have we already seen this
routable?* It computes a stable key from a routable's attributes, checks a
time-windowed index scoped to a pool, tenant, or globally, and applies an action
when a duplicate is found.

<Callout type="warn">
  Deduplication is **not** [idempotency](/docs/concepts/idempotency). Dedup is
  *inbound duplicate suppression* — it drops or merges routables that look like
  ones seen recently. Idempotency is about *exactly-once state progress* under
  retries and crashes for work already accepted. They solve different problems; use
  the dedup key to avoid processing the same lead twice, and idempotency records to
  make a single accepted lead's side effects effectively-once.
</Callout>

### The key [#the-key]

The dedup key is produced by a **CEL expression** evaluated against the routable.
The engine ships normalization helpers — `hash(...)`, `normalize_email(...)`,
`normalize_phone(...)`, `lower(...)`, `trim(...)` — so a key can canonicalize
before fingerprinting (for example, hashing a normalized email and phone
together). The config surfaces this as `key_fields`, the routable field
references used to compute the key; the default is `routable.idempotency_key`.

### Scope and action [#scope-and-action]

* **Scope** bounds where a duplicate counts: `pool`, `tenant`, or `global`.
  When scope is `pool`, a `pool_id` is required.
* **Action** decides what happens on a hit:

<TypeTable
  type="{
  reject: { type: &#x22;action&#x22;, description: &#x22;Reject the duplicate with an error.&#x22; },
  merge: { type: &#x22;action&#x22;, description: &#x22;Merge the duplicate into the original (update attributes).&#x22; },
  flag: { type: &#x22;action&#x22;, description: &#x22;Route anyway, but flag the routable as a duplicate in metadata.&#x22; },
}"
/>

<Callout type="info">
  `reject`, `merge`, and `flag` are the accepted action values in the runtime.
  `reject` is the default. Any other value (including `allow`) is not a valid
  action and is refused by config validation.
</Callout>

### Endpoints [#endpoints]

All under `/api/dedup`.

<TypeTable
  type="{
  &#x22;POST /check&#x22;: { description: &#x22;Check whether a routable with a given key was seen before. Records the access.&#x22; },
  &#x22;GET /config&#x22;: { description: &#x22;Get the dedup config (enabled, ttl, scope, key_fields, action), optionally pool-scoped.&#x22; },
  &#x22;PUT /config&#x22;: { description: &#x22;Update the dedup config, optionally scoped to a pool.&#x22; },
  &#x22;GET /lookup&#x22;: { description: &#x22;Read-only history for a key (original ref, duplicate count, expiry). Does not record the access.&#x22; },
}"
/>

The `check` versus `lookup` distinction matters: `check` is the enforcement call
that records the access, while `lookup` is a passive read for inspection.

```bash
curl -X POST http://localhost:8080/api/dedup/check \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "key": "hash:abc123", "scope": "pool", "pool_id": "'$POOL_ID'" }'
```

Config carries a `ttl` (an entry's time-to-live, e.g. `"3600s"`), so the index is
a *time window*, not a permanent ledger — a routable seen long enough ago is no
longer a duplicate.

## Outcomes [#outcomes]

An **outcome** is what happened after a <Term name="Route" /> — a contact, a
conversion, a rejection. Outcomes are the feedback loop: they feed the learning
strategies that adapt routing, and they carry the value and attribution that the
*settled* stage uses for billing. An outcome of *not converted* inside the
<Term name="Warranty" /> window is also what triggers a claw-back.

<Callout type="info">
  [Learning strategies](/docs/strategies/learning) consume outcomes as their reward
  signal — a bandit updates its arms from recorded conversions and values. This
  page is the outcome record and its API; that page is the learning runtime.
</Callout>

A single decision can accrue **multiple** outcomes over time (e.g. *contacted*
then *converted*). Each outcome is tied to a decision and, through it, to a pool
and recipient.

### Recording [#recording]

`POST /api/decisions/{decision_id}/outcomes` records an outcome against a routing
decision.

<TypeTable
  type="{
  decision_id: { type: &#x22;string&#x22;, description: &#x22;The routing decision this outcome belongs to (required).&#x22; },
  outcome_type: { type: &#x22;string&#x22;, description: &#x22;Category, e.g. converted, contacted, rejected, no_answer, voicemail (required).&#x22; },
  value: { type: &#x22;number&#x22;, description: &#x22;Monetary or numeric value (e.g. deal amount).&#x22; },
  currency: { type: &#x22;string&#x22;, description: &#x22;ISO 4217 code for value.&#x22; },
  label: { type: &#x22;string&#x22;, description: &#x22;Human-readable description.&#x22; },
  occurred_at: { type: &#x22;date-time&#x22;, description: &#x22;When the outcome occurred. Defaults to now.&#x22; },
  source: { type: &#x22;string&#x22;, description: &#x22;Reporting system, e.g. crm, dialer, web.&#x22; },
  source_id: { type: &#x22;string&#x22;, description: &#x22;External identifier from the source system.&#x22; },
  attributes: { type: &#x22;object&#x22;, description: &#x22;Additional structured data about the outcome.&#x22; },
}"
/>

```bash
curl -X POST http://localhost:8080/api/decisions/$DECISION_ID/outcomes \
  -H "X-Tenant-ID: $DUCTOR_TENANT_ID" \
  -H "Content-Type: application/json" \
  -d '{ "outcome_type": "converted", "value": 1250.00, "currency": "USD", "source": "crm" }'
```

### Querying [#querying]

<TypeTable
  type="{
  &#x22;GET /api/decisions/{id}/outcomes&#x22;: { description: &#x22;All outcomes for one decision.&#x22; },
  &#x22;GET /api/outcomes&#x22;: { description: &#x22;Query outcomes by pool, recipient, routable, decision, type, source, value range, and time range.&#x22; },
  &#x22;GET /api/outcomes/{outcome_id}&#x22;: { description: &#x22;Fetch a single outcome.&#x22; },
  &#x22;GET /api/outcomes/stats&#x22;: { description: &#x22;Aggregated stats — counts, conversion rates, value summaries, time-to-outcome percentiles.&#x22; },
  &#x22;GET /api/pools/{pool_id}/outcomes/stats&#x22;: { description: &#x22;Outcome stats rolled up for one pool.&#x22; },
  &#x22;GET /api/recipients/{recipient_id}/outcomes/stats&#x22;: { description: &#x22;Outcome stats rolled up for one recipient.&#x22; },
}"
/>

Stats endpoints are the reporting surface behind conversion dashboards and the
per-pool and per-recipient rollups that inform both routing adaptation and
billing attribution.

## Related [#related]

<Cards>
  <Card title="Idempotency & Exactly-Once" href="/docs/concepts/idempotency" description="The distinct guarantee dedup is often confused with — exactly-once state progress under retries." />

  <Card title="Learning Strategies" href="/docs/strategies/learning" description="How recorded outcomes become the reward signal that adapts routing." />

  <Card title="Routing Pipeline" href="/docs/concepts/routing-pipeline" description="Where dedup runs at intake and where a decision is produced." />
</Cards>
