On this page
Guides / CRM and contacts

CRM and contacts

Create contacts, send them WhatsApp messages, tag them and walk your whole base.

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:

const contact = await met.contacts.create({
  name: 'Ada Lovelace',
  phone: '+573001112233',
});
contact = met.contacts.create(name="Ada Lovelace", phone="+573001112233")
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

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

For approved WhatsApp templates:

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

Tags and notes

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);
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:

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:

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.

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:

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

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:

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:

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 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:

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:

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 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.