# Contacts

**You will:** Keep one contact per person, find them by your own id, and know where presence, events, opt-outs and erasure live.

**You need:**
- A key with contacts:write (contacts:read to list and read, companies:write to create companies)

A contact is the person your organization attends. Cases, messages and events hang from a contact. Phone and email are optional, because a contact can come from your own app and have neither. The `externalRef` is your own id for the person: use it, and you never need to store a Lelemon id in your database.

The examples use this client:

```ts
import { Lelemon } from '@lelemon-ai/sdk';

const lelemon = new Lelemon({ apiKey: process.env.LELEMON_API_KEY });
```

## Create a contact

Only `name` is required. Sending the same `externalRef` twice answers `409 external_ref_conflict`.

```bash title="cURL"
curl https://api.lelemon.cl/v2/contacts \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: contact-cust-88123" \
  --json '{
    "name": "Ana Rojas",
    "email": "ana.rojas@example.com",
    "phone": "+56911112222",
    "externalRef": "cust-88123",
    "tags": ["vip", "newsletter"]
  }'
```

```ts title="Node.js"
const contact = await lelemon.contacts.create({
  name: 'Ana Rojas',
  email: 'ana.rojas@example.com',
  phone: '+56911112222',
  externalRef: 'cust-88123',
  tags: ['vip', 'newsletter'],
});
```

```json title="MCP"
{
  "tool": "contacts.create",
  "arguments": { "body": { "name": "Ana Rojas", "externalRef": "cust-88123" } }
}
```


Expected: `201` with the contact:

**Contact**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `contact` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `email` | string or null | yes |  |
| `phone` | string or null | yes |  |
| `externalRef` | string or null | yes |  |
| `companyId` | string or null | yes | The company of this contact, or null when it has none. |
| `tags` | string[] | yes |  |
| `fields` | object | yes | The values of the contact fields this contact has, by key. Empty when it has none. |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |
| `presence` | Presence | no | Whether the contact is online. Present only when the organization setting presenceEnabled is on, at least one presence signal has arrived, and the key has the contacts:presence-read scope. |
| `match` | object or null | no | Only when you search with q: the field of the contact that matched and its value, or null when the match was the name, email, phone or externalRef. Fields marked sensitive are never searched or shown. |
| `caseCount` | integer | no | Only when you search, filter by pipeline or open cases, or sort: how many cases the contact has. |
| `openCaseCount` | integer | no | Same as caseCount, only the open ones. |
| `lastActivityAt` | string | no | Same as caseCount: the latest message of its cases, or its creation date. |

## Create or update in one call

`PUT /v2/contacts/by-external-ref/{externalRef}` creates the contact when the reference is new and updates it when it exists. It always answers `200` and calling it twice leaves one contact. The fields you leave out stay as they were; a new contact with no `name` is named after the `externalRef`. This is the call to make every time your system sees a person.

```bash title="cURL"
curl -X PUT https://api.lelemon.cl/v2/contacts/by-external-ref/cust-88123 \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: upsert-cust-88123-1" \
  --json '{ "name": "Ana Rojas", "email": "ana.rojas@example.com" }'
```

```ts title="Node.js"
const upserted = await lelemon.contacts.upsertByExternalRef('cust-88123', {
  name: 'Ana Rojas',
  email: 'ana.rojas@example.com',
});
```


To open a case for a person you may not have stored yet, send `contact` instead of `contactId` to `POST /v2/cases`: it finds the person by `externalRef` or phone, or creates them, and does not change a contact that exists.

```ts
const kase = await lelemon.cases.create({
  contact: { name: 'Pedro Soto', externalRef: 'cust-90211', phone: '+56911113333' },
  agentId: 'agt_...',
});
```

## Contact fields

A contact field is a piece of data you want on every person: a plan, a customer number, a birth date. You define it once for the organization and then send its value with each contact. The dashboard shows the values in the contact page, lets your team edit them there and filters the Contacts list by them.

`POST /v2/contact-fields` takes a `key` (letters, numbers and underscores, starting with a letter, fixed once created), a `type` and optionally a `label`. The types check what you send:

| `type` | Accepts |
| --- | --- |
| `text`, `textarea` | A string, up to 500 and 5000 characters |
| `email` | An email address |
| `phone` | A phone number: digits, spaces, `+`, `-`, `(` and `)` |
| `url` | An `http` or `https` URL |
| `number` | A JSON number |
| `date` | A date as `YYYY-MM-DD` |
| `boolean` | `true` or `false` |
| `select` | One of its `options` (required for this type and not allowed for the others) |

An organization can have up to 100 contact fields.

```bash title="cURL"
curl https://api.lelemon.cl/v2/contact-fields \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{
    "key": "plan",
    "type": "select",
    "label": "Plan",
    "options": [{ "value": "free" }, { "value": "pro", "label": "Pro" }]
  }'
```

```ts title="Node.js"
const field = await lelemon.contactFields.create({
  key: 'plan',
  type: 'select',
  label: 'Plan',
  options: [{ value: 'free' }, { value: 'pro', label: 'Pro' }],
});
```


Expected: `201` with the field, whose id starts with `cfd_`.

**ContactField**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `contact_field` |
| `id` | string | yes |  |
| `key` | string | yes |  |
| `label` | string | yes |  |
| `type` | enum | yes | `text`, `textarea`, `email`, `phone`, `url`, `number`, `date`, `boolean`, `select` |
| `options` | ContactFieldOptionOut[] | yes |  |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

Send the values in `fields` when you create a contact, update it, or use the upsert. A key that was never defined, or a value that does not fit its type, answers `400 invalid_request` with `param` set to `fields.<key>`, and nothing is saved. On an update the keys you leave out stay as they were and `null` removes a value.

```bash title="cURL"
curl -X PUT https://api.lelemon.cl/v2/contacts/by-external-ref/cust-88123 \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "fields": { "plan": "pro" } }'
```

```ts title="Node.js"
const withPlan = await lelemon.contacts.upsertByExternalRef('cust-88123', {
  fields: { plan: 'pro' },
});
await lelemon.contacts.update(withPlan.id, { fields: { plan: null } });
```


Every contact answers its values in `fields`, which is `{}` when it has none. `GET /v2/contact-fields`, `GET /v2/contact-fields/{id}`, `PATCH /v2/contact-fields/{id}` (the `label` and, for a select, its `options`) and `DELETE /v2/contact-fields/{id}` complete the resource. Deleting a field also removes its value from every contact, and it cannot be undone. Contact fields are not for secrets: they are searchable and your whole team sees them.

## Find and list contacts

`GET /v2/contacts` lists newest first. Filter by `externalRef`, `phone`, `email` or `updatedSince` (a date-time, to sync only what changed). `presence=online` keeps the contacts online now and needs `contacts:presence-read`. `GET /v2/contacts/{id}` returns one.

```bash title="cURL"
curl "https://api.lelemon.cl/v2/contacts?externalRef=cust-88123" \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl "https://api.lelemon.cl/v2/contacts?updatedSince=2026-10-01T00:00:00Z&limit=100" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const [match] = (await lelemon.contacts.list({ query: { externalRef: 'cust-88123' } })).data;

for await (const item of lelemon.contacts.list({ query: { updatedSince: '2026-10-01T00:00:00Z', limit: 100 } })) {
  console.log(item.id, item.externalRef);
}
```


Lists follow the [pagination rules](/basics/lists).

## Search contacts

Send `q` to search by name, email, phone, `externalRef` and the values of the contact fields, ignoring case and accents. Each result says in `match` which field matched and its value, or `null` when the match was the name, email, phone or `externalRef`. A field the team marked sensitive in any pipeline is never searched and never shown in `match`, for contacts with or without cases.

`pipelineId` keeps the contacts that have a case in that pipeline, `hasOpenCases=true` the ones with an open case, and `fieldKey` with `fieldValue` the ones whose contact field has exactly that value. `order` is `activity` (latest activity first) or `name`. They combine with each other and with the filters above.

```bash title="cURL"
curl "https://api.lelemon.cl/v2/contacts?q=rojas&hasOpenCases=true&order=name" \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl "https://api.lelemon.cl/v2/contacts?fieldKey=plan&fieldValue=pro" \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const found = await lelemon.contacts.list({
  query: { q: 'rojas', hasOpenCases: true, order: 'name' },
});
for (const contact of found.data) {
  console.log(contact.name, contact.match, contact.openCaseCount);
}

const onPro = await lelemon.contacts.list({ query: { fieldKey: 'plan', fieldValue: 'pro' } });
```


With any of these parameters each contact also carries `caseCount`, `openCaseCount` and `lastActivityAt`, and the list is no longer newest first: it follows `order`, which is `activity` when you do not send it. It is the same search as the Contacts screen of the dashboard.

## Companies

A company groups the contacts that work in it. `GET /v2/companies` lists them by name, with `q` (part of the name), `status` and `type` filters, and `GET /v2/companies/{id}` returns one with the ids of its contacts in `contactIds`. Both need `contacts:read`.

`POST /v2/companies`, `PATCH /v2/companies/{id}` and `DELETE /v2/companies/{id}` need `companies:write`, the same permission the dashboard asks of its owners and admins to edit a company. Only `name` is required: `type` is `prospect` and `status` is `lead` when you do not send them. The `rut` is unique among the live companies of the organization, and one that is taken answers `409 conflict` with `param: "rut"`. `PATCH` changes only what you send, and `null` removes `rut`, `website`, `industry`, `source` or `notes`.

Delete is for data created by mistake: a company that was lost as a business stays, with `status: "lost"`. Deleting a company never deletes its contacts. They stay, their `companyId` becomes `null`, and the `rut` can be used again.

To link a person to a company, send `companyId` when you create the contact or in `PATCH /v2/contacts/{id}`. `null` unlinks it, and a company that does not exist in the organization answers `400` with `param: "companyId"`. Linking is a contact edit, so it needs `contacts:write`. Every contact answers its `companyId`.

`companyId` is also accepted by `PUT /v2/contacts/by-external-ref/{externalRef}` (omitted keeps the company, `null` unlinks), by `contact` in `POST /v2/cases` and by `contact` in `POST /v2/contact-events`, with `param: "contact.companyId"` when the company does not exist. In `POST /v2/cases` it applies only when the contact is created there. Deleting a company at the same time as linking a contact to it is safe: the contact is either linked to a live company or the link is refused.

```bash title="cURL"
curl https://api.lelemon.cl/v2/companies \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  -H "Idempotency-Key: company-76123456-7" \
  --json '{ "name": "Panadería La Espiga", "rut": "76.123.456-7", "type": "client", "status": "active" }'

curl -X PATCH https://api.lelemon.cl/v2/contacts/$CONTACT_ID \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "companyId": "'$COMPANY_ID'" }'

curl "https://api.lelemon.cl/v2/companies?q=espiga&status=active" \
  -H "Authorization: Bearer $LELEMON_API_KEY"

curl -X DELETE https://api.lelemon.cl/v2/companies/$COMPANY_ID \
  -H "Authorization: Bearer $LELEMON_API_KEY"
```

```ts title="Node.js"
const company = await lelemon.companies.create({
  name: 'Panadería La Espiga',
  rut: '76.123.456-7',
  type: 'client',
  status: 'active',
});

await lelemon.contacts.update(contact.id, { companyId: company.id });

const companies = await lelemon.companies.list({ query: { q: 'espiga', status: 'active' } });
const refreshed = await lelemon.companies.retrieve(companies.data[0].id);
console.log(refreshed.contactIds);

await lelemon.companies.update(company.id, { status: 'paused', website: null });
await lelemon.companies.delete(company.id);
```


**Company**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `object` | enum | yes | `company` |
| `id` | string | yes |  |
| `name` | string | yes |  |
| `rut` | string or null | yes |  |
| `type` | enum | yes | What the company is for the organization. `prospect`, `client`, `partner`, `vendor` |
| `status` | enum | yes | How the relationship is today. `lead`, `active`, `paused`, `lost` |
| `website` | string or null | yes |  |
| `industry` | string or null | yes |  |
| `source` | string or null | yes |  |
| `notes` | string or null | yes |  |
| `createdAt` | string | yes |  |
| `updatedAt` | string | yes |  |

## The account in your system

If a contact has an `externalRef` and the organization has an active [data connector](/guides/data-connectors), the contact page of the dashboard shows the account of that reference in your system, read live each time the page opens. It uses the same permissions and the same masking as the connector: only owners and admins see it, personal data stays hidden until someone reveals it and each reveal is recorded. Nothing is stored. When your system does not answer, the page says why and the rest of the contact keeps working.

## Update a contact

`PATCH /v2/contacts/{id}` changes only what you send. Send `null` in `email`, `phone` or `externalRef` to remove it. `tags` replaces the whole list, so send every tag you want to keep.

```bash title="cURL"
curl -X PATCH https://api.lelemon.cl/v2/contacts/$CONTACT_ID \
  -H "Authorization: Bearer $LELEMON_API_KEY" \
  --json '{ "phone": null, "tags": ["vip", "b2b"] }'
```

```ts title="Node.js"
await lelemon.contacts.update(contact.id, { phone: null, tags: ['vip', 'b2b'] });
```


## Events about a contact

Tell Lelemon what happened to a person in your system and let it message them. `POST /v2/contact-events` takes a `type` you choose, the contact (by `externalRef`) and your `data`. A [trigger](/guides/events-and-triggers) that listens to that `type` sends the template, opens the case or moves it. The contact is created if it does not exist.

```ts
const result = await lelemon.contactEvents.create({
  type: 'order.shipped',
  contact: { externalRef: 'cust-88123', name: 'Ana Rojas', phone: '+56911112222' },
  data: { orderNumber: 'A-1042', carrier: 'Starken' },
});
console.log(result.result, result.caseActions);
```

The event costs money when a message goes out, so it takes `?dryRun=true` and requires an `Idempotency-Key`. The full flow, with templates and cadences, is in [Message on events](/guides/events-and-triggers).

## Presence

If your product has a web or mobile app, report who is online and your team sees it in the inbox and can filter by it. See [Show who is online](/guides/presence).

## Opt-outs

An opt-out stops all sending to a contact on one channel (`whatsapp`, `email` or `voice`): events, messages and campaigns skip them. `POST /v2/whatsapp/templates/{id}/test` too: a phone that belongs to a contact who opted out of `whatsapp` answers `422 contact_opted_out` and nothing is sent. A contact who writes "stop" is opted out by the platform; you can also set it yourself. `GET /v2/opt-outs` lists them and needs `contacts:read`.

```ts
await lelemon.contacts.optOuts.set('ctc_...', 'email');
await lelemon.contacts.optOuts.clear('ctc_...', 'email');
```

`DELETE` clears only the opt-outs you set. One the contact asked for stays until they write again, and answers `409`.

An opt-out blocks every WhatsApp message to that number, whoever sends it: a template, a reply from the team in the dashboard, an agent or a test send (`422 contact_opted_out`). It also holds for another contact with the same phone number or email. It survives the erasure of the person: the opt-out keeps only a keyed hash of the number and the address, never the number itself, so a new contact with the same number is still opted out.

## Erase a contact

`DELETE /v2/contacts/{id}` erases the person for good: the contact, its cases, messages, files, fields, agent memory and logs lose their content. It runs as a job and answers `202` with a `suppression` you follow with `GET /v2/suppressions/{id}` and the `contact.suppressed` webhook. It answers `409 resource_in_use` while the contact has open cases, unless you send `closeOpenCases=true`. Use `?dryRun=true` first to see the inventory without content. The deadlines, what is kept and how to redact a single message are in [Erase a contact or a message](/guides/erasure).

```ts
const preview = await lelemon.contacts.delete('ctc_...', { query: { dryRun: true } });
const job = await lelemon.contacts.delete('ctc_...');
```

## Errors you will meet

| Error | Cause |
| --- | --- |
| `400 invalid_request` | A field breaks a limit, or a value in `fields` does not fit its type; `param` names it |
| `403 scope_missing` | The key lacks `contacts:write` or `contacts:read` |
| `404 not_found` | The contact does not exist in this mode, or it was erased |
| `409 external_ref_conflict` | Another contact already uses that `externalRef` |
| `409 conflict` | A contact field with that `key` already exists, or the organization has 100 |
| `409 resource_in_use` | Erasure of a contact with open cases |

All codes are in the [error catalog](/errors).

## Good practice

- Put your own customer id in `externalRef` and use the upsert. It makes every sync safe to repeat.
- Store phones in E.164 (`+56911112222`). The API keeps what you send.
- Do not use tags as a database: keep a few that your team filters by. For data about the person, define a contact field.
- Check opt-outs before you build your own sending on top of Lelemon; Lelemon already skips them.

## What's next

- [Message on events](/guides/events-and-triggers) to write first.
- [Pipelines](/guides/pipelines) for the cases a contact opens.
- [Erase a contact or a message](/guides/erasure) for data requests.
