En esta sección
Guías / WhatsApp para plataformas

WhatsApp para plataformas

Para Tech Partners: crea la cuenta del comercio, conecta su WhatsApp, recibe el resultado por webhook y muéstrale su bandeja dentro de tu producto.

Actualizada el Ver .md

Si tienes una plataforma con muchos comercios —restaurantes, tiendas, clínicas— puedes darle a cada uno su WhatsApp con un Met que responde, sin que tus comercios salgan nunca de tu producto ni tengan usuario en Meteor. Esta guía junta las cuatro piezas en el orden en que se usan:

  1. Crear la cuenta del comercio.
  2. Conectar su línea de WhatsApp con un enlace de un solo uso.
  3. Enterarte del resultado por webhook.
  4. Mostrarle su bandeja dentro de tu página, con tu tema, tu color y tu idioma.

Los pasos 1, 2 y 4 los hace tu backend con una API key de partner de un Tech Partner aprobado. Una key de workspace no sirve para ellos: sus permisos solo existen en keys de partner, y ninguno funciona con una key de prueba (met_test_), porque conectan números reales y responden chats reales. El paso 3 es distinto: el webhook se registra en tu propia cuenta Met, con una key de workspace de esa cuenta.

PasoScopeRuta
Crear la cuentaworkspaces:provisionPOST /workspaces
Conectar WhatsAppchannels:connectPOST /workspaces/{workspaceId}/whatsapp/connect-sessions
Avisos de la líneachannels:read (en tu cuenta Met)Webhooks channel.line.connected y channel.line.failed
Bandeja incrustableinbox:embedPOST /workspaces/{workspaceId}/inbox-sessions y DELETE …/inbox-sessions/{sessionId}

Los pasos 2 y 4 solo funcionan sobre cuentas atribuidas a ti. Sobre otra, la API responde 403 workspace_not_attributed.

Antes de empezar, registra tus orígenes. Meteor guarda, por partner, la lista de orígenes https desde los que tu plataforma abre la conexión y la bandeja. return_origin, el origen de redirect_url y parent_origin tienen que estar en esa lista; si no, la API responde 403 origin_not_allowed. Pídele a tu contacto en Meteor que agregue los tuyos. La bandeja está en piloto. Funciona de punta a punta. Escríbenos antes de llevarla a todos tus comercios.

1. Crea la cuenta del comercio

import Met from '@meteor.ia/sdk';

const met = new Met(process.env.MET_PARTNER_KEY!);

const cuenta = await met.workspaces.create(
  {
    business_name: 'Restaurante La Esquina',
    owner_email: 'dueno@laesquina.co',
    owner_name: 'Carlos Ruiz',
    owner_phone: '+573001234567',
    country: 'CO',
  },
  { idempotencyKey: `alta-${comercio.id}` },
);

La cuenta nace con el plan Tech Partner, sin energía y atribuida a ti. Cárgale saldo antes de su primer mensaje: sin energía, el Met no responde. Los detalles —cupos, recargas, traspaso— están en Aprovisionamiento de cuentas.

2. Genera el enlace para conectar WhatsApp

Pides una sesión de conexión y le muestras al comercio el connect_url. Ahí hace el registro de Meta y vuelve a tu producto.

const sesion = await met.workspaces.whatsappConnectSession(
  cuenta.id,
  { return_origin: 'https://app.tuplataforma.com' },
  { idempotencyKey: `conexion-${comercio.id}` },
);
// sesion.connect_url  → ábrelo en una ventana emergente
// sesion.session_id   → guárdalo para cruzarlo con el resultado
sesion = met.workspaces.whatsapp_connect_session(
    cuenta["id"],
    return_origin="https://app.tuplataforma.com",
    idempotency_key=f"conexion-{comercio_id}",
)
CampoQué es
connection_typecoexistence (por defecto): el comercio sigue usando la app WhatsApp Business en su celular y la conecta escaneando un QR. dedicated: un número que se usa solo por la API.
return_originOrigen https de tu página que abre la ventana. Solo el origen, sin ruta. Tiene que estar en tus orígenes permitidos, igual que el origen de redirect_url.
redirect_urlURL https a la que vuelve el comercio al terminar, si prefieres un enlace en vez de una ventana.

Manda al menos uno de return_origin y redirect_url (si no, 400 missing_return_destination). Si mandas los dos, tienen que ser del mismo dominio (400 return_destinations_mismatch).

El connect_url sirve una sola vez: vale 24 horas sin abrir y, desde que el comercio lo abre, 15 minutos. No lo guardes ni lo escribas en tus logs. Reintentar con la misma Idempotency-Key en 24 horas te devuelve la misma sesión.

Variante en ventana emergente

Abre el connect_url con window.open desde una página servida en return_origin. Al terminar, la ventana le avisa a la tuya por postMessage, solo a ese origen:

const ORIGEN_CONEXION = new URL(sesion.connect_url).origin;

window.addEventListener('message', (e) => {
  if (e.origin !== ORIGEN_CONEXION) return;
  if (e.data?.type !== 'meteor.whatsapp.connect') return;
  const { status, line_id, phone_number, session_id, error_code } = e.data;
  // status: 'completed' | 'failed'
});

En failed llega error_code (por ejemplo meta_connection_failed o waba_already_connected) y line_id y phone_number vienen en null.

Variante por enlace

Con redirect_url, el comercio vuelve a tu página con el resultado en la URL: ?status=completed&session_id=…&line_id=…, o ?status=failed&session_id=…&error_code=…. Cualquier parámetro que ya tuviera tu URL se conserva.

En las dos variantes, no tomes el resultado del navegador como la verdad: el comercio puede cerrar la ventana antes de tiempo. Para eso está el paso siguiente.

3. Entérate de cada línea por webhook

Cuando la conexión termina, Meteor emite dos eventos firmados:

EventoCuándo
channel.line.connectedEl comercio terminó de conectar su línea.
channel.line.failedLa conexión falló. Trae error_code, nunca el mensaje crudo de Meta.

Llegan al workspace del comercio y, si tu cuenta de partner está vinculada a una cuenta Met tuya, también a esa cuenta. Así registras un solo endpoint, en tu propia cuenta, y te enteras de las líneas de todos tus comercios. Si tu cuenta de partner todavía no está vinculada, pídeselo a tu contacto en Meteor.

El registro se hace en tu cuenta Met con una key de workspace de esa cuenta (scope webhooks:manage), no con la key de partner:

const metMiCuenta = new Met(process.env.MET_MI_CUENTA_KEY!);

await metMiCuenta.webhooks.subscriptions.create({
  url: 'https://app.tuplataforma.com/met/eventos',
  enabled_events: ['channel.line.connected', 'channel.line.failed'],
});

El data de los dos eventos:

{
  "object": "whatsapp_line",
  "workspace_id": 501,
  "channel_id": 88,
  "line_id": "1098765432",
  "phone_number": "+57 300 123 4567",
  "connect_session_id": "9f3c…",
  "partner_id": "7b0c…"
}

connect_session_id es el session_id del paso 2: con él sabes qué comercio terminó. Cada sesión emite una sola vez por destino. Guarda el line_id: lo vas a usar para abrir la bandeja de esa línea. Verifica siempre la firma (X-Met-Signature) como explica Webhooks.

4. Abre la bandeja dentro de tu producto

La bandeja es una bandeja de WhatsApp de Met servida en un iframe: el comercio lee y responde sus chats, toma uno (el Met se calla), lo devuelve (el Met retoma) y pausa la IA en un chat 15 minutos, 1 hora o 4 horas. No ve ninguna pantalla de Meteor y no necesita usuario.

Responder también pausa al Met. Lo que escribe una persona desde la bandeja cuenta como una persona atendiendo: el Met deja de responder en ese chat, igual que si lo hubiera tomado, hasta que alguien lo devuelva con Devolver al Met. Así el comercio y el Met nunca contestan a la vez.

Pide una sesión desde tu backend

const bandeja = await met.workspaces.inboxSession(cuenta.id, {
  parent_origin: 'https://app.tuplataforma.com',
  line_id: '1098765432',
  language: 'es',
  theme: 'system',
  brand_color: '#0F7B6C',
  operator: { external_id: usuario.id, name: usuario.nombre },
});
// { object: 'inbox_session', session_id, inbox_url, expires_at }
bandeja = met.workspaces.inbox_session(
    cuenta["id"],
    parent_origin="https://app.tuplataforma.com",
    line_id="1098765432",
    language="es",
)
CampoQué es
parent_originObligatorio. El origen https exacto de la página que pone el iframe. Sin ruta, sin parámetros y sin comodines (*.dominio se rechaza). Solo ese origen puede enmarcar la bandeja, y los eventos van solo a él. Tiene que estar registrado en tus orígenes permitidos.
line_idMuestra solo los chats de esa línea. Sin él, todos los de la cuenta.
languagees (por defecto) o en.
themelight, dark o system (por defecto).
brand_colorTu color en #RRGGBB. La bandeja arma su paleta a partir de él.
hideSecciones que no se muestran: search, ai_controls, attachments.
operator{ external_id, name }: quién de tu equipo o del comercio la abre. Queda en la traza; no crea un usuario.

#### Desde un asistente, por MCP

Si trabajas con un asistente (un editor con IA o tu propio agente) conectado al servidor MCP de partners, https://api.partners.meteor.com.co/mcp, puedes pedir la misma sesión con la herramienta met_partner_create_inbox_session. Recibe los campos de la tabla más workspace_id, la cuenta del comercio.

Ponla en un iframe

Pide la sesión cada vez que vayas a mostrar la bandeja y pon el inbox_url en el src, desde una página servida en parent_origin:

<iframe id="bandeja" src="INBOX_URL" title="Bandeja de WhatsApp"
        style="width:100%;height:640px;border:0"></iframe>

Para cerrarla antes (el usuario cerró sesión en tu producto, por ejemplo), revócala:

await met.workspaces.revokeInboxSession(cuenta.id, bandeja.session_id);

La bandeja abierta deja de responder en segundos y su conexión en vivo se corta en menos de un minuto. Revocar una sesión ya revocada responde igual.

Lo que hace la bandeja abierta no cuenta contra la cuota diaria de API de tu plan.

Habla con la bandeja por postMessage

La bandeja y tu página se avisan cosas por postMessage. Cada mensaje es un texto con el prefijo meteor.inbox: seguido de un JSON:

meteor.inbox:{"version":1,"nonce":"…","event":"ready","payload":{"version":1}}

De la bandeja a tu página:

EventoPayloadCuándo
ready{ version }La bandeja abrió y ya puede recibir mensajes.
conversation.opened{ contact_id, phone }Se abrió un chat. phone es el número del contacto, o null.
handoff.taken{ contact_id }Alguien tomó el chat: el Met se calla.
handoff.returned{ contact_id }El chat volvió al Met.
ai.paused{ contact_id, minutes, until }Se pausó la IA en un chat.
unread.changed{ total }Cambió el total de mensajes sin leer (para tu insignia).
session.expired{}La sesión venció o fue revocada.
customer_panel.opened{ contact_id, phone }El panel del cliente muestra a ese contacto: mándale sus relaciones.
customer_panel.closed{ contact_id }Se cerró el panel del cliente.
action.clicked{ contact_id, phone, action_id }Tocaron una de tus acciones en el panel del cliente. Abre tu modal.
relation.clicked{ contact_id, phone, relation_id, type }Tocaron una de tus relaciones. Abre su detalle.
error{ code, message, event? }Algo falló. Ramifica por code; hoy message llega vacío. Con invalid_message, event dice qué mensaje tuyo se rechazó.

De tu página a la bandeja:

EventoPayload
init{ theme?, language?, brand_color?, customer_panel? } — customer_panel: open o closed (por defecto)
set_theme{ theme?, brand_color? } (al menos uno)
set_language{ language }
open_conversation{ contact_id }
set_actions{ actions } — hasta 6 acciones, ver abajo
set_relations{ contact_id, relations } — hasta 50 relaciones de ese contacto
add_relation{ contact_id, relation } — una relación nueva

Por postMessage solo cambian el tema, el color y el idioma; hide se fija al crear la sesión.

Un anfitrión completo, con el reintento incluido:

const ORIGEN_BANDEJA = new URL(inboxUrl).origin; // https://api.met.meteor.com.co
const PREFIJO = 'meteor.inbox:';
const marco = document.getElementById('bandeja') as HTMLIFrameElement;
let nonce: string | null = null;
let intentos = 0;
let cerrada = false; // true cuando TÚ revocas la sesión (cierre de sesión, cambio de comercio…)
let espera: ReturnType<typeof setTimeout> | undefined;

function enviar(event: string, payload: Record<string, unknown> = {}) {
  if (!nonce) return; // todavía no llegó el primer mensaje
  marco.contentWindow!.postMessage(PREFIJO + JSON.stringify({ version: 1, nonce, event, payload }), ORIGEN_BANDEJA);
}

window.addEventListener('message', (e) => {
  if (e.origin !== ORIGEN_BANDEJA || e.source !== marco.contentWindow) return;
  if (typeof e.data !== 'string' || !e.data.startsWith(PREFIJO)) return;
  let m;
  try {
    m = JSON.parse(e.data.slice(PREFIJO.length));
  } catch {
    return; // no es un mensaje de la bandeja
  }
  if (m?.version !== 1) return;
  if (!nonce) nonce = m.nonce;  // el primer mensaje fija el nonce
  if (m.nonce !== nonce) return;

  switch (m.event) {
    case 'ready':
      clearTimeout(espera);
      intentos = 0;
      break;
    case 'unread.changed':
      actualizarInsignia(m.payload.total);
      break;
    case 'session.expired':
      // Si la revocaste tú, no la reabras: se cerró a propósito.
      if (!cerrada) reintentar();
      break;
  }
});

// Espera creciente (2 s, 4 s, 8 s…) y un tope: si la bandeja no abre tras
// 5 intentos, muestra un aviso en tu página en vez de insistir.
function reintentar() {
  if (cerrada || intentos >= 5) return mostrarAvisoDeBandeja();
  intentos++;
  clearTimeout(espera);
  setTimeout(abrirBandeja, 1_000 * 2 ** intentos);
}

async function abrirBandeja() {
  clearTimeout(espera);
  nonce = null;
  try {
    const { inbox_url } = await fetch('/api/mi-backend/bandeja', { method: 'POST' }).then((r) => {
      if (!r.ok) throw new Error(String(r.status));
      return r.json();
    });
    marco.src = inbox_url;
    // Si `ready` no llega en ~10 s, la sesión no abrió: pide otra.
    espera = setTimeout(reintentar, 10_000);
  } catch {
    reintentar();
  }
}

async function cerrarBandeja() {
  cerrada = true;
  clearTimeout(espera);
  await fetch('/api/mi-backend/bandeja', { method: 'DELETE' }); // tu backend revoca la sesión
}

abrirBandeja();

abrirBandeja y cerrarBandeja llaman a tu backend, que es el que tiene la key de partner y crea la sesión. La key nunca va al navegador.

Para abrir un chat concreto —por ejemplo, al hacer clic en una notificación de tu producto—, envía open_conversation con el contact_id del contacto en Met (el que traen los eventos contact.message.* de los webhooks). Si ese chat no es de la cuenta, o no es de la línea de la sesión, la bandeja te responde error con code: "conversation_not_found".

El panel del cliente: tus acciones y tus relaciones

Al tocar el nombre del contacto en la cabecera del chat se abre, a la derecha, el panel del cliente: su avatar, su nombre y su número; tus acciones (por ejemplo «Tomar pedido» o «Nueva reserva»), y las relaciones que tu producto tiene con ese cliente, agrupadas por tipo (Pedidos, Reservas…). Las acciones y las relaciones las mandas tú por postMessage; la bandeja las pinta.

La bandeja solo avisa. Al tocar una acción o una relación no abre nada: te manda action.clicked o relation.clicked con el contacto y el id, y tu página abre su modal o su detalle. La bandeja no llama a tu backend ni a la API de Met con esos datos.

Tus datos no se guardan en Meteor. Las acciones y las relaciones viven solo en la memoria de esa pestaña de la bandeja. Si se recarga o abres otra sesión, vuelve a mandarlas.

Para que el panel arranque abierto, mándalo en init; si no, arranca cerrado y se abre al tocar el nombre. En pantallas de menos de 720 px de ancho el panel tapa el chat, así que abrir un chat no lo abre solo: se abre al tocar el nombre.

// Dentro del `switch (m.event)` del anfitrión de arriba:
case 'ready':
  enviar('init', { customer_panel: 'open' });
  enviar('set_actions', { actions: [
    { id: 'tomar-pedido', label: 'Tomar pedido', icon: 'shopping-bag', primary: true },
    { id: 'nueva-reserva', label: 'Nueva reserva', icon: 'calendar-plus' },
  ] });
  break;
case 'customer_panel.opened': {
  // El panel muestra a un contacto: mándale lo tuyo de ese cliente.
  const { contact_id, phone } = m.payload;
  buscarPedidosPorTelefono(phone).then((pedidos) => enviar('set_relations', { // tu backend
    contact_id,
    relations: pedidos.map((p) => ({
      id: p.id, type: 'Pedidos', title: `Pedido #${p.numero}`, status: p.estado, subtitle: p.fecha,
    })),
  }));
  break;
}
case 'action.clicked':
  abrirTuModal(m.payload.action_id, m.payload.contact_id);
  break;
case 'relation.clicked':
  abrirTuDetalle(m.payload.type, m.payload.relation_id);
  break;

// Y cuando tu modal crea un pedido, lo sumas: queda primero en su tipo, con la marca «Nuevo».
enviar('add_relation', {
  contact_id,
  relation: { id: '4821', type: 'Pedidos', title: 'Pedido #4821', subtitle: '2 almuerzos · $48.000' },
});

Una acción:

CampoQué es
idObligatorio. Texto (hasta 64) o número entero. Vuelve en action.clicked.
labelObligatorio. Hasta 40 caracteres.
iconOpcional. Un nombre de la lista de abajo, o un SVG (ver «Íconos»).
primaryOpcional. true la pinta llena con tu color de marca; el resto va contorneada.

Una relación:

CampoQué es
idObligatorio. Texto (hasta 64) o número entero. Vuelve en relation.clicked.
typeObligatorio. El grupo y su rótulo: Pedidos, Reservas… (hasta 40). Los grupos salen en el orden en que aparece cada tipo.
titleObligatorio. Hasta 120 caracteres.
subtitleOpcional. Hasta 160.
statusOpcional. Hasta 40; va delante del subtítulo («Entregado · 26 oct»).
badgeOpcional. Una insignia corta, hasta 24 («Pagado»).
iconOpcional, como en las acciones.

Íconos. Usa un nombre de esta lista: shopping-bag, shopping-cart, calendar, calendar-plus, receipt, package, truck, utensils, credit-card, dollar-sign, file-text, clipboard-list, tag, star, gift, heart, user, users, bell, clock, check, plus, map-pin, phone, mail, message-square, external-link. Si necesitas el tuyo, manda el SVG como texto (hasta 2 KB). La bandeja no inserta tu SVG: lo lee y lo vuelve a dibujar solo con figuras (path, circle, rect, line, polyline, polygon, ellipse, g) y sus medidas. Se descartan scripts, estilos, enlaces, imágenes, eventos, degradados y colores: el ícono toma el color del texto, en claro y en oscuro.

5. Personaliza la bandeja

QuéCómo
Tematheme al crear la sesión (light, dark o system); en vivo, set_theme.
Color de marcabrand_color al crear la sesión; en vivo, set_theme con brand_color.
Idiomalanguage al crear la sesión (es o en); en vivo, set_language.
Ocultar seccioneshide al crear la sesión: search (el buscador), ai_controls (tomar, devolver y pausar) y attachments (los adjuntos).
Panel del clientecustomer_panel en init: open para que arranque abierto, closed (por defecto) para que se abra al tocar el nombre.

No hay CSS libre: el tema, el color y el idioma son lo que se puede cambiar, y así la bandeja sigue funcionando bien en claro, en oscuro y con cualquier color.

Con hide: ['ai_controls'] desaparecen Tomar, Devolver al Met y Pausar la IA, pero responder sigue pausando al Met. Sin esos controles nadie puede devolverle el chat desde la bandeja, así que úsalo solo para operadores que leen y no responden, o cuando tu producto devuelve los chats al Met por otra vía.

Errores

CódigoQué pasó
403 scope_not_allowed_for_ownerEstás usando una key de workspace; estos scopes solo existen en keys de partner
403 missing_scopeTu key de partner no tiene el scope de la ruta (channels:connect o inbox:embed)
403 partner_key_requiredLa petición llegó sin key de partner (por ejemplo, con una sesión del panel)
403 test_mode_restrictedEstás usando una key met_test_; estas operaciones son reales
403 tech_partner_requiredTu cuenta de partner no tiene la capacidad Tech Partner aprobada
403 workspace_not_attributedLa cuenta no está atribuida a ti
403 origin_not_allowedEl origen (parent_origin, return_origin o el de redirect_url) no está en tus orígenes permitidos
400 invalid_parent_originparent_origin no es un origen https exacto (trae ruta, parámetros o comodines)
400 invalid_return_origin / invalid_redirect_urlNo es una URL https válida
400 missing_return_destinationFalta return_origin o redirect_url
400 return_destinations_mismatchreturn_origin y redirect_url son de dominios distintos
404 inbox_session_not_foundEsa sesión de bandeja no existe en esa cuenta, o no es tuya

Para el detalle de reintentos e idempotencia, ver Errores e idempotencia.