# Poner el chat en tu sitio — Vocero

> Tres pasos y una sola vez: dos claves, tus dominios y la pieza que corresponde a tu plataforma.

Guía oficial: https://vocero.net/documentacion/chat-web/ · Más documentación: https://vocero.net/llms.txt

## Qué vas a conseguir

Que quien entre a tu sitio pueda escribirte desde ahí mismo, que esa consulta llegue al grupo de Telegram de tu equipo, y que cualquiera de tus agentes la tome y la conteste desde su celular. Quien escribe ve un chat normal en tu sitio; nunca se entera de que del otro lado hay Telegram.

Y algo que los otros canales no dan: **quien atiende ve desde dónde te está escribiendo** —en qué página está, qué tiene en el carrito— antes de decidir si toma la consulta.

## Antes de empezar: lo único que tenés que verificar

Tu sitio tiene que poder **ejecutar algo del lado del servidor**. No es una web cualquiera: es un archivo chico que ponemos nosotros y que se encarga de autorizar cada conversación.

- **¿Tenés WordPress con WooCommerce?** Listo, no tenés que hacer nada: lo trae el plugin.
- **¿Tenés Shopify?** Se resuelve con una función gratuita del lugar donde esté tu sitio; te decimos cuál.
- **¿Tenés un sitio a medida, o uno hecho con Wix, Cloudflare, Netlify o Vercel?** Todos permiten esto y te damos el archivo escrito.
- **¿Tenés un hosting viejo que solo sirve páginas y no puede ejecutar nada?** Ahí no vamos a poder instalarlo, y preferimos decírtelo ahora.

**¿Por qué esta exigencia?** Porque el código del chat está a la vista de cualquiera que mire tu página: es así en todos los chats web del mundo. Si eso fuera todo lo que hace falta, alguien podría copiarlo, pegarlo en otro sitio, y mandarle consultas a tu equipo consumiendo tu plan. Lo único que lo impide de verdad es una **clave que viva en tu servidor**, donde nadie la puede leer. Preferimos exigirla siempre antes que ofrecer un modo más cómodo con un agujero.

## 1. Te damos dos claves

Al crear tu canal te vamos a dar dos códigos, y no son lo mismo:

![Crear el canal de chat web en el panel de Vocero](/documentacion/imagenes/chatweb-01-crear-canal.png)

|                              | Dónde va                  | Cuidado                                             |
| ---------------------------- | ------------------------- | --------------------------------------------------- |
| **Clave del sitio** (`wc_…`) | En el código de tu página | Está a la vista. No es un secreto.                  |
| **Clave secreta** (`wcs_…`)  | Solo en tu servidor       | **No la publiques nunca.** Se muestra una sola vez. |

Guardá la secreta apenas te la damos: no la volvemos a mostrar. Si se pierde, generamos otra — pero mientras no la actualices en tu sitio, el chat deja de abrir conversaciones.

![Las dos claves del canal, con el aviso de guardar la secreta](/documentacion/imagenes/chatweb-02-claves.png)

## 2. Decinos desde qué dominios se usa

Nos pasás la dirección de tu sitio —`https://mitienda.com`— y solo desde ahí va a funcionar.

**Si tu sitio se ve con más de una dirección, pasanos todas.** Es más común de lo que parece y es la causa número uno de "a mí me anda y a mi socio no":

- Si tu sitio abre con `www` y sin `www`, son dos.
- Si tenés un dominio por país, es uno por cada uno.
- Si tenés una versión de prueba, también.

Pasanos la dirección **como la ves en la barra del navegador**, con el `https://` adelante — `https://mitienda.com`, no `mitienda.com`. Si tu sitio anda por `http://` sin la `s`, avisanos antes: ahí el navegador no deja usar el micrófono ni la ubicación, así que hay que resolver eso primero.

Agregar o sacar una es un cambio nuestro de un minuto, así que si te olvidás de alguna se resuelve en el momento.

**Hasta que nos digas al menos uno, el chat no abre.** Es a propósito: un canal recién creado no puede quedar abierto a todo internet mientras alguien se acuerda de configurarlo.

## 3. Instalarlo

### Si tenés WooCommerce

1. Instalás el plugin que te pasamos, desde _Plugins → Añadir nuevo → Subir plugin_.
2. Lo activás.
3. En _WooCommerce → Vocero_, pegás las dos claves.

Listo. El plugin además le cuenta a quien atiende qué producto está mirando la persona y qué tiene en el carrito, y podés elegir con tres casillas qué se comparte: el carrito, quién es (si tiene la sesión iniciada) y su última compra. Son datos personales de tus clientes, así que la decisión es tuya.

### Si tenés Shopify

Pegás un fragmento que te damos en _Tienda online → Temas → Editar código_, dentro de `theme.liquid`, justo antes de `</body>`.

Dos cosas para saber de antemano: **si cambiás de tema hay que volver a pegarlo** —es la causa número uno de "dejó de andar"— y hace falta resolver dónde vive la clave secreta, que te ayudamos a ver según dónde esté tu tienda.

### Si tenés otro sitio

Dos cosas:

1. Una línea que te damos, antes de que cierre el `body` de tus páginas.
2. Un archivo en tu servidor, con la clave secreta adentro. Está acá abajo, en [El firmador](#el-firmador): elegí el de tu plataforma.

Son unas diez líneas en total, en versión para PHP, para Node y para Cloudflare.

### Si nuestro servidor no contesta

La línea que te damos trae un `onerror="…"` largo. **No lo saques**: si alguien abre tu página justo cuando nuestro servidor no responde, eso vuelve a pedir el chat solo —a los 5 segundos, después a los 10, y así hasta una vez por minuto— y el ícono aparece en cuanto volvemos, sin que la persona tenga que recargar. Si ya tenías la línea de antes, sin el `onerror`, el chat sigue andando igual; solo no se recupera solo de un corte nuestro. Para agregárselo, mirá [la sección que sigue](#si-ya-tenés-el-chat-instalado-actualizar-la-etiqueta).

Si en cambio el chat llega pero nuestro servidor no contesta, eso lo resuelve el chat mismo: no aparece hasta que volvemos, y aparece solo cuando volvemos.

Tres cuidados con esa línea:

- **Copiala tal cual.** El `onerror` va entre comillas dobles y adentro usa solo simples. Si un editor o un formateador te cambia las comillas, se rompe.
- **Dejala en la página.** No la reemplaces por una que arme JavaScript ni la muevas a un archivo aparte: el reintento tiene que estar ahí justamente para cuando nuestro script no llegó.
- **Si tu sitio tiene una política de seguridad de contenido (CSP)** que prohíbe código en línea, el `onerror` no corre. El chat anda igual, pero sin el reintento.

Para probarlo: en Chrome, abrí las herramientas del navegador, pestaña _Red_, clic derecho sobre el pedido a `widget.js` → _Bloquear URL de solicitud_, y recargá. El ícono no aparece y cada pocos segundos sale un pedido nuevo al script. Desbloquealo y el ícono aparece en el intento siguiente, sin recargar.

### Si ya tenés el chat instalado: actualizar la etiqueta

Si pusiste el chat antes del 13 de octubre de 2026, tu etiqueta no tiene el reintento. Sigue andando, pero conviene actualizarla: son dos cambios en una sola línea, y no se toca nada más —ni las claves, ni el firmador, ni lo que configuraste en el panel.

Según cómo lo instalaste:

- **WooCommerce**: subí la versión nueva del plugin encima de la que tenés (_Plugins → Añadir nuevo → Subir plugin_, y cuando WordPress pregunte, _Reemplazar la actual por la subida_). Las claves y las casillas quedan como estaban.
- **Shopify**: en `theme.liquid`, buscá dentro del fragmento de Vocero la etiqueta `<script` que carga `widget.js` y hacele los dos cambios de abajo. El resto del fragmento queda igual.
- **Cualquier otro sitio**: buscá en tus páginas la etiqueta `<script` que carga `widget.js` y hacele los dos cambios de abajo. Si tu sitio tiene una plantilla o un componente común para todas las páginas, está ahí, una sola vez.

Los dos cambios:

1. **Al `src`, agregale `?site=` y tu clave pública** —la misma que está en `data-vocero`—: donde dice `widget.js"` pasa a decir `widget.js?site=wc_tu_clave"`. Con eso podemos detectar si tu código de chat aparece copiado en un sitio que no es el tuyo.
2. **Agregale el atributo `onerror`**, copiado tal cual, con las comillas como están.

Así queda la etiqueta completa, sin nada opcional:

```html
<script
  src="https://vocero.chat/webchat/v1/widget.js?site=wc_tu_clave"
  data-vocero="wc_tu_clave"
  data-vocero-signer="/vocero-aval"
  async
  onerror="(function r(s,n){setTimeout(function(){var c=document.createElement('script'),a=s.attributes,i;for(i=0;i<a.length;i++)if(a[i].name!='onerror')c.setAttribute(a[i].name,a[i].value);c.onerror=function(){r(c,n+1)};s.replaceWith(c)},Math.min(6e4,5e3*Math.pow(2,n)))})(this,0)"
></script>
```

Lo que tu etiqueta ya tenía, se queda:

- **La dirección del firmador.** Si la tuya no es `/vocero-aval`, dejá la tuya. Si el atributo se llama `data-vocero-aval` en vez de `data-vocero-signer`, también sirve: es el nombre de antes y se sigue leyendo.
- **`data-vocero-position`, `data-vocero-offset-bottom` y `data-vocero-offset-side`**, si los usás: van igual, y el reintento los copia.
- **`wc_tu_clave`** es un ejemplo: en los dos lugares va tu clave pública, la que empieza con `wc_`.

Para comprobar que quedó: abrí tu página, mirá el código fuente y buscá `widget.js?site=` —tiene que aparecer con tu clave— y `onerror=`. Después hacé la prueba de bloquear la dirección que está más arriba: si el ícono aparece solo al desbloquear, está andando.

## El firmador

Es el archivo que va en tu servidor, con la clave secreta adentro: el que autoriza cada conversación. Todos hacen lo mismo — cambia el envoltorio según dónde corra tu sitio. Cada uno se puede descargar o copiar de acá mismo, y **la guía completa para desarrolladores** —el contrato del aval, la identidad avalada, y qué mirar si quien escribe aparece como anónimo— está en [la guía del firmador](/documentacion/chat-web/firmador/).

| Para | Archivo |
| --- | --- |
| Cualquier dominio que pasa por Cloudflare, sin importar dónde esté hosteado el sitio | <a href="/firmadores/cloudflare-worker.js" download>cloudflare-worker.js</a> |
| Un sitio alojado en Cloudflare Pages | <a href="/firmadores/cloudflare-pages-function.js" download>cloudflare-pages-function.js</a> |
| Node, Express o cualquier servidor JavaScript | <a href="/firmadores/node.js" download>node.js</a> |
| PHP, incluido WordPress a mano | <a href="/firmadores/firmador.php" download>firmador.php</a> |
| Una aplicación Node con usuarios logueados, que quiera avalar quién está del otro lado *(opcional)* | <a href="/firmadores/node-con-identidad.js" download>node-con-identidad.js</a> |
| Una aplicación Python —FastAPI, Django, Flask— con usuarios logueados, ídem *(opcional)* | <a href="/firmadores/firmador_python.py" download>firmador_python.py</a> |

> **Worker y Pages no son lo mismo, y confundirlos no da ningún error.** En **Pages** el archivo va dentro de la carpeta `functions/` del repositorio del sitio y exporta una función llamada `onRequestPost`. Un **Worker** se cuelga de una ruta desde el panel de Cloudflare y exporta `export default { fetch }`. Si ponés el del Worker en `functions/`, se publica sin quejarse — y la dirección devuelve 404, que es de los síntomas más desconcertantes que hay.

Configurá las dos claves como variables de entorno donde corra el archivo, y **la secreta siempre como secreto**, nunca como variable de texto ni escrita en el código. Si falta una, los firmadores te lo dicen con un mensaje claro en vez de firmar sobre nada.

Y conviene **limitarle la frecuencia**, porque es una dirección pública — pero sin cortarse el pie: el widget le pide el aval **también al retomar una sesión**, o sea una vez por carga de página mientras la conversación siga viva. Es lo que permite identificar a quien entró sin sesión, se identificó y recién después abrió el chat. **Un aval por visitante cada pocos segundos alcanza de sobra; un límite de uno cada varios minutos deja como anónima justo a la persona que se acaba de identificar.**

### Para Cloudflare Workers

Cualquier dominio que pase por Cloudflare puede publicar esto como Worker en la ruta `/vocero-aval`, aunque el sitio esté hosteado en otro lado.

```js
// Firmador del chat web de Vocero, para Cloudflare.
//
// Sirve para un sitio estatico en Cloudflare Pages o Workers, que es el caso de
// una web sin servidor propio. Se publica como funcion y listo. Ver el README
// de esta carpeta y ai/14-chat-web.md.
//
// Las dos claves se configuran como variables del Worker. La secreta va como
// **secreto**, no como variable comun.

/**
 * Que falte una variable **no puede pasar en silencio**.
 *
 * Sin esto, `env.VOCERO_SITE_KEY` sin cargar firma sobre la cadena
 * `"undefined"`: sale una firma perfectamente formada y perfectamente inutil,
 * y del otro lado se ve un `AVAL_INVALIDO` que no dice cual de las dos falta.
 * Paso el 2026-09-08 y costo media noche.
 *
 * Y al reves: la clave **secreta** en el lugar de la publica es el otro error
 * de la familia, porque las dos son cadenas y nada las distingue a simple
 * vista. La idea es del equipo de SG2, que lo puso en su build.
 */
function revisarVariables(env) {
  if (!env.VOCERO_SITE_KEY || !env.VOCERO_SECRET) {
    return 'Falta VOCERO_SITE_KEY o VOCERO_SECRET en las variables del firmador.';
  }
  if (env.VOCERO_SITE_KEY.startsWith('wcs_')) {
    return 'VOCERO_SITE_KEY tiene la clave secreta: la publica empieza con wc_ y la secreta con wcs_.';
  }
  if (!env.VOCERO_SITE_KEY.startsWith('wc_')) {
    return 'VOCERO_SITE_KEY no parece una clave de sitio: tiene que empezar con wc_.';
  }
  return null;
}

export default {
  async fetch(request, env) {
    if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 });

    const falta = revisarVariables(env);
    if (falta) return new Response(falta, { status: 500 });

    const cuerpo = await request.json().catch(() => ({}));
    const visitorId = cuerpo.visitorId || `v_${crypto.randomUUID()}`;
    const issuedAt = Math.floor(Date.now() / 1000);

    const clave = await crypto.subtle.importKey(
      'raw',
      new TextEncoder().encode(env.VOCERO_SECRET),
      { name: 'HMAC', hash: 'SHA-256' },
      false,
      ['sign'],
    );

    const firma = await crypto.subtle.sign(
      'HMAC',
      clave,
      new TextEncoder().encode([env.VOCERO_SITE_KEY, visitorId, String(issuedAt)].join('\n')),
    );

    const signature = [...new Uint8Array(firma)]
      .map((b) => b.toString(16).padStart(2, '0'))
      .join('');

    return Response.json({ visitorId, issuedAt, signature });
  },
};
```

### Para Cloudflare Pages

El mismo firmador con la envoltura que Pages espera. Va en `functions/vocero-aval.js` del repositorio del sitio.

```js
// Firmador del chat web de Vocero, para Cloudflare **Pages**.
//
// Es el mismo firmador que `cloudflare-worker.js` con otra envoltura, y la
// envoltura no es un detalle: **Pages Functions no reconoce `export default`**.
// Espera una función llamada `onRequestPost`, y con la otra forma el archivo se
// publica sin error y la dirección devuelve 404, que es de los sintomas mas
// desconcertantes que hay.
//
// Va en `functions/vocero-aval.js` del repositorio del sitio, y queda servido
// en `https://<sitio>/vocero-aval`. La carpeta `functions/` define las rutas
// por su estructura: el nombre del archivo es la dirección.
//
// Las dos claves se configuran en *Settings -> Variables and Secrets* del
// proyecto de Pages. La secreta va como **secret**, no como variable de texto:
// las de texto se ven en el panel y quedan en los registros de compilación.

/**
 * Que falte una variable **no puede pasar en silencio**.
 *
 * Sin esto, `env.VOCERO_SITE_KEY` sin cargar firma sobre la cadena
 * `"undefined"`: sale una firma perfectamente formada y perfectamente inutil,
 * y del otro lado se ve un `AVAL_INVALIDO` que no dice cual de las dos falta.
 * Paso el 2026-09-08 y costo media noche.
 *
 * Y al reves: la clave **secreta** en el lugar de la publica es el otro error
 * de la familia, porque las dos son cadenas y nada las distingue a simple
 * vista. La idea es del equipo de SG2, que lo puso en su build.
 */
function revisarVariables(env) {
  if (!env.VOCERO_SITE_KEY || !env.VOCERO_SECRET) {
    return 'Falta VOCERO_SITE_KEY o VOCERO_SECRET en las variables del firmador.';
  }
  if (env.VOCERO_SITE_KEY.startsWith('wcs_')) {
    return 'VOCERO_SITE_KEY tiene la clave secreta: la publica empieza con wc_ y la secreta con wcs_.';
  }
  if (!env.VOCERO_SITE_KEY.startsWith('wc_')) {
    return 'VOCERO_SITE_KEY no parece una clave de sitio: tiene que empezar con wc_.';
  }
  return null;
}

export async function onRequestPost({ request, env }) {
  const falta = revisarVariables(env);
  if (falta) return new Response(falta, { status: 500 });

  const cuerpo = await request.json().catch(() => ({}));
  const visitorId = cuerpo.visitorId || `v_${crypto.randomUUID()}`;
  const issuedAt = Math.floor(Date.now() / 1000);

  const clave = await crypto.subtle.importKey(
    'raw',
    new TextEncoder().encode(env.VOCERO_SECRET),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const firma = await crypto.subtle.sign(
    'HMAC',
    clave,
    new TextEncoder().encode([env.VOCERO_SITE_KEY, visitorId, String(issuedAt)].join('\n')),
  );

  const signature = [...new Uint8Array(firma)].map((b) => b.toString(16).padStart(2, '0')).join('');

  return Response.json({ visitorId, issuedAt, signature });
}

// Cualquier otro método devuelve 405. Sin esto, un GET a la dirección cae en el
// manejador de la página y contesta el HTML del sitio, que confunde al
// diagnosticar: parece que la función no existe.
export function onRequest() {
  return new Response('Method not allowed', { status: 405 });
}
```

### Para Node

Exporta la función `firmar`; el comentario final muestra cómo colgarla de una ruta con Express.

```js
// Firmador del chat web de Vocero, para Node.
//
// Va en el servidor del sitio. Guarda la clave secreta del canal y le entrega
// un aval al widget cada vez que alguien abre el chat. Ver el README de esta
// carpeta y ai/14-chat-web.md.
//
// La clave secreta **nunca** va en el codigo: sale del entorno.

import { createHmac, randomBytes } from 'node:crypto';

const CLAVE_PUBLICA = process.env.VOCERO_SITE_KEY;
const CLAVE_SECRETA = process.env.VOCERO_SECRET;

/**
 * Arma el aval. Si el visitante todavia no tiene identificador, se le da uno.
 *
 * `issuedAt` va en segundos y vale dos minutos: el aval se pide en el momento
 * de abrir el chat, no se escribe en la pagina. Si se escribiera, cualquiera
 * baja esa pagina mil veces y se lleva mil firmas validas.
 */
export function firmar(visitorId) {
  const quien = visitorId || `v_${randomBytes(16).toString('base64url')}`;
  const issuedAt = Math.floor(Date.now() / 1000);

  const signature = createHmac('sha256', CLAVE_SECRETA)
    .update([CLAVE_PUBLICA, quien, String(issuedAt)].join('\n'))
    .digest('hex');

  return { visitorId: quien, issuedAt, signature };
}

// Con Express:
//
//   app.post('/vocero-aval', express.json(), (req, res) => {
//     res.json(firmar(req.body?.visitorId));
//   });
//
// Conviene limitarle la frecuencia: un aval por visitante cada pocos segundos
// alcanza, y sin eso alguien puede pedirlos en bucle.
```

### Para PHP

Va como un archivo propio — por ejemplo `/vocero-aval.php` — y la etiqueta del widget apunta ahí con `data-vocero-signer`.

```php
<?php
// Firmador del chat web de Vocero, para PHP.
//
// Va en el servidor del sitio, en una direccion propia —por ejemplo
// /vocero-aval.php—. Ver el README de esta carpeta y ai/14-chat-web.md.
//
// Las dos claves salen del entorno: la secreta **nunca** va escrita acá ni en
// ningun archivo que el navegador pueda descargar.

header('Content-Type: application/json');

$clavePublica = getenv('VOCERO_SITE_KEY');
$claveSecreta = getenv('VOCERO_SECRET');

$cuerpo = json_decode(file_get_contents('php://input'), true) ?: [];
$visitorId = $cuerpo['visitorId'] ?? ('v_' . bin2hex(random_bytes(16)));
$issuedAt = time();

$firmado = implode("\n", [$clavePublica, $visitorId, (string) $issuedAt]);
$signature = hash_hmac('sha256', $firmado, $claveSecreta);

echo json_encode([
    'visitorId' => $visitorId,
    'issuedAt' => $issuedAt,
    'signature' => $signature,
]);
```

### Opcional: avalar la identidad de quien escribe

Estos dos no hacen falta para que el chat funcione. Son para el caso en que el chat va **dentro de una aplicación con usuarios logueados**: tu servidor ya sabe quién está del otro lado, y puede firmarlo para que quien atiende vea un **usuario verificado** — "es Ana" — en vez de un nombre escrito a mano — "dice llamarse Ana". Sobre el primero un agente puede actuar; sobre el segundo no debería. El detalle del contrato está en [la guía del firmador](/documentacion/chat-web/firmador/).

Resuelven lo mismo en dos lenguajes: **si tu backend es Node, la primera; si es Python, la segunda.**

```js
// Firmador del chat web de Vocero **con identidad avalada**, para Node.
//
// Es el de `node.js` mas la segunda firma. Sirve para el caso en que el chat va
// dentro de una aplicacion con sesion —no en un sitio publico—, donde el
// servidor **ya sabe quien esta del otro lado** y no tiene por que hacerselo
// escribir.
//
// La diferencia no es comodidad. Sobre un nombre **declarado** —que lo manda el
// navegador y cualquiera lo cambia por el de otro— un agente no deberia actuar;
// sobre uno **avalado** si. Es lo que separa "dice llamarse Ana" de "es Ana", y
// en atencion decide si se le puede dar informacion de la cuenta.
//
// Son dos firmas distintas y con vigencias distintas, a proposito: el aval se
// pide cada vez que alguien abre el chat y dura dos minutos; la identidad se
// firma una vez al cargar la pagina y dura una hora, porque entre que la pagina
// carga y la persona se decide a escribir puede pasar un rato largo.

import { createHmac, randomUUID } from 'node:crypto';

const SITE_KEY = process.env.VOCERO_SITE_KEY;
const SECRET = process.env.VOCERO_SECRET;

const hmac = (partes) => createHmac('sha256', SECRET).update(partes.join('\n')).digest('hex');

/**
 * Lo que hay que devolver ante un `POST` a la direccion que declara la etiqueta
 * en `data-vocero-signer`.
 *
 * `usuario` sale de **la sesion del servidor**, nunca del cuerpo del pedido:
 * si se tomara de lo que manda el navegador, cualquiera se avala como
 * cualquiera y la firma no prueba nada.
 */
export function avalar(usuario, visitorIdPedido) {
  const visitorId = visitorIdPedido || `v_${randomUUID()}`;
  const issuedAt = Math.floor(Date.now() / 1000);

  const respuesta = {
    visitorId,
    issuedAt,
    signature: hmac([SITE_KEY, visitorId, String(issuedAt)]),
  };

  // Sin sesion se contesta solo el aval: el chat abre igual, con quien escribe
  // como visitante anonimo. Es lo que corresponde en las pantallas publicas de
  // la aplicacion, como el ingreso.
  if (!usuario) return respuesta;

  // El nombre viaja pero **no se firma**: lo avalado son los identificadores.
  // Firmar tambien el nombre obligaria a re-firmar cada vez que alguien se
  // corrige el apellido, y no aporta: lo que decide si se le puede dar
  // informacion de una cuenta es el identificador, no como se llama.
  respuesta.identity = {
    externalId: usuario.id,
    name: usuario.name,
    email: usuario.email,
    issuedAt,
    signature: hmac([SITE_KEY, visitorId, usuario.id ?? '', usuario.email ?? '', String(issuedAt)]),
  };

  return respuesta;
}

// Ejemplo con Express. Lo que importa es de donde sale `req.user`.
//
//   app.post('/vocero-aval', limiteDeFrecuencia, (req, res) => {
//     res.json(avalar(req.user, req.body?.visitorId));
//   });
//
// El limite de frecuencia no es opcional: es una direccion publica y, con la
// firma obligatoria, la unica superficie que le queda a un atacante.
```

Y el equivalente para Python — FastAPI, Django o Flask; el ejemplo del final es con FastAPI:

```python
# Firmador del chat web de Vocero **con identidad avalada**, para Python.
#
# Sirve para una aplicacion con sesion —FastAPI, Django, Flask— donde el
# servidor **ya sabe quien esta del otro lado**. Es el equivalente de
# `node-con-identidad.js`, y firma exactamente lo mismo: si los dos lados no
# arman la cadena igual, la firma no valida y el sintoma es un `AVAL_INVALIDO`
# que no dice por que.
#
# La diferencia con el firmador anonimo no es comodidad. Sobre un nombre
# **declarado** —que lo manda el navegador y cualquiera lo cambia por el de
# otro— un agente no deberia actuar; sobre uno **avalado** si. Es lo que separa
# "dice llamarse Ana" de "es Ana", y en atencion decide si se le puede dar
# informacion de la cuenta.

# Las anotaciones quedan diferidas: asi el archivo se puede copiar tal cual a un
# proyecto viejo sin que `str | None` reviente en el import.
from __future__ import annotations

import hashlib
import hmac
import os
import time
import uuid

SITE_KEY = os.environ["VOCERO_SITE_KEY"]
SECRET = os.environ["VOCERO_SECRET"]

# Que falte una variable no puede pasar en silencio: sin esto se firma sobre la
# cadena vacia y sale una firma perfectamente formada y perfectamente inutil.
# Del otro lado solo se ve `AVAL_INVALIDO`, que no dice cual de las dos falta.
if not SITE_KEY or not SECRET:
    raise RuntimeError("Faltan VOCERO_SITE_KEY o VOCERO_SECRET")
if SITE_KEY.startswith("wcs_"):
    raise RuntimeError("VOCERO_SITE_KEY tiene la clave secreta: la publica empieza con wc_")


def _firmar(*partes: str) -> str:
    mensaje = "\n".join(partes).encode()
    return hmac.new(SECRET.encode(), mensaje, hashlib.sha256).hexdigest()


def avalar(usuario=None, visitor_id: str | None = None) -> dict:
    """Lo que hay que devolver ante un POST a la direccion que declara la
    etiqueta en `data-vocero-signer`.

    `usuario` sale de **la sesion del servidor**, nunca del cuerpo del pedido:
    tomandolo de lo que manda el navegador, cualquiera se avala como cualquiera
    y la firma deja de probar nada.

    Se espera con los atributos `id`, `name` y `email`; con un diccionario
    tambien anda si se lo adapta.
    """
    visitor_id = visitor_id or f"v_{uuid.uuid4()}"
    issued_at = int(time.time())

    respuesta = {
        "visitorId": visitor_id,
        "issuedAt": issued_at,
        "signature": _firmar(SITE_KEY, visitor_id, str(issued_at)),
    }

    # Sin sesion se contesta solo el aval: el chat abre igual, con quien escribe
    # como visitante anonimo. Es lo que corresponde en las pantallas publicas de
    # la aplicacion, como el ingreso.
    if usuario is None:
        return respuesta

    externo = str(getattr(usuario, "id", "") or "")
    correo = str(getattr(usuario, "email", "") or "")

    # El nombre viaja pero **no se firma**: lo avalado son los identificadores.
    # Firmarlo obligaria a re-firmar cada vez que alguien se corrige el
    # apellido, y no aporta: lo que decide si se le puede dar informacion de una
    # cuenta es el identificador, no como se llama.
    respuesta["identity"] = {
        "externalId": externo,
        "name": getattr(usuario, "name", None),
        "email": correo,
        "issuedAt": issued_at,
        "signature": _firmar(SITE_KEY, visitor_id, externo, correo, str(issued_at)),
    }

    return respuesta


# Ejemplo con FastAPI. Lo que importa es de donde sale `usuario`.
#
#     @router.post("/vocero-aval")
#     async def vocero_aval(
#         cuerpo: dict | None = None,
#         usuario=Depends(usuario_actual_opcional),
#     ):
#         return avalar(usuario, (cuerpo or {}).get("visitorId"))
#
# **Opcional y no obligatorio**: en las pantallas publicas no hay sesion y el
# chat tiene que abrir igual, como anonimo. Con la dependencia obligatoria, el
# chat del ingreso deja de funcionar.
#
# Y ponerle un **limite de frecuencia**: es una direccion publica y, con la
# firma obligatoria, la unica superficie que le queda a un atacante.
```

## Cómo se ve, y qué podés cambiar

Desde tu panel elegís:

- **El ícono del chat cerrado** y de qué lado de la pantalla aparece.
- **Cuánto se separa del borde de abajo** — importa si tu tienda tiene una barra fija de "agregar al carrito" en el celular, porque taparla molesta.
- **El color y el logo** del encabezado. Elegí el de tu marca: nosotros calculamos si el texto va en blanco o en negro para que se lea.
- **El saludo** con el que arranca.
- **Un mensajito al lado del ícono** —"¿Te ayudo?"— y a los cuántos segundos aparece. Viene apagado, y si la persona lo cierra no vuelve a aparecer en esa visita.
- **Qué datos le pedís** antes de abrir la conversación. Y podés pedir algunos **solo por el sitio**: por ejemplo el correo, que en WhatsApp no hace falta porque ya tenés el teléfono.

### Si tenés más de una página y el chat no va en el mismo lugar

Pasa, por ejemplo, cuando tenés una versión para computadora y otra para celular y en una el ícono tapa algo. **No hace falta dar de alta un segundo chat**: eso te obligaría a mantener dos veces los datos que pedís, el horario, el color y las claves.

Lo que hacés es agregarle al código de inserto, en esa página y solo en esa, uno o más de estos:

```html
<script
  src="https://vocero.chat/webchat/v1/widget.js?site=wc_tu_clave"
  data-vocero="wc_tu_clave"
  data-vocero-signer="/vocero-aval"
  data-vocero-position="left"
  data-vocero-offset-bottom="120"
  data-vocero-offset-side="40"
  async
  onerror="(function r(s,n){setTimeout(function(){var c=document.createElement('script'),a=s.attributes,i;for(i=0;i<a.length;i++)if(a[i].name!='onerror')c.setAttribute(a[i].name,a[i].value);c.onerror=function(){r(c,n+1)};s.replaceWith(c)},Math.min(6e4,5e3*Math.pow(2,n)))})(this,0)"
></script>
```

- **`data-vocero-position`** — `left` o `right`.
- **`data-vocero-offset-bottom`** — a cuántos píxeles del borde de abajo.
- **`data-vocero-offset-side`** — a cuántos del costado.

El `onerror` queda como está: si el chat tiene que reintentar, la etiqueta nueva sale con todos los atributos de la vieja, estos tres incluidos.

Lo que pongas acá **pisa lo del panel solo en esa página**, y lo que no pongas sigue saliendo del panel. Así tenés una sola configuración, un solo formulario y una sola clave, con el ícono donde corresponde en cada lado.

Tres cosas que conviene saber: un **valor mal escrito se ignora** y queda el del panel, así que un error de tipeo no te deja sin chat; los **píxeles se acotan entre 0 y 400**, para que un número de más no mande el ícono fuera de la pantalla; y la **separación del costado**, que antes no se podía cambiar, sigue siendo de **20 px** si no decís otra cosa.

## Qué puede mandar y recibir quien te escribe

Texto, fotos, audios grabados ahí mismo, videos, documentos y su ubicación. Y todo eso le llega a tu agente a su Telegram, y lo que él conteste vuelve al chat.

## Si algo no anda

Cinco síntomas cubren casi todo, y se distinguen en el orden en que aparecen.

### No aparece el ícono

El widget no llegó a cargar. Se comprueba abriendo la página y buscando la
etiqueta en el código fuente: tiene que estar la línea con `widget.js` y tu
clave pública, la que empieza con `wc_`.

Si la etiqueta está y el ícono no aparece, casi siempre es el navegador con la
página vieja guardada: recargá forzando (`Cmd+Shift+R` en Mac, `Ctrl+F5` en
Windows).

Si la etiqueta está y el ícono no aparece en ningún navegador, puede ser que
nuestro servidor no esté respondiendo. Si tu etiqueta tiene el `onerror`, no
hace falta hacer nada: el chat lo sigue intentando y aparece solo cuando
volvemos. Si es la etiqueta de antes, sin el `onerror`, hay que recargar la
página cuando volvamos; para no depender de eso, cambiala por la nueva (ver
[Si nuestro servidor no contesta](#si-nuestro-servidor-no-contesta)).

### Aparece pero no abre la conversación

Dice «No se pudo iniciar la conversación». El ícono cargó, así que el problema
está en el **firmador** o en los **dominios declarados**.

Probá el firmador desde una terminal:

```bash
curl -s -X POST -H "Content-Type: application/json" -d '{}' https://TU-SITIO/vocero-aval
```

- **Devuelve `visitorId`, `issuedAt` y `signature`** → el firmador anda, así que
  el problema son los dominios: avisanos desde cuál se está usando el chat. Se
  declara el origen entero, con `https` y sin barra final, y **con y sin `www`**
  si las dos formas resuelven. Es el olvido más común, y el síntoma desconcierta:
  anda para quien entró por un enlace y no para quien escribió la dirección.
- **Devuelve un texto diciendo que falta una variable** → el firmador está
  publicado pero sin sus dos claves. Cargalas y volvé a desplegarlo.
- **Devuelve `404`** → la dirección no existe todavía, o la etiqueta apunta a una
  distinta de donde quedó publicado el firmador.
- **Devuelve HTML** → esa dirección está sirviendo una página en vez del
  firmador. Suele pasar cuando el archivo quedó en el lugar equivocado.

⚠️ Y si el firmador contesta bien pero el chat igual no abre, lo que casi
siempre pasa es que **la clave secreta cargada no es la del canal**: son dos
cadenas parecidas y es fácil mezclar la de un sitio con la de otro cuando hay
más de uno. Avisanos y lo verificamos de nuestro lado en un minuto, sin que
tengas que mandarnos ninguna clave.

### Abre pero nadie contesta

El chat funciona; lo que falta es del lado de la atención. Puede ser que el área
no tenga su grupo vinculado, o que nadie del equipo esté dado de alta como
agente. Eso lo vemos nosotros.

### Abre, pero quien escribe aparece como anónimo

Solo aplica si tu sitio **avala quién es** —una tienda con sesión, o tu propia
aplicación—. Que el chat abra quiere decir que la firma del aval está bien, así
que lo que falla es la segunda firma, la de la identidad. Las causas, en orden:

- **Se firmó para otro visitante.** La identidad se ata al visitante que llega
  en el pedido; la de otro no vale, que es justamente para lo que sirve.
- **Se firmó la cadena equivocada.** El nombre **no se firma**: solo el
  identificador y el correo.
- **Venció.** Dura una hora desde que se emite.
- **Tu aplicación autentica con un token y no con una cookie.** Entonces tu
  servidor está contestando bien que no hay sesión, porque el pedido le llega
  sin credencial. La salida está en la guía del firmador.

Y una que no es un error: si la conversación se abrió **antes** de que la
persona se identificara, su nombre aparece recién con el mensaje siguiente.

### Anda en un dominio y no en otro

La etiqueta está solo en uno. Pasa cuando el sitio vive en varias direcciones y
la instalación se hizo en una: hay que ponerla en todas, y declararlas todas.

---

## Cosas que conviene saber

- **La conversación se ve mientras la persona está en tu sitio.** Si cierra la pestaña y vuelve mañana, su chat arranca en blanco. **Vos no perdés nada**: la conversación entera queda en tu panel y tu equipo la sigue viendo. Y si dejó sus datos, la próxima vez le ofrecemos escribir con esos mismos.
- **Los archivos duran lo que dura la visita.** Después queda anotado que mandó una foto, con su nombre, pero la foto no se guarda. Es la misma política que tenemos en todos los canales.
- **Lo que el navegador nos cuenta puede no ser cierto.** Si alguien escribe "soy Juan Pérez" en el formulario, a tu agente le aparece como _"dice ser Juan Pérez"_. Lo que sí aparece como verificado es lo que confirma tu propio servidor — con WooCommerce eso pasa solo, cuando la persona tiene la sesión iniciada en tu tienda.
- **El chat lleva un "por Vocero" chico al pie**, con un enlace a nuestro sitio. Es parte de cómo nos conocen otras empresas. Según tu plan se puede sacar; preguntanos.
- **Si algo no anda**, lo primero que miramos es si el dominio está declarado y si la clave secreta del servidor es la que corresponde. En el panel te decimos si tu canal recibió alguna conversación válida.
