Documentation

Contacts & preferences

Address recipients by your own ids, keep the attributes your templates need on the contact, and let people choose what reaches them.

All documentation
Getting started
Overview Quickstart SDK packages
Frontend
React Vue Svelte React Native Web Components Headless & theming Real-time
Backend API
Authentication Templates Trigger & status Contacts & preferences
More
Self-hosting MCP server Reference

Sync contacts

Sync the contacts you'll notify. Address them by your own external_id; you never need to store Elaan's internal ids.

Create
POST /v1/contacts
Authorization: Bearer sk_…

{
  "external_id": "crm-12345",
  "emails": ["ada@example.com"],
  "attributes": { "first_name": "Ada" }
}
This route creates; it does not upsert. A second call with an external_id that already exists returns 409, it does not overwrite. If you want create-or-update semantics, use POST /v1/contacts/bulk with a single row: it matches on external_id and leaves any field you omit alone. See Making sure a contact exists.

Every management route also accepts ext:<external_id> in place of the internal id (for example GET /v1/contacts/ext:crm-12345), so your backend operates entirely in your own identifiers.

Typically your backend syncs the basic contact (identity, email, attributes), while the frontend adds device-facing bits like push tokens and preference overrides through the SDK.

Updating a contact

A contact is not a document you replace wholesale. It is a root with several independently owned collections, and different callers own different parts: your backend owns identity, email and attributes, while the contact's own browser owns their push subscription, preferences and language. So each part has its own route, and nobody can clear another's half by omitting it.

WhatRouteCredential
EmailsPOST / DELETE /v1/contacts/{id}/emailsService key
PhonesPOST / DELETE /v1/contacts/{id}/phonesService key
One attributePUT / DELETE /v1/contacts/{id}/attributes/{key}Service key
Everything at oncePUT /v1/contacts/{id}Service key
Push destinationPOST / DELETE /v1/contacts/{id}/push-subscriptionsEither
One preferencePUT /v1/contacts/{id}/preferences, cleared with DELETE /v1/contacts/{id}/preferences/{type_key}/{channel}Either
Preferred languagePUT /v1/contacts/{id}/languageEither

Registering a push token is one of these routes, not a contact update. The SDK calls POST /v1/contacts/{id}/push-subscriptions directly from the browser or app with a contact token; it never touches POST /v1/contacts. A destination is identified by its (provider, value) pair, and re-registering an existing one replaces it rather than being ignored, so a browser that reissues the same endpoint with rotated keys updates those keys instead of leaving stale ones behind. Registering on every launch is the intended pattern and costs nothing.

Making sure a contact exists

Most integrations need this when minting a contact token: the user has just loaded a page, and you don't know whether you have synced them yet. Three ways, best first.

Mint first, create on 404. POST /v1/contacts/tokens returns 404 for an unknown external_id. Creation is the exception rather than the rule, so this is one request on every page load after the first instead of two.

let res = await mintToken(externalId);
if (res.status === 404) {
  await createContact(externalId);   // POST /v1/contacts
  res = await mintToken(externalId);
}

Upsert with a one-row bulk import. POST /v1/contacts/bulk matches on external_id and leaves omitted fields alone, so it is safe to run on every login without clearing the phone number or preferences the contact set themselves.

Create and treat 409 as success. Correct, but it spends a wasted round trip on every request forever.

What does not work is calling POST /v1/contacts and treating a non-2xx as a failure. It succeeds exactly once per contact and returns 409 after that, so a token endpoint written that way works on the first page load and fails on every one after it.

Attributes reach your templates

Whatever you put in attributes becomes available to every template rendered for that contact, so first_name set once at sync time is readable as {{ contact.first_name }} in every email and inbox message without being passed on each trigger. Attributes are flat strings; anything you would otherwise repeat in every variables payload belongs here instead.

Importing in bulk

Moving an audience you already have is one call per batch, not one per person. POST /v1/contacts/bulk takes up to 500 rows and matches each to an existing contact by external_id, updating it or creating it if there is none.

Request
POST /v1/contacts/bulk
Authorization: Bearer sk_…

{
  "contacts": [
    { "external_id": "crm-1001", "emails": ["ada@example.com"],
      "attributes": { "first_name": "Ada", "tier": "gold" } },
    { "external_id": "crm-1002", "emails": ["grace@example.com"] }
  ]
}
Response
{
  "created": 1, "updated": 1, "failed": 0,
  "rows": [
    { "index": 0, "external_id": "crm-1001", "outcome": "updated" },
    { "index": 1, "external_id": "crm-1002", "outcome": "created" }
  ]
}

One bad row does not lose the batch. A malformed address or a blank id fails that row and says why, against its position in what you sent, while every other row imports. A 5,000-contact migration tells you which three lines to fix rather than refusing all of it.

That applies to the content of a row. A malformed request shape is different: a value of the wrong JSON type, or a key that is not part of the schema, is rejected before any row is read and returns 422 for the whole batch.

A field you omit is left alone; a field you send empty is cleared. That is what makes it safe to import a file of just external_id and emails over contacts that already carry phones and attributes. It also means a row can deliberately remove a stale address by sending an empty list.

Re-running a corrected file is the expected way to work: rows that already landed are updated, not duplicated.

Importing a CSV

The console takes a spreadsheet directly, under Contacts, Import CSV. It reads the file in your browser, shows you what it understood, then sends it in batches and reports every row.

contacts.csv
external_id,emails,phones,language,first_name,tier
crm-1001,ada@example.com,+15550101,en,Ada,gold
crm-1002,"grace@example.com; g.hopper@navy.mil",,es,Grace,
crm-1003,,,,Alan,silver
  • external_id is required: it is how a row is matched to a contact.
  • emails and phones (singular accepted) may hold several values in one cell, separated by a semicolon.
  • language is a tag like es.
  • Every other column becomes an attribute named after its header, so an export from your own system usually works unedited.

Header names are matched case insensitively. A blank cell means the row does not mention that field, so a sparse spreadsheet never clears anything.

Preferences

The <Preferences /> component renders and edits the full matrix automatically. To build your own, read the matrix (every notification type × channel, with the effective on/off and whether the contact overrode it):

GET /v1/contacts/{contact_id}/preferences   // contact or service token
PUT /v1/contacts/{contact_id}/preferences   // { notification_type_key, channel, enabled }

In the SDK this is the usePreferences() hook / elaan.preferences store: setPreference(typeKey, channel, enabled) and clearPreference(typeKey, channel).

The matrix only ever offers a channel that type can actually reach, which means a channel with a template behind it. Offering a recipient a switch that silently does nothing is worse than not offering it, so a type with no push template shows no push column.

Transactional types are not listed. Password resets, email verification and the like are marked as not opt-out-able, and are omitted from the contact-facing matrix entirely. That does not mean "always sent": the type's own channel defaults still decide what fires. Only the recipient's veto is removed.

Preferred language

A contact can carry a preferred language, set at creation or updated on its own route:

PUT /v1/contacts/{contact_id}/language   // { "language": "fr" }

Templates can have per-language variants, and the contact's language selects between them, falling back to the language-less default when there is no variant. In the SDK this is setLanguage(). It is deliberately a separate route rather than part of the whole-contact update, so a partial-shaped update cannot silently clear it.