> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clustr-ai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# CRM Cleaning API

> Every /api/crm/cleaning endpoint, with its parameters and an example

These are the endpoints the Clustr app itself calls for CRM Cleaning. Use them to build
your own review screen, or to drive cleaning from a script.

## Before you start

**Authentication.** These endpoints take the signed-in user's session token, not an API
key:

```
Authorization: Bearer <session token>
```

The workspace comes from the token. An `X-Tenant-Id` header you send is replaced by
it.

To automate CRM Cleaning with an API key, use the [MCP tools](https://docs.clustr-ai.com/crm-cleaning/mcp.md)
instead. They cover the same actions and accept a key from your
[API settings page](https://app.clustr-ai.com/settings/api).

**Base URL.** `https://api.clustr-ai.com`

**Add-on.** Every endpoint needs the CRM Cleaning add-on. Without it, calls return `403`
with code `FEATURE_NOT_ENABLED`.

**Responses.** Every response is wrapped the same way:

```json theme={null}
{ "success": true, "data": { } }
```

An error carries a code and a message:

```json theme={null}
{ "success": false, "error": { "code": "REVERT_EXPIRED", "message": "the 30-day revert window has expired" } }
```

A body that is not valid JSON, or has the wrong shape, answers `400 BAD_REQUEST` with
the message "invalid request body". `POST /apply`, `POST /revert-batch` and
`PUT /settings` answer `400 INVALID_REQUEST` instead. Calls that name a run or a change
that does not exist answer `404 NOT_FOUND`.

**Paging.** List endpoints take `limit` (default 50, at most 200) and `offset`, and
return `total` so you can page.

## Words used below

| Word | Meaning |
| - | - |
| Change | One proposed write to one record: an `action` on an `object_type` and `object_id`, usually on one `property`, from `before_value` to `after_value`. |
| Kind | The `(action, object_type, property)` triple that groups changes, for example `format_normalize` on `contact` `phone`. |
| Bucket | `auto` (safe to apply in bulk), `confirm` (needs a person) or `flag` (a finding: never applied). |
| State | `pending`, `accepted`, `applied`, `dismissed`, `reverted`, `failed`, `conflict`, `stale` or `superseded`. Read the legacy `escalated` as `pending`. |
| Run | One scan, with its status and what it found. |

## The queue

### Get the queue

`GET /api/crm/cleaning/queue`

Every waiting change that can be applied, from every scan, grouped by kind, plus
counts. Findings are not in it.

```json Response theme={null}
{
  "success": true,
  "data": {
    "groups": [
      {
        "bucket": "auto",
        "object_type": "contact",
        "property": "phone",
        "action": "format_normalize",
        "count": 214,
        "examples": [
          { "id": "c1f0…", "object_id": "51234901", "property": "phone",
            "subject_name": "Ana Silva", "before_value": "02071234567",
            "after_value": "+44 20 7123 4567" }
        ]
      }
    ],
    "counts": { "auto": 214, "confirm": 38, "total": 252, "findings": 96,
                "oldest_first_seen_at": "2026-09-02T08:14:00Z" }
  }
}
```

### List waiting changes

`GET /api/crm/cleaning/queue/changes`

One page of the queue. Merge items carry both records side by side in `merge`: the
record that survives, the value each field keeps, `certain`, why the pair matched
(`match_key` and `matched_value`, shown in the app as "Same email: …") and
`name_conflict` when the person names disagree.

| Query | Description |
| - | - |
| `action`, `object_type`, `property` | Narrow to one kind |
| `bucket` | `auto` or `confirm` |
| `q` | Search by record name |
| `limit`, `offset` | Paging |

Returns `{ items, total, limit, offset }`.

### Apply a group

`POST /api/crm/cleaning/queue/apply`

Accepts every waiting change of one kind. A background worker writes them to the CRM,
usually within minutes. `action` and `object_type` are required (`400 INVALID_REQUEST`
otherwise). For merges and company links, leave `property` empty to apply every waiting
change of that object type. An optional `bucket` (`auto` or `confirm`) narrows the
group; `flag` answers `409 FINDING_NOT_APPLICABLE`.

**Merges in bulk.** Every bulk apply (this call, `apply-similar`, a run's group or
bucket) takes only merges whose `merge_tier` is `certain`. A Likely merge stays waiting
until you accept it on its own with `POST /changes/{change_id}/accept`.

```json Request theme={null}
{ "action": "format_normalize", "object_type": "contact", "property": "phone" }
```

```json Response theme={null}
{ "success": true, "data": { "status": "accepted", "queued": 214 } }
```

### Dismiss a group

`POST /api/crm/cleaning/queue/dismiss`

Dismisses every waiting change of one kind. Nothing is written. `suppress` defaults to
`true`: Clustr also writes an exclusion rule so the kind is not suggested again. Send
`"suppress": false` to clear the group and keep the suggestions. (The app's first option,
**Dismiss these**, sends `false`.)

```json Request theme={null}
{ "action": "associate_company", "object_type": "contact", "property": "", "suppress": false }
```

```json Response theme={null}
{ "success": true, "data": { "status": "dismissed", "dismissed": 12, "rule": null } }
```

## One change

| Method and path | What it does |
| - | - |
| `POST /api/crm/cleaning/changes/{change_id}/accept` | Accept one waiting change (state `pending`, or the legacy `escalated`), a Likely merge included. The worker applies it. A finding answers `409 FINDING_NOT_APPLICABLE`. |
| `POST /api/crm/cleaning/changes/{change_id}/dismiss` | Dismiss one waiting change, or one finding. Nothing is written. |
| `POST /api/crm/cleaning/changes/{change_id}/apply-similar` | Accept this change and every waiting change of the same kind in the same run. Only for safe changes: answers `422 NOT_AUTO_SAFE` otherwise. Merges: Certain ones only. |
| `POST /api/crm/cleaning/changes/{change_id}/dismiss-similar` | Dismiss every waiting change of the same kind in the same run and write an exclusion rule. |
| `POST /api/crm/cleaning/changes/{change_id}/revert` | Put the old value back. Applied changes only, within 30 days. |
| `POST /api/crm/cleaning/changes/{change_id}/retry` | Queue a `failed` or `conflict` change again. |

The accept, dismiss and similar calls take an optional body. On a merge, `accept` also
takes `field_overrides`, a map of field to the value the surviving record should keep:

```json Request theme={null}
{ "field_overrides": { "jobtitle": "Head of Finance" } }
```

Revert answers:

| Status | Code | Why |
| - | - | - |
| `200` | | Reverted |
| `409` | `NOT_APPLIED` | Only applied changes can be reverted |
| `409` | `REVERT_CONFLICT` | The field was edited in the CRM since the change was applied. Nothing is written. |
| `422` | `REVERT_EXPIRED` | The 30 days are over |
| `422` | `NOT_REVERTABLE` | This kind of change can never be reverted |

Revert reads the field first. When the CRM already holds the old value, the change is
marked reverted without a write.

Retry answers `409 NOT_RETRYABLE` when the change is not `failed` or `conflict`, and
`404 NOT_FOUND` when it does not exist. Retry reads
the live value again and writes the proposed value, so on a `conflict` it replaces the
edit someone made in the CRM. Format fixes are the exception: they still refuse when the
live value is no longer the same value. See [History](https://docs.clustr-ai.com/crm-cleaning/history.md).

## Findings

### List findings

`GET /api/crm/cleaning/findings`

Problems cleaning cannot fix by itself, counted by kind and listed with a link to open
each record in the CRM (HubSpot only).

| Query | Description |
| - | - |
| `kind` | One finding kind, for example `flag_ownerless` |
| `detail_kind` | A detector's sub-kind, for example `won_but_open` |
| `object_type` | `contact`, `company` or `deal` |
| `q` | Search by record name |
| `limit`, `offset` | Paging |

```json Response theme={null}
{
  "success": true,
  "data": {
    "kinds": [
      { "kind": "flag_ownerless", "object_type": "deal", "count": 41, "mergeable": false },
      { "kind": "duplicate_companies", "object_type": "company", "count": 9, "mergeable": true }
    ],
    "items": [
      { "id": "9b2e…", "kind": "duplicate_companies", "object_type": "company",
        "label": "Northwind Traders", "reason": "Same domain northwind.example",
        "crm_url": "https://app.hubspot.com/contacts/…", "mergeable": true,
        "members": [
          { "object_id": "18200450112", "label": "Northwind Traders", "crm_url": "https://app.hubspot.com/contacts/…" },
          { "object_id": "18200450987", "label": "Northwind", "crm_url": "https://app.hubspot.com/contacts/…" }
        ] }
    ],
    "total": 50, "limit": 50, "offset": 0
  }
}
```

### Export findings

`GET /api/crm/cleaning/findings/export`

The open findings as a CSV file, with the same `kind`, `detail_kind`, `object_type` and
`q` filters as the list. Columns: Kind, Object type, Record ID, Record, Reason, First
seen, Last seen, CRM link, Clustr ID, Members, Member CRM links (a duplicate finding's
records and their links, separated by semicolons). See [Export the history](#export-the-history) for the file
format.

### Dismiss findings

`POST /api/crm/cleaning/findings/dismiss`

Dismisses every open finding of one kind. Nothing changes in the CRM. `suppress`
defaults to `false`: the kind comes back on the next scan if the problem is still
there. Send `"suppress": true` to stop reporting it.

```json Request theme={null}
{ "kind": "flag_ownerless", "object_type": "deal", "suppress": false }
```

### Merge a duplicate group

`POST /api/crm/cleaning/duplicates/{change_id}/merge`

Takes the `id` of a duplicate finding whose `mergeable` is `true`. Calling it is the
confirmation: the merges are queued as accepted. Clustr reads the
records again from the CRM, your survivorship policy picks the record to keep, and one
merge per extra record is queued for the worker. HubSpot only.

A duplicate finding lists its records in `members`, each with its own `crm_url`.

For `duplicate_deals`, only deals that share a company can be merged. Clustr reads the
deals again and merges only while they are all still open, in one pipeline, for one
company, with the same name, amount and currency, and created within 90 days of each
other. The deal with more contacts, or else the older one, is kept. A deal merge cannot
be reverted.

```json Response theme={null}
{
  "success": true,
  "data": {
    "change_id": "9b2e…", "object_type": "company",
    "survivor_id": "18200450112", "loser_ids": ["18200450987"],
    "bucket": "auto", "queued": 1, "existing": 0, "skipped": 0, "members_gone": 0
  }
}
```

| Status | Code | Why |
| - | - | - |
| `409` | `NOT_DUPLICATE_CLUSTER` | The id is not an open duplicate finding |
| `409` | `FAMILY_CLUSTER` | The records are related companies (a parent and a subsidiary), not duplicates |
| `409` | `REPORT_ONLY_DUPLICATE` | Deals that share a name but no company. They are reported, never merged. |
| `409` | `DUPLICATE_ALREADY_RESOLVED` | Already merged or dismissed. A second call always answers this. |
| `409` | `MERGES_DISABLED` | Merges are turned off in settings |
| `422` | `MERGE_UNSUPPORTED` | The CRM cannot merge records (Salesforce, Attio) |
| `422` | `MERGE_UNRESOLVED` | Fewer than two of the records still exist, or (deals) they no longer match: one is closed, or the pipeline, company, name, amount or currency changed |

## Scans and runs

### Start a scan

`POST /api/crm/cleaning/scan`

Starts a scan of the connected CRM. The body is optional; its `source` labels the run
(default `api`). If a scan is already running, you get that run back with
`already_running: true` instead of a second scan.

```json Response theme={null}
{
  "success": true,
  "data": {
    "run": { "id": "4d7a…", "status": "scanning", "mode": "full", "source": "api" },
    "summary": { "total": 0, "auto": 0, "confirm": 0, "flag": 0 }
  }
}
```

### Run enrichment only

`POST /api/crm/cleaning/enrich`

Runs only the enrichment checks, inside the monthly enrichment budget: **Enrich all**.
It covers every allowed field on contacts and companies and spends at most about a third
of what is left this month. Paid lookups land on the next scan. Same response as a scan.

### List runs

`GET /api/crm/cleaning/runs?limit=20`

The workspace's scans, newest first, scheduled ones included. `limit` defaults to 20,
at most 100.

### Get a run

| Method and path | What it returns |
| - | - |
| `GET /api/crm/cleaning/runs/{run_id}` | The run: `status` (`pending`, `scanning`, `ready`, `partial`, `applying`, `completed`, `failed`), `mode` (`full` or `incremental`), `coverage` and `skip_reasons` |
| `GET /api/crm/cleaning/runs/{run_id}/counts` | Counts by bucket and state |
| `GET /api/crm/cleaning/runs/{run_id}/changes` | The run's changes, paged. Filters: `bucket`, `state`, `category`, `q`, and `object_type`, `action`, `property` or `kind` for one group |
| `GET /api/crm/cleaning/runs/{run_id}/breakdown?category=` | Issues rolled up by field and problem |
| `GET /api/crm/cleaning/runs/{run_id}/groups` | The run's waiting changes, grouped by kind |
| `POST /api/crm/cleaning/runs/{run_id}/groups/apply` | Apply one group of this run. Same body as the queue. |
| `POST /api/crm/cleaning/runs/{run_id}/groups/dismiss` | Dismiss one group of this run. Same body as the queue. |
| `POST /api/crm/cleaning/apply` | Accept a whole bucket of a run: `{ "run_id": "…", "bucket": "auto" }`. The `flag` bucket answers `409 FINDING_NOT_APPLICABLE`: findings are never applied. |

The run reads answer `404 NOT_FOUND` for a run that does not exist. A `partial` run
stopped early but kept everything it found. `coverage` says how much of
the CRM it examined.

## History

`GET /api/crm/cleaning/history`

Every change across all runs, newest first.

| Query | Description |
| - | - |
| `state` | For example `applied`, `reverted`, `failed`, `conflict`, `stale` |
| `bucket` | With `state=stale`: `confirm` for "resolved before review", `flag` for "no longer found" |
| `q` | Search by record name |
| `decided_by` | Only changes decided by exactly this decider, as `decided_by` shows it |
| `decided_by_kind` | `person` for changes someone on your team decided, `automatic` for changes Clustr decided or nobody has yet |
| `action` | Only these rules, comma-separated, for example `format_normalize,merge_contacts` (at most 50) |
| `object_type` | `contact`, `company` or `deal` |
| `object_id` | One record's changes |
| `property` | Changes to one field, for example `phone` or `state` |
| `from`, `to` | An RFC 3339 time range, `from` included and `to` not, on when the change was applied (or found, if it never was). This is the time the list is sorted on. |
| `limit`, `offset` | Paging. `limit` is 50 by default and at most 200. |

Returns `{ changes, total, limit, offset, has_more }`. Each change has `revertable`,
which is `true` only when a revert would be accepted right now. A filter Clustr cannot
read, such as a `from` that is not an RFC 3339 time or a `to` before `from`, answers
`400` with code `INVALID_REQUEST` and the reason.

### Export the history

`GET /api/crm/cleaning/history/export`

The history as a CSV file, with the same filters as the list.
Columns: Date applied, Rule, Object type, Record ID, Record name, Field, Before, After,
State, Decided by, CRM link, Clustr ID, Activity date (when the change was applied, else
last updated), Reason, Error.

* The body is the file, not JSON: `Content-Type: text/csv` and
  `Content-Disposition: attachment; filename="clustr-crm-cleaning-history-2026-10-08.csv"`.
* At most 50,000 rows. `limit` lowers that.
* `X-Export-Total` is how many rows match, `X-Export-Rows` how many are in the file, and
  `X-Export-Truncated` is `true` when the file stopped at the limit. `X-Total-Count`
  carries the same count as `X-Export-Total`, under its earlier name.
* Rows are sent as they are read. If the export fails partway, the file's last line is
  `# EXPORT FAILED: this file is incomplete. Download it again.` and the connection is
  closed. Send `Accept-Encoding: gzip` to receive it compressed.
* UTF-8 with a byte order mark, so Excel reads accents. A cell that starts with `=`, `+`,
  `-` or `@` gets a leading `'`.

```bash theme={null}
curl -H "Authorization: Bearer $TOKEN" -o history.csv \
  "https://api.clustr-ai.com/api/crm/cleaning/history/export?state=applied"
```

### Revert a batch

`POST /api/crm/cleaning/revert-batch`

Reverts every applied change of one rule, through the same checks as a single revert.
This is **Revert all** in History.

| Field | Description |
| - | - |
| `action` | Required. The rule, for example `format_normalize`, `canonicalize_domain`, `canonicalize_linkedin_url`, `associate_company` or `enrichment_fill` |
| `run_id` | Only changes from this run |
| `object_type` | `contact` or `company` |
| `property` | Only changes to this field, for example `phone` |
| `since`, `until` | Only changes applied in this window, RFC 3339, both inclusive |
| `cursor` | `next_cursor` from the previous call |

```json Request theme={null}
{ "action": "format_normalize", "property": "phone", "since": "2026-10-06T00:00:00Z", "until": "2026-10-06T23:59:59Z" }
```

```json Response theme={null}
{
  "success": true,
  "data": {
    "reverted": 1000, "skipped_not_revertable": 0, "skipped_expired": 12,
    "conflict": 3, "failed": 0, "has_more": true, "next_cursor": "8f1c…"
  }
}
```

* Merges and phone repairs are never reverted here; they count in
  `skipped_not_revertable`. Revert a merge with `POST /changes/{change_id}/revert`.
* Changes past their 30 days count in `skipped_expired`.
* A field edited in the CRM since counts in `conflict` and is left as it is.
* The skipped counts are given on the first call only, so the numbers of a multi-call
  batch add up.
* One call reverts at most 1,000 changes, or stops starting new ones after about 20
  seconds. When `has_more` is `true`, call again with `cursor` set to `next_cursor`.

Errors: `400 INVALID_REQUEST` when `action` is missing, a time is not RFC 3339,
`run_id` or `cursor` is not an id, or `until` is before `since`.

## Settings

### Get settings

`GET /api/crm/cleaning/settings`

The whole settings document. `effective_modes` is read-only: the mode each rule
resolves to, defaults included. `effective_when_different` is read-only too: what
enrichment does with a value your CRM already has, per object type, defaults included
(`{"contact": "suggest", "company": "keep"}` unless you chose otherwise).

### Replace settings

`PUT /api/crm/cleaning/settings`

Replaces the whole document. Read it first, change what you need, and send it all back:
a field you leave out is reset. Invalid values answer `400 INVALID_SETTINGS`.

```json Request theme={null}
{
  "default_mode": "review",
  "actions": {
    "format_normalize": { "mode": "auto" },
    "associate_company": { "mode": "off" }
  },
  "exclusions": [
    { "action": "format_normalize", "property": "mobile_phone", "reason": "Kept as typed by sales" }
  ],
  "schedule": { "enabled": true, "cadence": "weekly", "day_of_week": 1, "hour": 6, "minute": 0, "timezone": "Europe/London" },
  "merge_enabled": true,
  "realtime_duplicates": true,
  "escalation_recipients": ["ops@example.com"],
  "alerts": { "duplicate_spike": true, "completeness_drop": false, "duplicate_spike_threshold": 50 },
  "enrichment": { "enabled": true, "audience": "active", "when_different": { "contact": "suggest", "company": "keep" } },
  "survivorship": { "primary_rules": [{ "kind": "lifecycle_stage_rank" }, { "kind": "has_owner" }] },
  "match_rules": { "contact": [{ "fields": ["first_name", "last_name", "company"] }] }
}
```

The fields are explained in [Settings](https://docs.clustr-ai.com/crm-cleaning/settings.md). `alerts` turns the
[weekly summary highlights](https://docs.clustr-ai.com/crm-cleaning/health-and-summary.md#weekly-summary-highlights)
off one by one; leaving it out keeps both on with a threshold of 25. A negative threshold answers
`400 INVALID_SETTINGS`. `survivorship.primary_rules` and `match_rules` are explained in
[Duplicates and merges](https://docs.clustr-ai.com/crm-cleaning/queue-and-findings.md#duplicates-and-merges); an
unknown kind or field, a `property_equals` rule without a property and value, more than
10 primary rules, more than 5 match rules per object type or more than 6 fields in one
rule answers `400 INVALID_SETTINGS`.
`realtime_duplicates` set to `false` stops the duplicate check
on [new records](https://docs.clustr-ai.com/crm-cleaning/checks.md#new-records); leaving it out keeps it on while
merges are on.
`enrichment.when_different` says what enrichment does when your CRM already has a
different value, per object type (`contact`, `company`): `keep` or `suggest`, a change
that always waits for a person. Unset, contacts suggest and companies keep. Another key or value answers
`400 INVALID_SETTINGS`. See [Enrichment](https://docs.clustr-ai.com/crm-cleaning/enrichment.md#when-your-crm-already-has-a-different-value).
A suggestion in the queue carries `reason`, such as "Your CRM says X; LinkedIn now
says Y".

### Suggestions

`GET /api/crm/cleaning/settings/suggestions`

Rules your team always accepts and could switch from Ask me to Do it, from the last 90
days of decisions. Only Format & casing and LinkedIn URL are ever suggested. See
[Suggestions to switch to Do it](https://docs.clustr-ai.com/crm-cleaning/settings.md#suggestions-to-switch-to-do-it).

```json Response theme={null}
{
  "success": true,
  "data": {
    "suggestions": [
      { "action": "format_normalize", "label": "Format & casing",
        "accepted": 48, "dismissed": 0, "reverted": 0,
        "message": "You accepted 48 of 48 formatting fixes. Switch this rule to Do it?" }
    ],
    "windowDays": 90
  }
}
```

To act on one, set the rule's mode to `auto` in the settings and save.

### List templates

`GET /api/crm/cleaning/settings/templates`

The ready-made recipes: `{ templates: [{ id, name, description, highlights, settings, show_flags }] }`.
Apply one by merging its `settings` into yours and saving.

## Health, completeness and summary

| Method and path | What it returns |
| - | - |
| `GET /api/crm/cleaning/completeness?weeks=12` | Field completeness over every synced record, the open duplicate count, the figures from about a week ago, and one point per week (`weeks` 1 to 52) |
| `GET /api/crm/cleaning/summary?days=7` | What cleaning did over the last `days` (1 to 90, default 7): changes applied, fields filled, merges, what is waiting, completeness then and now, and the time saved: `minutesSaved`, `hoursSaved` and `timeSavedText` ("About 3.5 hours of manual cleanup saved this week.") |
| `GET /api/crm/cleaning/health?trend=30` | The health score of the last scan, the issue count per category, the score over the last `trend` scans (default 30), what has been corrected so far, and how many writes failed recently. The app's Overview shows completeness instead. |
| `GET /api/crm/cleaning/budget` | The enrichment budget for this calendar month: `monthly_cap` (0 means enrichment is off), `used_this_month`, `remaining`, `window_start`, `window_reset`. Also the match rate: `records_sent` (found plus not found), `records_matched` (records the provider found, whether or not a field was filled), `records_filled`, `records_already_complete` (found, nothing to fill), `records_not_found`, `match_rate` (`records_matched / records_sent`, 0 to 1), `match_by_object` (the same counters for `company` and `contact`) and `field_fills` (`object_type`, `property`, `records`). Contacts not found are counted from October 2026; from November 2026 the numbers are complete |
| `GET /api/crm/cleaning/capabilities` | Which checks can fix things on your CRM and which only report. See [capabilities by provider](https://docs.clustr-ai.com/crm-cleaning-capabilities.md). |
| `GET /api/crm/cleaning/property-insights?object_type=contact` | Every property with how many records fill it, custom properties with no data or rarely filled flagged `empty` or `rarely_used`, and a summary per object with its fill bands and the trend of past counts. While a count runs, `counted`, `toCount` and `etaSeconds`. HubSpot only. See [property insights](https://docs.clustr-ai.com/crm-cleaning/property-insights.md#api). |
| `POST /api/crm/cleaning/property-insights/refresh` | Queue a recount in the background; it starts within a minute, whatever the hour. Once an hour: `429 REFRESH_RATE_LIMITED` with `Retry-After` inside the hour. Nothing is written to your CRM. |
| `GET /api/crm/cleaning/values/fields` | The fields whose values can be standardised, with `hubspot_only`. See [standardise values](https://docs.clustr-ai.com/crm-cleaning/standardise-values.md#api). |
| `GET /api/crm/cleaning/values?object_type=contact&field=job_title` | One field's values grouped into spellings of the same value (`clusters`: `canonical`, `total`, `variants` with `value`, `count` and `mapped_to`), the field's `mappings`, and `truncated` when only the 2,000 most common values were read. On a HubSpot dropdown field, option values carry their `label` and `options` lists the dropdown's options. A mapping the latest full scan did not apply to some records carries `skipped` (`reason`, `message`, `count`, `at`). |
| `POST /api/crm/cleaning/values/mappings` | Save one mapping `{object_type, field, from, to}`; the mapping with the same `to` is replaced. `400 INVALID_FIELD` or `400 INVALID_MAPPING` with the reason. |
| `DELETE /api/crm/cleaning/values/mappings?object_type=&field=&to=` | Delete the mapping to `to`. `404 MAPPING_NOT_FOUND` when there is none. |

```json Completeness response theme={null}
{
  "success": true,
  "data": {
    "hasData": true,
    "contacts": 18240,
    "companies": 6105,
    "fields": [
      { "key": "contactJobTitle", "objectType": "contact", "label": "Job title",
        "phrase": "Contacts with a job title", "headline": true,
        "filled": 11980, "total": 18240, "percent": 65.7 }
    ],
    "openDuplicates": 23,
    "previous": { },
    "trend": [ ]
  }
}
```

## Older endpoints

`GET /api/crm/cleaning/escalations` and its `POST /escalations/changes/{change_id}/accept`
and `dismiss` calls serve the older email review page. New code should use the queue.
`/api/crm/cleaning/escalations/act` takes the signed one-click links in the summary
email; it needs no session, only the link's `token`.

The `/api/crm/cleaning/quality/*` endpoints (`GET /quality/pending`,
`POST /quality/samples/{sample_id}/verdict`, `GET /quality/precision`) are for Clustr
staff and answer `403 STAFF_REQUIRED` to everyone else.

Two more routes exist only on Clustr's internal network, not at the base URL above:
sending the summary email now (`POST /escalations/email/run`), and one record's likely
duplicates (`POST /records/{object_type}/{object_id}/duplicates`). Use the
`find_crm_record_duplicates` [MCP tool](https://docs.clustr-ai.com/crm-cleaning/mcp.md) for the second.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.