En esta sección
Guías / CRM y contactos

CRM y contactos

Crea contactos, mándales mensajes de WhatsApp, etiquétalos y recorre tu base.

Actualizada el Ver .md

El dominio contacts te da acceso programático al CRM del workspace: contactos, mensajes, notas y etiquetas. Necesita los scopes contacts:read y/o contacts:write.

Las rutas son planas (/contacts/…): operan sobre el workspace al que pertenece la key, así que no hace falta pasar workspaceId.

Crear un contacto

Requiere phone o 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"}'

Mandar un mensaje

await met.contacts.sendMessage(contact.id, '¡Hola! Te escribo desde la API 👋');
met.contacts.send_message(contact["id"], "¡Hola! Te escribo desde la API 👋")

Para plantillas de WhatsApp aprobadas:

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

Etiquetas y notas

await met.contacts.addTag(contact.id, 'lead-caliente');
await met.contacts.createNote(contact.id, 'Pidió una demo para el viernes.');

const tags = await met.contacts.tags(contact.id);
met.contacts.add_tag(contact["id"], "lead-caliente")
met.contacts.create_note(contact["id"], "Pidió una demo para el viernes.")

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

Campos del contacto: $ para los de sistema, pelado para los tuyos

Esta convención es la fuente de la mayoría de los errores al escribir datos, así que vale la pena tenerla clara:

Escribir $etapa en un campo personalizado no falla: guarda una clave distinta que ninguna vista lee. El campo queda invisible en la app.

Para descubrir los campos personalizados de un workspace y sus valores válidos:

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

En los select hay que mandar exactamente una de sus options. En los currency, options trae un solo elemento con el código ISO de la moneda (['COP']) y el valor del contacto es solo el número.

El embudo comercial

Todo workspace estrena tres campos personalizados —etapa, monto y fecha_cierre_estimada— y una vista "Pipeline". No hay una entidad "oportunidad" aparte: el pipeline son campos del contacto, así que se leen y escriben como cualquier otro campo.

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() agrega en la base sobre todos tus contactos, no sobre una página. Los contactos sin etapa se agrupan en __no_value__. Si tu workspace renombró esos campos, pásalos: pipelineSummary({ stage_field: 'fase', amount_field: 'valor' }).

Recorrer la base

Usa iterate() para recorrer todos los contactos sin manejar cursores a mano:

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

Por debajo pide order=id y encadena starting_after. Ese detalle importa si paginas a mano: el orden por defecto de list() es por recencia de la conversación y se reordena con cada mensaje entrante, así que un barrido sobre él se saltea contactos. Para recorrer todo sin perder nada, pide order=id desde la primera página y usa starting_after con el id del último contacto recibido.

Para una sola página, list() acepta filtros del CRM (estado, canal, búsqueda) y devuelve { data, total, has_more }.

Leer conversaciones

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

WhatsApp: más que texto

sendMessage manda texto por el canal activo del contacto y alcanza para casi todo. Cuando necesitas que la persona elija en vez de escribir, WhatsApp tiene formatos propios, y van por el canal —no por el contacto— porque el formato depende de qué permite ese canal:

await met.channels.whatsapp.sendButtons(channelId, {
  contact_id: contact.id,
  body_text: '¿Confirmamos la visita del jueves a las 3?',
  buttons: [
    { id: 'si', title: 'Sí, confirmo' },
    { id: 'reagendar', title: 'Reagendar' },
  ],
});

Hasta 3 botones; con más opciones, una lista:

await met.channels.whatsapp.sendList(channelId, {
  contact_id: contact.id,
  body_text: 'Elige un horario',
  button_text: 'Ver horarios',
  sections: [{ title: 'Jueves', rows: [
    { id: 'j-9', title: '9:00 a. m.' },
    { id: 'j-15', title: '3:00 p. m.' },
  ] }],
});

El id de cada botón o fila es tuyo: es lo que te llega de vuelta cuando la persona elige, así que ponle algo que puedas interpretar sin una tabla aparte. Lo que vuelve entra como mensaje del contacto y dispara los mismos eventos que un mensaje escrito.

Los otros formatos son directos: sendReply cita un mensaje anterior (wa_message_id), sendReaction le pone un emoji, sendLocation manda coordenadas con nombre y dirección, y sendContactCard comparte una ficha de contacto.

Uno importante aparte: sendTemplate. Fuera de la ventana de 24 horas desde el último mensaje de la persona, WhatsApp solo deja escribir con una plantilla aprobada por Meta — cualquier otro envío falla. No es un límite de Meteor y no hay forma de saltearlo: si tu integración escribe a contactos que no acaban de responder, la plantilla es el camino, no la excepción.

Difusiones

Una difusión es un envío a un segmento, no a un contacto. Vale la pena antes de mandar:

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

previewCount resuelve el segmento y te dice a cuánta gente le va a llegar, sin enviar nada. Úsalo siempre: es la diferencia entre descubrir que el filtro estaba mal ahora o después de escribirle a toda la base.

Lo que se difunde es un flujo, no un texto:

const b = await met.broadcasts.create({
  name: 'Promo julio',
  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 es obligatorio y es la diferencia de diseño que conviene entender: una difusión no manda un mensaje suelto, arranca un flujo por cada contacto del segmento. Por eso puede responder, ramificar según lo que contesten y encadenar pasos — cosas que un envío plano no puede.

throttle_per_minute existe porque mandar todo de golpe es la forma más rápida de que el proveedor te limite. Y send_mode puede ser now, scheduled, manual o recurring; en scheduled necesitas scheduled_at.

Programada se puede cancel() mientras no haya salido; ya enviada, no — cancel detiene lo pendiente, no deshace lo entregado. Y como una difusión sale fuera de la ventana de 24 horas por definición, aplica lo de arriba: el primer mensaje del flujo va con plantilla aprobada.

Los contactos operan sobre el workspace de la key. Una key de partner puede operar varios workspaces de sus clientes; en ese caso, mira la API de Partners.