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.
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:
- Crear la cuenta del comercio.
- Conectar su línea de WhatsApp con un enlace de un solo uso.
- Enterarte del resultado por webhook.
- 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.
| Paso | Scope | Ruta |
|---|---|---|
| Crear la cuenta | workspaces:provision | POST /workspaces |
| Conectar WhatsApp | channels:connect | POST /workspaces/{workspaceId}/whatsapp/connect-sessions |
| Avisos de la línea | channels:read (en tu cuenta Met) | Webhooks channel.line.connected y channel.line.failed |
| Bandeja incrustable | inbox:embed | POST /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 deredirect_urlyparent_origintienen que estar en esa lista; si no, la API responde403 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 resultadosesion = met.workspaces.whatsapp_connect_session(
cuenta["id"],
return_origin="https://app.tuplataforma.com",
idempotency_key=f"conexion-{comercio_id}",
)| Campo | Qué es |
|---|---|
connection_type | coexistence (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_origin | Origen 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_url | URL 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:
| Evento | Cuándo |
|---|---|
channel.line.connected | El comercio terminó de conectar su línea. |
channel.line.failed | La 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",
)| Campo | Qué es |
|---|---|
parent_origin | Obligatorio. 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_id | Muestra solo los chats de esa línea. Sin él, todos los de la cuenta. |
language | es (por defecto) o en. |
theme | light, dark o system (por defecto). |
brand_color | Tu color en #RRGGBB. La bandeja arma su paleta a partir de él. |
hide | Secciones 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.
- Necesita una key de partner viva con los scopes
mcp:use(abrir la sesión MCP) einbox:embed. Con una keymet_test_la herramienta no aparece. - La herramienta llama a esta misma ruta con tu key, así que las reglas son las mismas: Tech Partner aprobado, cuenta atribuida a ti y
parent_originregistrado en tus orígenes permitidos. - Devuelve
session_id,inbox_urlyexpires_at. Elinbox_urlsirve una sola vez: entrégalo a quien lo va a poner en el iframe y no lo guardes.
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>
- El
inbox_urlsirve una vez y vale 10 minutos sin abrir (expires_at). No lo guardes ni lo reutilices: si dos pestañas abren la misma URL, solo una recibe la bandeja. - Abierta, vale mientras se use: 30 minutos sin uso la cierran, y nunca dura más de 8 horas.
- Si alguien la abre desde otro sitio, el navegador la bloquea: el iframe se sirve con
frame-ancestorsigual a tuparent_origin. - Al recargar tu página, pide otra sesión. La bandeja no usa cookies.
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}}
- La bandeja genera un
noncey lo anuncia en su primer mensaje —normalmenteready; si la sesión ya no sirve,session.expiredoerror—. Desde ahí, todo mensaje, en los dos sentidos, lleva esenonce. - La bandeja descarta sin responder lo que llegue de otro origen, de otra ventana, con otra versión o sin el
nonce. Si el mensaje sí es tuyo (trae elnonce) pero el evento no existe o el payload no tiene la forma de la tabla, tampoco lo aplica y te respondeerrorconcode: "invalid_message"y eleventque rechazó. - Un mensaje de más de 64 KB se descarta.
- La bandeja nunca envía a
'*': solo a tuparent_origin. Tú, de vuelta, envíale solo al origen de la API.
De la bandeja a tu página:
| Evento | Payload | Cuá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:
| Evento | Payload |
|---|---|
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:
| Campo | Qué es |
|---|---|
id | Obligatorio. Texto (hasta 64) o número entero. Vuelve en action.clicked. |
label | Obligatorio. Hasta 40 caracteres. |
icon | Opcional. Un nombre de la lista de abajo, o un SVG (ver «Íconos»). |
primary | Opcional. true la pinta llena con tu color de marca; el resto va contorneada. |
Una relación:
| Campo | Qué es |
|---|---|
id | Obligatorio. Texto (hasta 64) o número entero. Vuelve en relation.clicked. |
type | Obligatorio. El grupo y su rótulo: Pedidos, Reservas… (hasta 40). Los grupos salen en el orden en que aparece cada tipo. |
title | Obligatorio. Hasta 120 caracteres. |
subtitle | Opcional. Hasta 160. |
status | Opcional. Hasta 40; va delante del subtítulo («Entregado · 26 oct»). |
badge | Opcional. Una insignia corta, hasta 24 («Pagado»). |
icon | Opcional, como en las acciones. |
set_actionsreemplaza tus acciones (para todos los chats).set_relationsreemplaza las relaciones de ese contacto;add_relationsuma una al inicio de su tipo (si ya estaba, la mueve) y le pone la marca «Nuevo». La marca dura toda la sesión, aunque después reenvíes la lista conset_relations.- Hasta 50 relaciones por contacto: con
set_relations, más de 50 se rechaza; conadd_relation, sale la última fila de la lista (la del último grupo). - Todo se pinta como texto: si mandas HTML, se ve el HTML escrito, no se interpreta. Los caracteres de control y las marcas de dirección de texto se quitan.
- Un campo que no está en las tablas, un largo de más o un tipo equivocado rechaza el mensaje entero (
errorconinvalid_message). Uniconque no se reconoce no rechaza nada: la fila sale sin ícono.
Í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 |
|---|---|
| Tema | theme al crear la sesión (light, dark o system); en vivo, set_theme. |
| Color de marca | brand_color al crear la sesión; en vivo, set_theme con brand_color. |
| Idioma | language al crear la sesión (es o en); en vivo, set_language. |
| Ocultar secciones | hide al crear la sesión: search (el buscador), ai_controls (tomar, devolver y pausar) y attachments (los adjuntos). |
| Panel del cliente | customer_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ódigo | Qué pasó |
|---|---|
403 scope_not_allowed_for_owner | Estás usando una key de workspace; estos scopes solo existen en keys de partner |
403 missing_scope | Tu key de partner no tiene el scope de la ruta (channels:connect o inbox:embed) |
403 partner_key_required | La petición llegó sin key de partner (por ejemplo, con una sesión del panel) |
403 test_mode_restricted | Estás usando una key met_test_; estas operaciones son reales |
403 tech_partner_required | Tu cuenta de partner no tiene la capacidad Tech Partner aprobada |
403 workspace_not_attributed | La cuenta no está atribuida a ti |
403 origin_not_allowed | El origen (parent_origin, return_origin o el de redirect_url) no está en tus orígenes permitidos |
400 invalid_parent_origin | parent_origin no es un origen https exacto (trae ruta, parámetros o comodines) |
400 invalid_return_origin / invalid_redirect_url | No es una URL https válida |
400 missing_return_destination | Falta return_origin o redirect_url |
400 return_destinations_mismatch | return_origin y redirect_url son de dominios distintos |
404 inbox_session_not_found | Esa sesión de bandeja no existe en esa cuenta, o no es tuya |
Para el detalle de reintentos e idempotencia, ver Errores e idempotencia.