Skip to content

Resolution API

Resolve external identifiers to canonical entities.

GET /v1/resolve

Resolve an external identifier to its canonical entity, or search by free-text query.

Query Parameters

ParameterTypeRequiredDescription
systemstringYes (unless query)Source system name (e.g., "Stripe")
idstringYes (unless query)External ID in that system
querystringNoFree-text search across canonical data (email, name, etc.)

Request

python
from kanoniv.client import KanonivClient

client = KanonivClient(api_key="kn_...", base_url="https://api.kanoniv.com")

# By system + external ID
result = client.resolve(system="Stripe", external_id="cus_123")
print(result["id"])                  # "550e8400-e29b-41d4-a716-446655440000"
print(result["confidence_score"])    # 0.94
print(result["canonical_data"])      # {"email": "[email protected]", "name": "John Doe"}

# By free-text query
result = client.resolve(query="[email protected]")
print(result["canonical_data"])
bash
# By system + external ID
curl "https://api.kanoniv.com/v1/resolve?system=Stripe&id=cus_123" \
  -H "X-API-Key: kn_..."

# By free-text query
curl "https://api.kanoniv.com/v1/[email protected]" \
  -H "X-API-Key: kn_..."

Response

json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "tenant_id": "t_abc123",
  "entity_type": "customer",
  "canonical_data": {
    "email": "[email protected]",
    "name": "John Doe",
    "company": "Acme Corp"
  },
  "field_provenance": {
    "email": "Stripe",
    "name": "Salesforce",
    "company": "Salesforce"
  },
  "confidence_score": 0.94,
  "is_locked": false,
  "created_at": "2026-01-15T10:00:00Z",
  "updated_at": "2026-02-01T09:00:00Z"
}

Response Fields

FieldTypeDescription
idUUIDCanonical entity ID
tenant_idUUIDTenant this entity belongs to
entity_typestringEntity type (e.g., "customer", "vendor")
canonical_dataobjectMerged golden record fields
field_provenanceobjectWinning source for each field
confidence_scorefloatMatch confidence score (0.0--1.0)
is_lockedbooleanWhether this entity is locked from merging
created_attimestampWhen this canonical entity was created
updated_attimestampWhen this canonical entity was last updated

PII Masking

All endpoints returning entity data mask PII fields by default when pii_fields are configured in the spec. Add ?reveal_pii=true to see unmasked data (admin role only). This applies to GET /v1/resolve, POST /v1/resolve/realtime, POST /v1/resolve/bulk, GET /v1/canonical/:id, GET /v1/canonical/:id/linked, GET /v1/export/entities, and GET /v1/events.

Error Handling

python
from kanoniv import NotFoundError, ValidationError

try:
    result = client.resolve(system="Stripe", external_id="cus_unknown")
except NotFoundError:
    print("No identity link found for this external ID")
except ValidationError as e:
    print(f"Bad request: {e}")
bash
# 404 Not Found
{
  "error": {
    "code": "ENTITY_NOT_FOUND",
    "message": "Entity cus_unknown in system Stripe not found"
  }
}

# 400 Bad Request
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Missing identifying parameters. Please provide 'system' + 'id' or a 'query' string."
  }
}

POST /v1/resolve/realtime

Resolve a new record against the existing identity graph using Fellegi-Sunter probabilistic scoring. This endpoint uses the tenant's compiled FS plan - the same normalizers, comparators, and blocking keys used in batch reconciliation - to score the incoming record against candidate canonical entities in real time.

Key behaviors:

  • Generates composite blocking keys from the input fields (email, phone, last_name+first_name, company+last_name) and queries canonical entities via SQL to find candidates.
  • Scores each candidate using the full FS plan with normalizers (email, phone, name, nickname, domain) and comparators (exact, jaro_winkler, levenshtein, soundex, metaphone, cosine).
  • Uses two thresholds for decisions:
    • match_threshold - links the new record to an existing entity (lower bar)
    • merge_threshold - merges two existing entities when the new record bridges them (higher bar, defaults to match_threshold * 1.5)
  • When the new record matches N entities above merge_threshold, losers are merged into the highest-scoring winner with a full audit trail (entity_merges, entity_events).
  • Falls back to exact email/phone matching if no compiled FS plan exists (backwards compatible).

Request Body

FieldTypeRequiredDescription
source_namestringYesSource system name (e.g., "crm"), max 100 chars
external_idstringYesExternal ID in that system, max 500 chars
dataobjectYesRecord fields to match against (e.g., email, name, phone)

Request

python
from kanoniv import Client

client = Client(api_key="kn_...", base_url="https://api.kanoniv.com")

result = client.resolve_rt.realtime(
    source_name="crm",
    external_id="sf_new_001",
    data={
        "name": "Jonathan Doe",
        "email": "[email protected]",
        "phone": "555-0101",
    },
)

print(result["entity_id"])       # "c7e2f9a1-..."
print(result["is_new"])          # False
print(result["confidence"])      # 0.95
bash
curl -X POST "https://api.kanoniv.com/v1/resolve/realtime" \
  -H "X-API-Key: kn_..." \
  -H "Content-Type: application/json" \
  -d '{
    "source_name": "crm",
    "external_id": "sf_new_001",
    "data": {
      "name": "Jonathan Doe",
      "email": "[email protected]",
      "phone": "555-0101"
    }
  }'

Response (matched to existing entity)

json
{
  "entity_id": "c7e2f9a1-1234-5678-abcd-ef1234567890",
  "canonical_data": {
    "email": "[email protected]",
    "name": "John Doe",
    "phone": "+15550101"
  },
  "is_new": false,
  "matched_source": "crm",
  "confidence": 0.95
}

Response (new entity created)

json
{
  "entity_id": "a1b2c3d4-5678-9012-abcd-ef1234567890",
  "canonical_data": {
    "email": "[email protected]",
    "name": "Jane Smith"
  },
  "is_new": true,
  "matched_source": null,
  "confidence": 1.0
}

Response Fields

FieldTypeDescription
entity_idUUIDCanonical entity ID (existing or newly created)
canonical_dataobjectMerged golden record fields (PII-masked by default)
is_newbooleanWhether a new canonical entity was created
matched_sourcestring or nullSource name of the match (null if new entity)
confidencefloatMatch confidence (1.0 for exact/new, lower for probabilistic)

Scoring Thresholds

The endpoint reads thresholds from your compiled spec:

yaml
scoring:
  method: fellegi_sunter
  thresholds:
    match: 8.0        # link new record to existing entity
    possible: 4.0     # uncertain range
    non_match: -4.0   # definite non-match
    merge: 12.0       # merge existing entities (defaults to match * 1.5)

The merge threshold is intentionally higher than match to prevent accidental entity merges from borderline scores. Only high-confidence bridging records trigger inline merges.

Fallback Behavior

If no compiled FS plan exists for the tenant (e.g., no batch reconciliation has been run yet), the endpoint falls back to exact email and phone matching against the identity graph. The response will have "scoring_method": "exact_fallback" in this case.


POST /v1/resolve/bulk

Batch resolve multiple source+id pairs against the identity graph. Uses the Redis reverse index for sub-millisecond lookups, falling back to the database. Returns results for all requested lookups.

Request Body

FieldTypeRequiredDescription
lookupsarrayYesList of {"source": "...", "id": "..."} objects. Maximum 1000 per request.

Request

python
from kanoniv import Client

client = Client(api_key="kn_...", base_url="https://api.kanoniv.com")

result = client.resolve_rt.bulk([
    {"source": "salesforce", "id": "003A000001abc"},
    {"source": "stripe", "id": "cus_N4x8Kj"},
    {"source": "zendesk", "id": "req_88291"},
])

print(f"Resolved: {result['resolved']}, Not found: {result['not_found']}")
for r in result["results"]:
    if r["found"]:
        print(f"  {r['source']}:{r['id']} -> {r['entity_id']}")
bash
curl -X POST "https://api.kanoniv.com/v1/resolve/bulk" \
  -H "X-API-Key: kn_..." \
  -H "Content-Type: application/json" \
  -d '{
    "lookups": [
      {"source": "salesforce", "id": "003A000001abc"},
      {"source": "stripe", "id": "cus_N4x8Kj"},
      {"source": "zendesk", "id": "req_88291"}
    ]
  }'

Response

json
{
  "results": [
    {
      "source": "salesforce",
      "id": "003A000001abc",
      "entity_id": "c7e2f9a1-1234-5678-abcd-ef1234567890",
      "canonical_data": {"email": "[email protected]", "name": "John Doe"},
      "found": true
    },
    {
      "source": "stripe",
      "id": "cus_N4x8Kj",
      "entity_id": "c7e2f9a1-1234-5678-abcd-ef1234567890",
      "canonical_data": {"email": "[email protected]", "name": "John Doe"},
      "found": true
    },
    {
      "source": "zendesk",
      "id": "req_88291",
      "entity_id": null,
      "canonical_data": null,
      "found": false
    }
  ],
  "resolved": 2,
  "not_found": 1
}

Response Fields

FieldTypeDescription
resultsarrayOne entry per lookup in the same order as the request
results[].sourcestringSource name from the request
results[].idstringExternal ID from the request
results[].entity_idUUID or nullCanonical entity ID if found
results[].canonical_dataobject or nullGolden record fields if found (PII-masked by default)
results[].foundbooleanWhether the lookup resolved to an entity
resolvedintegerCount of lookups that resolved
not_foundintegerCount of lookups that did not resolve

GET /v1/resolve/pending

List pending review decisions: entity pairs that scored between the review and match thresholds and require human verification.

Query Parameters

ParameterTypeRequiredDefaultDescription
limitintegerNo50Max results to return
offsetintegerNo0Pagination offset

Request

python
reviews = client.get("/v1/resolve/pending", params={"limit": 20})
for r in reviews:
    print(f"{r['entity_a_name']} vs {r['entity_b_name']} (confidence: {r['confidence']})")
bash
curl "https://api.kanoniv.com/v1/resolve/pending?limit=20" \
  -H "X-API-Key: kn_..."

Response

json
[
  {
    "entity_a_id": "550e8400-e29b-41d4-a716-446655440000",
    "entity_b_id": "660e8400-e29b-41d4-a716-446655440001",
    "entity_a_name": "John Doe",
    "entity_b_name": "Jon Doe",
    "entity_a_email": "[email protected]",
    "entity_b_email": "[email protected]",
    "entity_a_source": "crm",
    "entity_b_source": "billing",
    "entity_a_data": { "email": "[email protected]", "name": "John Doe" },
    "entity_b_data": { "email": "[email protected]", "name": "Jon Doe" },
    "confidence": 0.78,
    "rule_results": { "email_exact": 0.0, "name_fuzzy": 0.78 },
    "created_at": "2026-02-01T09:00:00Z"
  }
]

Response Fields

FieldTypeDescription
entity_a_idUUIDFirst entity in the candidate pair
entity_b_idUUIDSecond entity in the candidate pair
entity_a_namestringName from first entity's normalized data
entity_b_namestringName from second entity's normalized data
entity_a_emailstringEmail from first entity
entity_b_emailstringEmail from second entity
entity_a_sourcestringSource system of first entity
entity_b_sourcestringSource system of second entity
entity_a_dataobjectFull normalized data for first entity
entity_b_dataobjectFull normalized data for second entity
confidencefloatMatch confidence score
rule_resultsobjectPer-rule score breakdown
created_attimestampWhen this review was created

Results are ordered by confidence descending (highest-confidence reviews first).


POST /v1/resolve/quick

Resolve a pending review by manually deciding to merge or split two entities. Creates a persistent manual override and removes the pair from the review queue.

Request Body

FieldTypeRequiredDescription
entity_a_idUUIDYesFirst entity ID
entity_b_idUUIDYesSecond entity ID
decisionstringYes"merge" or "split"
reasonstringNoHuman-readable reason (max 500 chars)

Request

python
client.post("/v1/resolve/quick", json={
    "entity_a_id": "550e8400-e29b-41d4-a716-446655440000",
    "entity_b_id": "660e8400-e29b-41d4-a716-446655440001",
    "decision": "merge",
    "reason": "Same person, different email"
})
bash
curl -X POST "https://api.kanoniv.com/v1/resolve/quick" \
  -H "X-API-Key: kn_..." \
  -H "Content-Type: application/json" \
  -d '{
    "entity_a_id": "550e8400-e29b-41d4-a716-446655440000",
    "entity_b_id": "660e8400-e29b-41d4-a716-446655440001",
    "decision": "merge",
    "reason": "Same person, different email"
  }'

Response

Returns 200 OK with no body on success. The override is applied on the next reconciliation run.


GET /v1/canonical/:id

Get a canonical entity by its UUID.

Path Parameters

ParameterTypeRequiredDescription
idUUIDYesCanonical entity ID

Request

python
entity = client.get("/v1/canonical/550e8400-e29b-41d4-a716-446655440000")

print(entity["entity_type"])          # "customer"
print(entity["canonical_data"])       # {"email": "[email protected]", ...}
print(entity["confidence_score"])     # 0.94
bash
curl "https://api.kanoniv.com/v1/canonical/550e8400-e29b-41d4-a716-446655440000" \
  -H "X-API-Key: kn_..."

Response

Same schema as GET /v1/resolve response. PII masking is applied for non-admin callers when pii_fields are configured.


GET /v1/canonical/:id/linked

Get a canonical entity with all its linked external entities and identity links.

Path Parameters

ParameterTypeRequiredDescription
idUUIDYesCanonical entity ID

Request

python
detail = client.get("/v1/canonical/550e8400-e29b-41d4-a716-446655440000/linked")

print(detail["canonical"]["entity_type"])  # "customer"
for ext in detail["linked_entities"]:
    print(f"{ext['entity_type']}:{ext['external_id']}")
for link in detail["links"]:
    print(f"confidence: {link['confidence']}, type: {link['link_type']}")
bash
curl "https://api.kanoniv.com/v1/canonical/550e8400-e29b-41d4-a716-446655440000/linked" \
  -H "X-API-Key: kn_..."

Response

json
{
  "canonical": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "tenant_id": "t_abc123",
    "entity_type": "customer",
    "canonical_data": {
      "email": "[email protected]",
      "name": "John Doe",
      "company": "Acme Corp"
    },
    "field_provenance": {
      "email": "Stripe",
      "name": "Salesforce",
      "company": "Salesforce"
    },
    "confidence_score": 0.94,
    "is_locked": false,
    "created_at": "2026-01-15T10:00:00Z",
    "updated_at": "2026-02-01T09:00:00Z"
  },
  "linked_entities": [
    {
      "id": "ext_001",
      "tenant_id": "t_abc123",
      "data_source_id": "src_stripe",
      "external_id": "cus_123",
      "entity_type": "customer",
      "raw_data": { "email": "[email protected]", "name": "John Doe" },
      "normalized_data": { "email": "[email protected]", "name": "John Doe" },
      "ingested_at": "2026-01-15T10:00:00Z",
      "batch_id": "batch_001"
    }
  ],
  "links": [
    {
      "id": "link_001",
      "tenant_id": "t_abc123",
      "external_entity_id": "ext_001",
      "canonical_entity_id": "550e8400-e29b-41d4-a716-446655440000",
      "link_type": "auto",
      "confidence": 0.94,
      "matched_on": ["email_exact", "name_fuzzy"],
      "created_at": "2026-01-15T10:00:00Z"
    }
  ]
}

Response Fields

canonical: The canonical entity (same schema as GET /v1/resolve).

linked_entities: External entity records linked to this canonical entity:

FieldTypeDescription
idUUIDExternal entity record ID
data_source_idUUIDSource configuration ID
external_idstringID in the source system
entity_typestringEntity type
raw_dataobjectOriginal source data
normalized_dataobjectNormalized/cleaned data
ingested_attimestampWhen this record was received
batch_idUUIDProcessing batch ID

links: Identity links connecting external entities to the canonical entity:

FieldTypeDescription
idUUIDLink ID
external_entity_idUUIDExternal entity this links from
canonical_entity_idUUIDCanonical entity this links to
link_typestring"auto" (rule-based) or "manual" (override)
confidencefloatLink confidence score
matched_onarrayRule names that created this link
created_attimestampWhen the link was created

Performance & Caching

Resolution uses multi-layer caching:

LayerTTLDescription
L1 (in-process moka)60sPer-instance in-memory cache (max 10,000 entries)
L2 (Redis)1 hourShared across API instances (bincode serialization)
L3 (Database)N/APostgreSQL with optimized indexes

Cache is populated on first access and refreshed on TTL expiry. The GET /v1/resolve endpoint (system + external ID lookup) uses all three layers. Free-text query searches and GET /v1/canonical/:id hit the database directly.

The identity and delegation layer for AI agents.