# CRM and contacts

The `contacts` domain gives you programmatic access to the workspace's CRM: contacts, messages, notes and tags. It needs the `contacts:read` and/or `contacts:write` scopes.

The routes are **flat** (`/contacts/…`): they operate on the workspace the key belongs to, so there is no need to pass `workspaceId`.

## Create a contact

Requires `phone` **or** `email`:

```ts
const contact = await met.contacts.create({
  name: 'Ada Lovelace',
  phone: '+573001112233',
});
```
```python
contact = met.contacts.create(name="Ada Lovelace", phone="+573001112233")
```
```bash
curl -X POST https://api.met.meteor.com.co/api/v1/contacts \
  -H "Authorization: Bearer $MET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada Lovelace","phone":"+573001112233"}'
```

## Send a message

```ts
await met.contacts.sendMessage(contact.id, 'Hi! Writing to you from the API 👋');
```
```python
met.contacts.send_message(contact["id"], "Hi! Writing to you from the API 👋")
```

For approved WhatsApp templates:

```ts
await met.contacts.sendTemplate(contact.id, { /* ... */ });
```

## Tags and notes

```ts
await met.contacts.addTag(contact.id, 'hot-lead');
await met.contacts.createNote(contact.id, 'Asked for a demo on Friday.');

const tags = await met.contacts.tags(contact.id);
```
```python
met.contacts.add_tag(contact["id"], "hot-lead")
met.contacts.create_note(contact["id"], "Asked for a demo on Friday.")

tags = met.contacts.tags(contact["id"])
```

## Contact fields: `$` for system ones, bare for yours

This convention is the source of most errors when writing data, so it is worth being clear about:

- **System fields** → carry a `$`: `$name`, `$email`, `$phone`, `$status`, `$assigned_user_id`, `$tags`, `$last_message_at`.
- **Custom fields** (the ones the workspace defines) → carry their `field_key` **bare**, with no `$`: `etapa`, `monto`, `empresa`.

Writing `$etapa` for a custom field **does not fail**: it stores a different key that no view reads. The field ends up invisible in the app.

To discover a workspace's custom fields and their valid values:

```ts
const defs = await met.variables.fieldDefinitions();
// [{ field_key: 'etapa', type: 'select', options: ['Nuevo', 'Calificado', …] }, …]
```

For `select` fields you have to send **exactly** one of its `options`. For `currency` fields, `options` carries a single element with the currency's ISO code (`['COP']`) and the contact's value is just the number.

## The sales pipeline

Every workspace starts with three custom fields — `etapa` (stage), `monto` (amount) and `fecha_cierre_estimada` (estimated close date) — and a "Pipeline" view. There is no separate "opportunity" entity: **the pipeline is contact fields**, so they are read and written like any other field.

```ts
await met.contacts.update(contact.id, { data: { etapa: 'Negociación', monto: 6300000 } });

const summary = await met.contacts.pipelineSummary();
// { stages: [{ stage: 'Negociación', contacts: 12, amount: 45000000 }, …] }
```

`pipelineSummary()` aggregates in the database over **all** your contacts, not over one page. Contacts with no stage are grouped under `__no_value__`. If your workspace renamed those fields, pass them: `pipelineSummary({ stage_field: 'fase', amount_field: 'valor' })`.

## Walk the whole base

Use `iterate()` to walk **every** contact without handling cursors by hand:

```ts
for await (const c of met.contacts.iterate({ limit: 100 })) {
  console.log(c.id, c.name ?? c.phone);
}
```

Underneath it asks for `order=id` and chains `starting_after`. That detail matters if you paginate by hand: the default order of `list()` is by conversation recency and **it reshuffles with every incoming message**, so a sweep over it skips contacts. To walk everything without losing anything, ask for `order=id` from the first page and use `starting_after` with the id of the last contact you received.

For a single page, `list()` accepts CRM filters (status, channel, search) and returns `{ data, total, has_more }`.

## Read conversations

```ts
const page = await met.contacts.messages(contact.id, { limit: 50 });
```

## WhatsApp: more than text

`sendMessage` sends text over the contact's active channel and is enough for almost everything. When you need the person to **choose** instead of typing, WhatsApp has formats of its own, and they go through the channel — not the contact — because the format depends on what that channel allows:

```ts
await met.channels.whatsapp.sendButtons(channelId, {
  contact_id: contact.id,
  body_text: 'Shall we confirm Thursday at 3?',
  buttons: [
    { id: 'yes', title: 'Yes, confirm' },
    { id: 'reschedule', title: 'Reschedule' },
  ],
});
```

Up to **3 buttons**; with more options, a list:

```ts
await met.channels.whatsapp.sendList(channelId, {
  contact_id: contact.id,
  body_text: 'Pick a time',
  button_text: 'See times',
  sections: [{ title: 'Thursday', rows: [
    { id: 'th-9', title: '9:00 a.m.' },
    { id: 'th-15', title: '3:00 p.m.' },
  ] }],
});
```

The `id` of each button or row is **yours**: it is what comes back when the person chooses, so put something in it you can interpret without a separate lookup table. What comes back arrives as a message from the contact and fires the same [events](events.html) as a typed message.

The other formats are straightforward: `sendReply` quotes an earlier message (`wa_message_id`), `sendReaction` puts an emoji on it, `sendLocation` sends coordinates with a name and address, and `sendContactCard` shares a contact card.

One important one apart: **`sendTemplate`**. Outside the 24-hour window since the person's last message, WhatsApp **only** allows writing with a template approved by Meta — any other send fails. It is not a Meteor limit and there is no way around it: if your integration writes to contacts who have not just replied, the template is the path, not the exception.

## Broadcasts

A broadcast is a send to a **segment**, not to a contact. It pays to check before sending:

```ts
const { count } = await met.broadcasts.previewCount([
  { field: 'estado', operator: 'eq', value: 'cliente' },
]);
```

`previewCount` resolves the segment and tells you how many people it will reach, without sending anything. Always use it: it is the difference between finding out the filter was wrong now or after writing to your entire base.

What gets broadcast is a **flow**, not a text:

```ts
const b = await met.broadcasts.create({
  name: 'July promo',
  flow_id: 'flw_123',
  filters: [{ field: 'estado', operator: 'eq', value: 'cliente' }],
  send_mode: 'scheduled',
  scheduled_at: '2026-08-01T14:00:00Z',
  throttle_per_minute: 60,
});
```

`flow_id` is required, and it is the design difference worth understanding: a broadcast does not send a loose message, it starts a [flow](functions-and-flows.html) for each contact in the segment. That is why it can answer, branch on what they reply and chain steps — things a flat send cannot do.

`throttle_per_minute` exists because sending everything at once is the fastest way to get rate-limited by the provider. And `send_mode` can be `now`, `scheduled`, `manual` or `recurring`; with `scheduled` you need `scheduled_at`.

A scheduled one can be `cancel()`ed as long as it hasn't gone out; once sent, no — `cancel` stops what is pending, it does not undo what was delivered. And since a broadcast goes out beyond the 24-hour window by definition, the rule above applies: the flow's first message goes with an approved template.

> Contacts operate on the key's workspace. A **partner** key can operate several of its clients' workspaces; in that case, see the [Partners API](../../partners.html).
