# IDs and identity

Source: https://kirora.io/docs/api/identifiers

Learn how Kirora ids, your own externalId, email and phone identify a contact, how identify resolves and refuses them, and how to get the id of every resource.

A contact can be known by four values: Kirora's id for it, your own id (`externalId`),
its email address and its phone number. This page sets out what each one is, where each
is accepted, how identify decides which contact you mean, and how to get the id of every
other resource.

## The four identifiers

| Identifier | Assigned by | Unique in the workspace | Notes |
| --- | --- | --- | --- |
| `id` | Kirora, at creation | Yes | A UUID. Never changes |
| `externalId` | You | Yes | Your user or customer id, up to 255 characters, matched exactly |
| `email` | You | Yes | Matched trimmed and without case |
| `phone` | You | Yes | Stored in E.164 form (`+447700900123`) |

Use `externalId` as the key between your system and Kirora. It is the identifier you
control, it does not change when a contact changes their address, and the web SDK's user
token names the contact by it.

A contact needs at least one of `externalId`, `email` or `phone`. Each is unique, so two
contacts can never share one: a write that would give a contact a value another contact
holds is refused with 409 `ALREADY_EXISTS`, with `details.field` naming it.

## Where your own id and the email address are accepted

Your `externalId` works in these places, and only these. Everywhere else, use Kirora's
`id`. The email address works on the contact routes and wherever a body takes a
`ContactIdentifier`: exactly one of `contactId`, `externalId` or `email`.

| Where | How |
| --- | --- |
| `/v1/contacts/{id}` and every route under it | Add `?idType=externalId` and put your id in the path, or `?idType=email` and the address, URL-encoded (`ada%40example.com`). One that names no contact is 404 |
| `trackEvent` and each event in `trackEventsBatch` | `externalId` in place of `contactId` or `email` |
| `sendTransactionalMessage` | `to: { "externalId": "…" }` |
| `triggerCampaign` | `externalId` on a recipient |
| `addListContacts`, `deleteListContacts` | `externalIds` in place of `contactIds` or `emails` |
| `deleteContacts` | A `ContactIdentifier` per entry |
| `mergeContacts` | `keep`, a `ContactIdentifier` |

For example, reading a contact by your id, then by its address:

```
GET /v1/contacts/customer-881?idType=externalId
GET /v1/contacts/ada%40example.com?idType=email
```

`listContacts` has no exact-match filter for either: read the one contact by its
identifier instead.

## How identify finds the contact

`POST /v1/contacts/identify` (`identifyContact`) creates or updates one contact. It looks
for an existing contact by `externalId`, then `email`, then `phone`, and stops at the
first that matches. If none matches, it creates the contact and returns 201 with
`meta.created: true`; otherwise it updates the match and returns 200.

```json
{ "externalId": "customer-881", "email": "ada@example.com", "firstName": "Ada" }
```

Once a contact is matched, the identifiers ranked below the one that matched may change.
Those ranked above it can only be filled in where the contact has none:

- **Matched by `externalId`.** `email` and `phone` in the request replace the
  contact's. This is how you change an address.
- **Matched by `email` or `phone`, and the contact has no `externalId`.** Your
  `externalId` is added to it.
- **Matched by `email` or `phone`, and the contact has a different `externalId`.** The
  request names two contacts. It is refused with 409 `CONFLICT` and `details.field` naming
  the identifier that differs (`externalId` here). Nothing is written.

| Response | Cause | What to do |
| --- | --- | --- |
| 409 `CONFLICT` | A lower-ranked identifier matched a contact that holds a different value for a higher-ranked one | Check your mapping: either the email belongs to someone else in your system, or the contact needs merging |
| 409 `ALREADY_EXISTS` | The `email`, `phone` or `externalId` you are setting belongs to another contact (`details.field`) | Merge the two contacts, or correct the value |
| 400 `VALIDATION_ERROR` | None of the three identifiers was sent | Send at least one |
| 400 `SIGNUP_BLOCKED` | The address matches the workspace's signup blocklist, so no contact is created | Nothing to retry; the address is blocked on purpose |

`identifyContactsBatch` resolves each contact exactly this way, and returns these errors
per contact (see [Batches](https://kirora.io/docs/api/batches)).

## When an email address changes

Identify by `externalId` with the new address, or update the contact:

```
PATCH /v1/contacts/customer-881?idType=externalId
{ "email": "ada.lovelace@example.com" }
```

The contact keeps its id, consent, history and list memberships. Messages already sent
keep the address they went to in `recipientEmail`; the contact's current address is
`contact.email`. The change is recorded as a `contact.field_updated` event, which can
start a journey.

Identifying by the new address alone, without `externalId`, finds no contact and creates
a second one. That is the usual cause of duplicates.

## Sends and events that name a contact

- **A transactional send to an email address** that no contact has creates a contact for
  transactional mail only. With `externalId` or `contactId`, the contact must already
  exist. To create a contact or set its profile fields, call `POST /v1/contacts/identify`
  first. A transactional send never changes a contact's profile or consent.
- **A tracked event** must name an existing contact. One that names nobody is 404 and
  creates nothing, so identify first.
- **The web SDK** acts for the contact whose `externalId` is the user
  token's `sub`. If no contact has it, the SDK's first call creates one. Sign-out in the
  SDK detaches the device from that contact; it does not delete the contact.

## Merging duplicates

`POST /v1/contacts/{id}/merge` (`mergeContacts`) folds the contact in the path, the
duplicate, into the contact you keep, which `keep` names by any identifier. Here the
email-only Ada, signed up before she had an account, merges into `customer-881`:

```
POST /v1/contacts/ada%40example.com/merge?idType=email
{ "keep": { "externalId": "customer-881" } }
```

- The kept contact keeps its own values and gains any the duplicate had that it lacks,
  including identifiers. An address or phone it gains brings that channel's consent
  with it.
- The duplicate's messages, events, devices and consent changes move across; its lists
  and suppressions are added.
- The duplicate is then deleted, ending its journey enrollments. A merge cannot be
  undone. The response is the kept contact, after the merge.

A contact with a message being handed to a provider returns 409
`CONTACT_SEND_IN_PROGRESS`; retry in a few seconds. A message waiting to be retried
doesn't block it. A `keep` that names no contact is 404; merging a contact into
itself is 400.

## Getting the id of a resource

Every resource has a Kirora `id` (a UUID). Create calls return it in `data.id`, and list
calls return it on every item. Some resources can also be named another way.

| Resource | Where its id comes from | Also named by |
| --- | --- | --- |
| Contact | `identifyContact`, `listContacts` | `externalId` or `email`, on the routes above |
| Template | `createTemplate`, `listTemplates` | Its `alias`, in a transactional send's `email.template` |
| List | `createList`, `listLists` | — |
| Segment | `createSegment`, `listSegments` | — |
| Campaign | `createCampaign`, `listCampaigns` | — |
| Journey | `createJourney`, `listJourneys` | — |
| Topic | `createTopic`, `listTopics` | — |
| Push project and push app | `listPushProjects`: the project's `id`, and each app's `id` in `apps` (the `pushAppId` a device registers with) | — |
| Sending domain | `createSendingDomain`, `listSendingDomains` | — |
| Webhook | `createWebhook`, `listWebhooks` | — |
| Message | The send's response (`data.messages[].id`), `listMessages` | `correlationId`, as a filter on `listMessages` |
| Event | `trackEvent` (`data.id`) | Your idempotency key, which a repeat returns the same event for |
| Your workspace | `getMe` (`data.workspace.id`) | — |
| Partner workspace | `createWorkspace`, `listWorkspaces` | Its `externalId`, which is your own tenant id, in the path and the `Kirora-Workspace` header |

Ids differ between workspaces, so a staging workspace's ids never work in live (see
[Staging](https://kirora.io/docs/api/staging)).

## Shared devices

One device can be used by several of your contacts: a family tablet, a shop's till. Kirora follows
whoever is signed in, never the device:

- **From your server,** name the contact who acted in each call (`externalId`, `email` or
  `contactId`), not the device.
- **With the web SDK**, call `logout()` in your sign-out handler, before your
  own sign-out, then identify the next contact when they sign in. Events tracked before
  sign-in wait on the device and go as the next contact who signs in. The SDK asks your
  server for a fresh user token at sign-in and whenever one expires; see
  [Web SDK](https://kirora.io/docs/sdks/web).
- **Push:** registering an installation for a different contact moves the device to
  them, so the previous contact stops receiving push on it. See
  [Push notifications](https://kirora.io/docs/api/push).

## Recording consent

Identify can also record marketing consent, in `consent`: `email` and `push`, each `true`
or `false`, and `statement`, how the contact agreed. Granting consent (a channel, or a
topic set to `true`) needs the statement; without one the call is refused with 400
`VALIDATION_ERROR` at `consent.statement`:

```json
{
  "externalId": "user_123",
  "email": "ada@example.com",
  "consent": {
    "email": true,
    "statement": { "basis": "opted_in", "evidence": "Sign-up form on example.com" }
  }
}
```

The same request without `statement` answers:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Say how this contact agreed (consent.statement: { basis, evidence }) when granting consent",
    "errors": [
      {
        "in": "body",
        "field": "consent.statement",
        "code": "custom",
        "message": "Say how this contact agreed (consent.statement: { basis, evidence }) when granting consent"
      }
    ]
  }
}
```

Withdrawing (`"email": false`) needs no statement. A contact reads its consent back as `consent: { "email": true, "push": false }`. Topic
choices go in `topics`, keyed by topic id: `{"<topicId>": {"subscribed": true}}` for
every channel, or `{"<topicId>": {"channels": {"email": true, "push": false}}}` per
channel. Each change is recorded in
the contact's consent changes (`listContactConsentChanges`). A transactional send needs
no marketing consent.

## Next steps

- [Batches and asynchronous results](https://kirora.io/docs/api/batches) — identify up to 100 contacts in one call
- [Events](https://kirora.io/docs/api/events) — track what an identified contact does
- [Objects and rules](https://kirora.io/docs/api/models) — the documents that refer to these ids
- [Errors](https://kirora.io/docs/api/reference/errors) — `CONFLICT`, `ALREADY_EXISTS` and the rest