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=emaillistContacts 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.
{ "externalId": "customer-881", "email": "[email protected]", "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.emailandphonein the request replace the contact’s. This is how you change an address. - Matched by
emailorphone, and the contact has noexternalId. YourexternalIdis added to it. - Matched by
emailorphone, and the contact has a differentexternalId. The request names two contacts. It is refused with 409CONFLICTanddetails.fieldnaming the identifier that differs (externalIdhere). 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).
When an email address changes
Identify by externalId with the new address, or update the contact:
PATCH /v1/contacts/customer-881?idType=externalId
{ "email": "[email protected]" }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
externalIdorcontactId, the contact must already exist. To create a contact or set its profile fields, callPOST /v1/contacts/identifyfirst. 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
externalIdis the user token’ssub. 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).
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,emailorcontactId), 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. - 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.
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:
{
"externalId": "user_123",
"email": "[email protected]",
"consent": {
"email": true,
"statement": { "basis": "opted_in", "evidence": "Sign-up form on example.com" }
}
}The same request without statement answers:
{
"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.