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 packagesFrontend
React Vue Svelte React Native Web Components Headless & theming Real-timeBackend API
Authentication Templates Trigger & status Contacts & preferencesMore
Self-hosting MCP server ReferenceSync contacts
Sync the contacts you'll notify. Address them by your own external_id; you never need to store Elaan's internal ids.
POST /v1/contacts Authorization: Bearer sk_… { "external_id": "crm-12345", "emails": ["ada@example.com"], "attributes": { "first_name": "Ada" } }
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.
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.
| What | Route | Credential |
|---|---|---|
| Emails | POST / DELETE /v1/contacts/{id}/emails | Service key |
| Phones | POST / DELETE /v1/contacts/{id}/phones | Service key |
| One attribute | PUT / DELETE /v1/contacts/{id}/attributes/{key} | Service key |
| Everything at once | PUT /v1/contacts/{id} | Service key |
| Push destination | POST / DELETE /v1/contacts/{id}/push-subscriptions | Either |
| One preference | PUT /v1/contacts/{id}/preferences, cleared with DELETE /v1/contacts/{id}/preferences/{type_key}/{channel} | Either |
| Preferred language | PUT /v1/contacts/{id}/language | Either |
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.
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.
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.
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.csvexternal_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.
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.