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
| Parameter | Type | Required | Description |
|---|---|---|---|
system | string | Yes (unless query) | Source system name (e.g., "Stripe") |
id | string | Yes (unless query) | External ID in that system |
query | string | No | Free-text search across canonical data (email, name, etc.) |
Request
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"])# 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
{
"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
| Field | Type | Description |
|---|---|---|
id | UUID | Canonical entity ID |
tenant_id | UUID | Tenant this entity belongs to |
entity_type | string | Entity type (e.g., "customer", "vendor") |
canonical_data | object | Merged golden record fields |
field_provenance | object | Winning source for each field |
confidence_score | float | Match confidence score (0.0--1.0) |
is_locked | boolean | Whether this entity is locked from merging |
created_at | timestamp | When this canonical entity was created |
updated_at | timestamp | When 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
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}")# 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 tomatch_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
| Field | Type | Required | Description |
|---|---|---|---|
source_name | string | Yes | Source system name (e.g., "crm"), max 100 chars |
external_id | string | Yes | External ID in that system, max 500 chars |
data | object | Yes | Record fields to match against (e.g., email, name, phone) |
Request
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.95curl -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)
{
"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)
{
"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
| Field | Type | Description |
|---|---|---|
entity_id | UUID | Canonical entity ID (existing or newly created) |
canonical_data | object | Merged golden record fields (PII-masked by default) |
is_new | boolean | Whether a new canonical entity was created |
matched_source | string or null | Source name of the match (null if new entity) |
confidence | float | Match confidence (1.0 for exact/new, lower for probabilistic) |
Scoring Thresholds
The endpoint reads thresholds from your compiled spec:
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
| Field | Type | Required | Description |
|---|---|---|---|
lookups | array | Yes | List of {"source": "...", "id": "..."} objects. Maximum 1000 per request. |
Request
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']}")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
{
"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
| Field | Type | Description |
|---|---|---|
results | array | One entry per lookup in the same order as the request |
results[].source | string | Source name from the request |
results[].id | string | External ID from the request |
results[].entity_id | UUID or null | Canonical entity ID if found |
results[].canonical_data | object or null | Golden record fields if found (PII-masked by default) |
results[].found | boolean | Whether the lookup resolved to an entity |
resolved | integer | Count of lookups that resolved |
not_found | integer | Count 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
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
limit | integer | No | 50 | Max results to return |
offset | integer | No | 0 | Pagination offset |
Request
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']})")curl "https://api.kanoniv.com/v1/resolve/pending?limit=20" \
-H "X-API-Key: kn_..."Response
[
{
"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
| Field | Type | Description |
|---|---|---|
entity_a_id | UUID | First entity in the candidate pair |
entity_b_id | UUID | Second entity in the candidate pair |
entity_a_name | string | Name from first entity's normalized data |
entity_b_name | string | Name from second entity's normalized data |
entity_a_email | string | Email from first entity |
entity_b_email | string | Email from second entity |
entity_a_source | string | Source system of first entity |
entity_b_source | string | Source system of second entity |
entity_a_data | object | Full normalized data for first entity |
entity_b_data | object | Full normalized data for second entity |
confidence | float | Match confidence score |
rule_results | object | Per-rule score breakdown |
created_at | timestamp | When 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
| Field | Type | Required | Description |
|---|---|---|---|
entity_a_id | UUID | Yes | First entity ID |
entity_b_id | UUID | Yes | Second entity ID |
decision | string | Yes | "merge" or "split" |
reason | string | No | Human-readable reason (max 500 chars) |
Request
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"
})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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Canonical entity ID |
Request
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.94curl "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
| Parameter | Type | Required | Description |
|---|---|---|---|
id | UUID | Yes | Canonical entity ID |
Request
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']}")curl "https://api.kanoniv.com/v1/canonical/550e8400-e29b-41d4-a716-446655440000/linked" \
-H "X-API-Key: kn_..."Response
{
"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:
| Field | Type | Description |
|---|---|---|
id | UUID | External entity record ID |
data_source_id | UUID | Source configuration ID |
external_id | string | ID in the source system |
entity_type | string | Entity type |
raw_data | object | Original source data |
normalized_data | object | Normalized/cleaned data |
ingested_at | timestamp | When this record was received |
batch_id | UUID | Processing batch ID |
links: Identity links connecting external entities to the canonical entity:
| Field | Type | Description |
|---|---|---|
id | UUID | Link ID |
external_entity_id | UUID | External entity this links from |
canonical_entity_id | UUID | Canonical entity this links to |
link_type | string | "auto" (rule-based) or "manual" (override) |
confidence | float | Link confidence score |
matched_on | array | Rule names that created this link |
created_at | timestamp | When the link was created |
Performance & Caching
Resolution uses multi-layer caching:
| Layer | TTL | Description |
|---|---|---|
| L1 (in-process moka) | 60s | Per-instance in-memory cache (max 10,000 entries) |
| L2 (Redis) | 1 hour | Shared across API instances (bincode serialization) |
| L3 (Database) | N/A | PostgreSQL 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.
