# WhatsApp para plataformas

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.

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

```ts
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](aprovisionamiento.html).

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

```ts
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
```

```python
sesion = 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**:

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

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

```json
{
  "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](webhooks.html).

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

```ts
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 }
```

```python
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) e `inbox:embed`. Con una key `met_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_origin` registrado en tus orígenes permitidos.
- Devuelve `session_id`, `inbox_url` y `expires_at`. El `inbox_url` sirve **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`:

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

- El `inbox_url` sirve **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-ancestors` igual a tu `parent_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:

```ts
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 `nonce` y lo anuncia en su **primer** mensaje —normalmente `ready`; si la sesión ya no sirve, `session.expired` o `error`—. Desde ahí, **todo** mensaje, en los dos sentidos, lleva ese `nonce`.
- 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 el `nonce`) pero el evento no existe o el payload no tiene la forma de la tabla, tampoco lo aplica y te responde `error` con `code: "invalid_message"` y el `event` que rechazó.
- Un mensaje de más de 64 KB se descarta.
- La bandeja nunca envía a `'*'`: solo a tu `parent_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:

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

```ts
// 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_actions` **reemplaza** tus acciones (para todos los chats). `set_relations` **reemplaza** las relaciones de ese contacto; `add_relation` suma 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 con `set_relations`.
- Hasta **50 relaciones por contacto**: con `set_relations`, más de 50 se rechaza; con `add_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 (`error` con `invalid_message`). Un `icon` que 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](errores-e-idempotencia.html).
