# Merge a contact into another

Source: https://kirora.io/docs/api/reference/mergeContacts

`POST /v1/contacts/{id}/merge`

Requires an API key with the `contacts:write` scope. [About keys and scopes](https://kirora.io/docs/api/reference/authentication)

Works with integration and partner keys, not agent keys.

A repeat after success returns 404 or 409: treat that as done. [About idempotency](https://kirora.io/docs/api/reference/idempotency)

Merges this contact, a duplicate, into the one `keep` names, for one person recorded twice. The kept contact keeps its values and gains any the duplicate had that it lacks: identifiers, name, timezone, country, locale, and attributes and dates by key. An email address or phone it gains brings that channel's consent with it; its own consent otherwise stands. The duplicate's messages, events, conversions, devices and consent changes move to the kept contact; its lists and suppressions are added, and its topic choices fill in topics the kept contact has not chosen. Then the duplicate is deleted, ending its journey enrollments. It cannot be undone. While a message to either is being handed to the provider, which takes seconds, it returns 409 `CONTACT_SEND_IN_PROGRESS`; retry shortly. A message waiting to be retried moves to the kept contact and still sends; the duplicate's copy of a campaign both received is skipped (`contact_deleted`) instead; a push that already reached one of the duplicate's devices is marked `sent`, and one a push service refused is marked `failed`, as it would have ended anyway. A `keep` that names no contact is a 404, and merging a contact into itself a 400 at `keep`.

## Path parameters

- `id` (string, required, 1–255 characters) — Kirora's id for the contact, your own with `idType=externalId`, or its email address with `idType=email`, URL-encoded (`@` as `%40`).

## Query parameters

- `idType` (string, optional, one of `id`, `externalId`, `email`, default `"id"`) — What `{id}` is: Kirora's id for the contact (`id`, the default), your own (`externalId`, as set with identify) or its email address (`email`, matched trimmed and without case).

## Header parameters

- `Kirora-Workspace` (string, optional, pattern `^[A-Za-z0-9._:-]{1,128}$`) — Partner keys only: the child workspace to act in, by the external id the partner gave it. Without it, a partner key acts in its own workspace. Any other key that sends it is refused with 403, and an unknown workspace is 404.
- `Idempotency-Key` (string, optional, 8–255 characters) — Makes a retry safe: a repeat with the same key and body within 24 hours gets the first response again, with `Idempotency-Replay: true`. The same key with another request is 409 `IDEMPOTENCY_KEY_REUSED`; while the first is still running, 409 `IDEMPOTENCY_KEY_IN_USE`; after an `unsafe` first request failed or never returned, 409 `IDEMPOTENCY_KEY_OUTCOME_UNKNOWN`. Use a new key, such as a UUID, for each new request.

## Request body

- `keep` (object, required) — The contact to keep, named one way; the contact in the path merges into it and is deleted.
  - Any of: **By contact id**
    - `contactId` (string, required, uuid) — Kirora's id for the contact.
  - Any of: **By external id**
    - `externalId` (string, required, 1–255 characters) — Your own id for the contact, as set with identify.
  - Any of: **By email**
    - `email` (string, required) — The contact's email address, matched trimmed and without case.

## Returns

Returns `200 OK` with a `ContactDetail` in `data`. It can also return `409 Conflict`, `413 Payload Too Large` or `415 Unsupported Media Type`; see the statuses below. Otherwise returns an error; [Errors](https://kirora.io/docs/api/reference/errors) lists every code and which to retry.

- `id` (string, required, uuid)
- `externalId` (string | null, required) — Your own id for the contact, if set.
- `email` (string | null, required, email)
- `firstName` (string | null, required)
- `lastName` (string | null, required)
- `phone` (string | null, required) — Phone number in E.164 format.
- `timezone` (string | null, required) — IANA timezone, e.g. `Europe/London`.
- `country` (string | null, required) — ISO 3166-1 alpha-2 code: as written, or inferred from the phone, else the timezone.
- `locale` (string | null, required) — BCP 47 tag (`fr`, `fr-CA`): the language the contact reads (its first subtag), and how merge tags format numbers, dates and money for them.
- `rfm` (object | null, required) — The contact's RFM (recency, frequency and monetary value) group among the workspace's customers, from its purchase events (the workspace's `purchaseEvent`: an order, a paid invoice, a booking, a donation), each score from 1 to 5 (a fifth of the customers each; 5 is the most recent, most frequent or highest spend), and their purchase totals. Spend counts only purchases in the workspace's main currency; when the purchase carries no amount, `monetary` follows frequency. Recomputed daily, so a purchase today shows tomorrow; `null` for someone who has never purchased.
  - `group` (string, required, one of `champions`, `loyal`, `cant_lose_them`, `potential_loyalists`, `new_customers`, `need_attention`, `about_to_sleep`, `at_risk`, `hibernating`, `lost`)
  - `recency` (integer, required, min 1, max 5)
  - `frequency` (integer, required, min 1, max 5)
  - `monetary` (integer, required, min 1, max 5)
  - `purchaseCount` (integer, required, min > 0) — Their purchase events (the workspace's `purchaseEvent`), in any currency.
  - `totalSpent` (object | null, required) — What their purchases' amounts (`purchaseValueProperty`) add up to, in the workspace's main currency (`currency` on `GET /v1/reports/rfm`); purchases in other currencies and refunds are left out. `null` when the purchase carries no amount.
    - `currency` (string | null, required) — The currency, an ISO 4217 code; null when none was named.
    - `value` (number, required) — The amount in that currency.
  - `lastPurchasedAt` (string, required, date-time) — When their latest purchase happened.
- `dates` (map<string, string>, required) — Named calendar dates, e.g. `birthday`, as `YYYY-MM-DD` or `--MM-DD`.
- `attributes` (map<string, object>, required) — Custom attributes, keyed by attribute name.
- `consent` (ContactConsent, required) — Consent on each channel.
  - `email` (boolean, required) — Whether the contact has consented to email.
  - `push` (boolean, required) — Whether the contact has consented to push.
- `createdAt` (string, required, date-time)
- `updatedAt` (string, required, date-time)
- `emailSuppression` (object, required)
  - `suppressed` (boolean, required) — Whether email to this contact is blocked.
  - `reason` (string | null, required) — Why email to this contact is suppressed: `bounced`, `complained`, `complained_transactional`, `unsubscribed`, `manual`, `imported`, or `other_workspace` (a bounce or complaint in another workspace with the same owner or partner).
- `listIds` (string[], required) — Ids of the lists the contact is in.

Statuses:

- `200 OK`: The kept contact, after the merge.
- `409 Conflict`: The request conflicts with current state — a duplicate unique value, or a resource whose status does not allow this operation.
- `413 Payload Too Large`: The body is larger than this operation accepts (`PAYLOAD_TOO_LARGE`).
- `415 Unsupported Media Type`: The body is not JSON, or not the upload the operation takes (`UNSUPPORTED_MEDIA_TYPE`).

Headers on `200 OK`:

- `Idempotency-Replay` (string, optional, one of `true`) — `true` when this is the stored response to an earlier request with the same key.

## Example: Merge an email-only duplicate into Amaka

```bash
curl -X POST https://api.kirora.io/v1/contacts/018f2a4e-6b1c-7d3e-9f40-37386ae05a6b/merge \
  -H "Authorization: Bearer $KIRORA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "keep": {
      "externalId": "customer-4821"
    }
  }'
```

Response `200`:

```json
{
  "data": {
    "id": "0192f3a1-7c2e-7d4b-9a10-3e5f6a7b8c9d",
    "externalId": "customer-4821",
    "email": "amaka@example.com",
    "firstName": "Amaka",
    "lastName": "Obi",
    "phone": "+447700900123",
    "timezone": "Europe/London",
    "country": "GB",
    "locale": "en-NG",
    "rfm": {
      "group": "loyal",
      "recency": 3,
      "frequency": 4,
      "monetary": 5,
      "purchaseCount": 6,
      "totalSpent": {
        "currency": "USD",
        "value": 412.5
      },
      "lastPurchasedAt": "2026-03-02T18:20:00.000Z"
    },
    "dates": {
      "birthday": "1992-03-14"
    },
    "attributes": {
      "plan": "pro",
      "favourite_spice": "smoked paprika",
      "orders": 3
    },
    "consent": {
      "email": true,
      "push": false
    },
    "createdAt": "2026-09-12T08:15:00.000Z",
    "updatedAt": "2026-10-01T09:30:00.000Z",
    "emailSuppression": {
      "suppressed": false,
      "reason": null
    },
    "listIds": [
      "0192f3a2-1b3c-7e5d-8a21-4f6a7b8c9d0e"
    ]
  }
}
```
