Duplicates
Detect and merge duplicate records. Scan for matches by email, phone, LinkedIn, or name and consolidate them into a single master record.
The duplicates endpoint provides two operations: detecting potential duplicate records and merging confirmed duplicates. Both operations require admin privileges.
Both actions on this endpoint are restricted to admin users. Non-admin tokens receive a 400 response with code PERMISSION_DENIED.
Detect Duplicates
POST /api/v1/duplicatesScans records for potential duplicates based on matching criteria.
Request Body
{
"action": "detect",
"object_name": "contacts",
"fields": ["email", "phone"],
"limit": 100
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | No | detect | Any value other than "merge" performs a detect. |
object_name | string | Yes | The object to scan (e.g. contacts, companies) | |
fields | string[] | No | inferred | Field names to compare for matching (e.g. email, phone, name). When omitted, the engine infers candidate fields from the object's metadata. |
limit | integer | No | 50 | Maximum number of duplicate groups to return. Capped at 200. |
Matching Rules
The detection engine loads up to 5000 records and compares them pairwise on the chosen fields. Each comparison is an exact, case-insensitive string match on the trimmed field value (empty/null values are skipped).
When fields is omitted, candidate fields are inferred from the object's metadata:
| Inferred When | Field Considered |
|---|---|
Field type is EMAIL | the email field |
Field type is PHONE | the phone field |
Field type is URL and the field name contains linkedin | the LinkedIn URL field |
Field type is TEXT and the field name is name, full_name, or company_name | the name field |
If no candidates are inferred, the engine falls back to comparing the name field.
Example Request
curl -X POST "https://kasar.app/api/v1/duplicates" \
-H "Authorization: Bearer ksr_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{
"action": "detect",
"object_name": "contacts",
"fields": ["email", "name"],
"limit": 50
}'Response
{
"object_name": "contacts",
"duplicate_groups": [
{
"records": [
{
"id": "550e8400-...",
"first_name": "Alice",
"last_name": "Martin",
"email": "alice@example.com",
"created_at": "2025-01-15T10:30:00Z"
},
{
"id": "660f9511-...",
"first_name": "Alice",
"last_name": "Martin",
"email": "alice@example.com",
"created_at": "2025-02-20T14:00:00Z"
}
],
"match_reason": "email: \"alice@example.com\""
}
],
"total_groups": 1,
"records_scanned": 3420
}| Field | Type | Description |
|---|---|---|
object_name | string | The object that was scanned |
duplicate_groups | object[] | Array of groups, each containing matching records |
duplicate_groups[].records | object[] | The records that match each other |
duplicate_groups[].match_reason | string | The field(s) and value(s) that triggered the match, formatted as field: "value". Multiple reasons are comma-separated. |
total_groups | integer | Total number of duplicate groups found |
records_scanned | integer | Number of records that were loaded and scanned |
Merge Duplicates
POST /api/v1/duplicatesMerges one or more duplicate records into a master record. All relations (BELONGS_TO_ONE foreign keys and MANY_TO_MANY junction entries) are re-linked to the master record before the duplicates are deleted.
Request Body
{
"action": "merge",
"object_name": "contacts",
"master_id": "550e8400-...",
"duplicate_ids": ["660f9511-..."],
"merge_strategy": "fill_blanks"
}| Field | Type | Required | Default | Description |
|---|---|---|---|---|
action | string | Yes | Must be "merge" | |
object_name | string | Yes | The object containing the records | |
master_id | UUID | Yes | The record to keep as the canonical version | |
duplicate_ids | UUID[] | Yes | Records to merge into the master and delete | |
merge_strategy | string | No | keep_master | How to handle conflicting field values |
Merge Strategies
| Strategy | Description |
|---|---|
keep_master | Keep all scalar field values from the master record. Duplicate values are discarded. This is the default. |
fill_blanks | For each scalar field, if the master has a blank/null value, fill it with the value from a duplicate (the first duplicate that has a non-empty value wins). Non-blank master fields are preserved. Relations, junctions, and compound fields (EMAILS/PHONES) are never copied by this strategy — they are transferred by the merge engine in both strategies. |
Example Request
curl -X POST "https://kasar.app/api/v1/duplicates" \
-H "Authorization: Bearer ksr_a1b2c3d4e5f6..." \
-H "Content-Type: application/json" \
-d '{
"action": "merge",
"object_name": "contacts",
"master_id": "550e8400-e29b-41d4-a716-446655440000",
"duplicate_ids": ["660f9511-f3ac-52e5-b827-557766551111"],
"merge_strategy": "fill_blanks"
}'Response
{
"master_id": "550e8400-e29b-41d4-a716-446655440000",
"duplicates_deleted": 1,
"updated_relations": 4,
"strategy": "fill_blanks"
}| Field | Type | Description |
|---|---|---|
master_id | UUID | The ID of the surviving master record |
duplicates_deleted | integer | Number of duplicate records that were deleted |
updated_relations | integer | Number of relation/junction links that were re-pointed to the master |
strategy | string | The merge strategy that was applied (keep_master by default) |
What Happens During a Merge
- Field values are resolved according to the chosen strategy.
- BELONGS_TO_ONE relations: Any records pointing to a duplicate via a foreign key are updated to point to the master instead.
- MANY_TO_MANY junctions: Junction table entries referencing duplicates are re-linked to the master. If a junction already exists for the master, the duplicate entry is removed to avoid conflicts.
- Duplicate records are permanently deleted.
The merge operation is atomic. If any step fails, the entire operation is rolled back and no data is modified.
Error Responses
All handler errors are returned with HTTP status 400. A missing or invalid Bearer token is rejected earlier with 401 UNAUTHORIZED.
| Status | Code | Description |
|---|---|---|
| 401 | UNAUTHORIZED | Invalid or missing API token |
| 400 | PERMISSION_DENIED | Token is not an admin, or lacks delete permission on a duplicate record |
| 400 | INVALID_OBJECT | The object name does not exist |
| 400 | MISSING_REQUIRED_FIELD | master_id or duplicate_ids is missing (merge) |
| 400 | RECORD_NOT_FOUND | The master record was not found during a fill_blanks merge |
| 400 | MERGE_FAILED | The merge operation failed |
| 400 | WRITE_FAILED | An unexpected error occurred while writing |