Zadarma (Click to Call).

Hoy me tomé el trabajo de depurar el funcionamiento del Widget de Zadarma (Click to Call).

¿Para qué sirve? (Básicamente, coloca un botón en tu sitio web que permite que cualquier cliente, desde cualquier parte del mundo, se comunique con tu central PBX sin costo alguno, ni para vos ni para el cliente).

Cuando generás el botón desde su plataforma, Zadarma te entrega dos fragmentos de código:

Uno para incluir en el body de tu web.

Otro para el footer.

El primero carga las bibliotecas necesarias y el segundo integra el Widget en tu sitio.

Hasta ahí, todo parece maravilloso en la teoría… pero al verlo en la práctica, la experiencia deja bastante que desear.

Requisitos que debe cumplir tu servidor para que funcione:

HTTPS obligatorio: el widget no funciona en sitios HTTP.

Configuración adecuada de Apache: necesitás un set de headers bien configurados que permitan el uso del micrófono en el sitio y la comunicación fuera del dominio.

Mucha creatividad en el desarrollo: el botón por defecto es estático, poco atractivo y requiere bastante trabajo para integrarlo de forma decente en tu diseño.

Suerte (y rezar un poco): no provee información de STUN / TURN ni opciones para que el desarrollador tenga control real sobre la comunicación.

Paciencia infinita con el soporte técnico: no está preparado para consultas de desarrollo, pero sí para repetirte que el problema nunca es de ellos, siempre del usuario.

A continuación, les comparto el código original que proporciona Zadarma:

Codigo Body

<div id="zadarmaScripts"></div>
<script>
(function() {
  var script = document.createElement('script');
  script.src = 'https://my.zadarma.com/callmewidget/v2.0.9/loader.js';
  document.getElementById('zadarmaScripts').appendChild(script);
}());
</script>

Codigo Widget

<div id="myZadarmaCallmeWidget16344"></div>
<script>
  var myZadarmaCallmeWidget16344;
  var myZadarmaCallmeWidgetFn16344 = function() {
    myZadarmaCallmeWidget16344 = new ZadarmaCallmeWidget("myZadarmaCallmeWidget16344");
    myZadarmaCallmeWidget16344.create({
"widgetId": "XXXXXXXXXXXXXX TuCodigo de Widget aqui", "sipId":"XXXXX Tu ID ZZZ", "domElement":"myZadarmaCallmeWidget16344" }, { "shape":"square", "language":"es", "width":"0", "dtmf":true, "dtmf_position": "top","dtmf_time_to_disappear": "20", "font": "'Trebuchet MS','Helvetica CY',sans-serif", "color_call": "rgb(255, 255, 255)", "color_bg_call": "rgb(126, 211, 33)", "color_border_call": "rgb(191, 233, 144)", "is_custom_hover": 1, "color_call_hover": "rgb(255, 255, 255)", "bg_call_hover": "rgb(107, 179, 28)", "border_call_hover": "rgb(176, 214, 132)", "color_connection": "rgb(255, 255, 255)", "color_bg_connection": "rgb(33, 211, 166)", "color_border_connection": "rgb(144, 233, 211)", "color_calling": "rgb(255, 255, 255)", "color_border_calling": "rgb(255, 218, 128)", "color_bg_calling": "rgb(255, 181, 0)", "color_ended": "rgb(255, 255, 255)", "color_bg_ended": "rgb(164,164,164)", "color_border_ended": "rgb(210, 210, 210)" });"widgetId": "X6pr7bpH2nf9cTc8Hh7vGvHbu6ggmNSfKN2xn33PSzfDy4cBvTLG39brPd8LbTnkd8zrFktf8kYk61h9mz8hS8gDha693nxz6bf7b439c8b4ea9d6dcefcd9ad381a7c", "sipId":"995512", "domElement":"myZadarmaCallmeWidget16344" }, { "shape":"square", "language":"es", "width":"0", "dtmf":true, "dtmf_position": "top","dtmf_time_to_disappear": "20", "font": "'Trebuchet MS','Helvetica CY',sans-serif", "color_call": "rgb(255, 255, 255)", "color_bg_call": "rgb(126, 211, 33)", "color_border_call": "rgb(191, 233, 144)", "is_custom_hover": 1, "color_call_hover": "rgb(255, 255, 255)", "bg_call_hover": "rgb(107, 179, 28)", "border_call_hover": "rgb(176, 214, 132)", "color_connection": "rgb(255, 255, 255)", "color_bg_connection": "rgb(33, 211, 166)", "color_border_connection": "rgb(144, 233, 211)", "color_calling": "rgb(255, 255, 255)", "color_border_calling": "rgb(255, 218, 128)", "color_bg_calling": "rgb(255, 181, 0)", "color_ended": "rgb(255, 255, 255)", "color_bg_ended": "rgb(164,164,164)", "color_border_ended": "rgb(210, 210, 210)"
    });
  };

  if (window.addEventListener) {
    window.addEventListener('load', myZadarmaCallmeWidgetFn16344, false);
  } else if (window.attachEvent) {
    window.attachEvent('onload', myZadarmaCallmeWidgetFn16344);
  }
</script>


Como toda persona que lleva un tiempo en este rubro, uno acumula conocimientos en varias áreas y, si son como yo, no se quedan únicamente con el “así debería funcionar”.
Esa curiosidad me llevó a detectar una serie de detalles que vale la pena mencionar:

  1. Personalización del widget
    Para empezar, no me convencía que fuera completamente estático, sin posibilidad de manipular su posición ni sus colores. Esto me llevó a desarrollar un código complementario que permite ajustar su ubicación y apariencia en el sitio.
  2. Problemas de tiempos de carga (cURL)
    Descubrí que, dependiendo de los tiempos de carga, las peticiones cURL quedaban fuera de tiempo y el widget simplemente no funcionaba. Esto me obligó a optimizar la primera parte del código para hacerlo más estable.
  3. Compatibilidad con navegadores y códecs
    Luego encontré que, según el navegador, variaba el códec de audio a utilizar, y no todo podía resolverse desde la configuración de Apache. Además, las bibliotecas que provee Zadarma son muy limitadas (versión reducida), sin todas las funciones necesarias. Esto me llevó a realizar una segunda intervención sobre el widget.
  4. Problemas en entornos NAT y ausencia de soporte
    Más adelante, noté que el widget no funcionaba correctamente en estructuras con NAT. Al consultar al soporte técnico, la respuesta fue poco útil: simplemente no sabían de qué les hablaba. Esto me llevó a buscar alternativas propias y terminar forzando el uso de STUN y TURN personalizados para que la comunicación funcionara como corresponde.

Por todo esto, decidí compartir el código que fui desarrollando y que seguramente les pueda resultar de gran utilidad si piensan implementar el widget en proyectos reales.

Ahora empecemos a mostrar el desarrollo que llevo poner esto en marcha

Líneas modificadas en la configuración del VirtualHost en Apache/2.4.65

Cabe destacar que la configuración completa de este VirtualHost es mucho más extensa.
A continuación, solo se presentan las líneas relacionadas con el aspecto que deseamos mostrar.
Cada línea se encuentra comentada para indicar su función específica.

               <IfModule mod_headers.c>
                        # ------------------------------
                        # Seguridad básica
                        # ------------------------------
                        Header always set X-Content-Type-Options "nosniff"
                        Header always set Referrer-Policy "same-origin"
                        Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"

                        # XSS Protection (obsoleto en Chrome/Edge, opcional)
                        Header always set X-XSS-Protection "1; mode=block"

                        # ------------------------------
                        # WebRTC - permitir cámara y micrófono
                        # ------------------------------
                        Header always set Permissions-Policy "camera=(self \"https://*.zadarma.com\"), microphone=(self \"https://*.zadarma.com\")"
                        Header always set Content-Security-Policy "default-src 'self' https: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https: *.zadarma.com; style-src 'self' 'unsafe-inline' https: *.zadarma.com; img-src 'self' https: data: *.zadarma.com; font-src 'self' https: data: *.zadarma.com; connect-src 'self' https: wss: *.zadarma.com; frame-src 'self' https: *.zadarma.com; media-src *;"

                        # ------------------------------
                        # CORS dinámico con credenciales
                        # ------------------------------
                        # Capturar Origin si es de cabgroupsrl.com o zadarma.com
                        SetEnvIfNoCase Origin "^https://([A-Za-z0-9-]+\.)?(cabgroupsrl\.com|zadarma\.com)$" ORIGIN=$0

                        <If "%{env:ORIGIN} != ''">
                                # Si coincide, devolver el mismo Origin + habilitar credenciales
                                Header always set Access-Control-Allow-Origin "%{ORIGIN}e"
                                Header always set Access-Control-Allow-Credentials "true"
                        </If>
                        <Else>
                                # Si no coincide, usar * (sin credenciales)
                                Header always set Access-Control-Allow-Origin "*"
                        </Else>

                        # Métodos y cabeceras permitidos
                        Header always set Access-Control-Allow-Methods "GET, POST, OPTIONS"
                        Header always set Access-Control-Allow-Headers "Content-Type, Authorization"
                </IfModule>

                <IfModule mod_gzip.c>
                        mod_gzip_on Yes
                        mod_gzip_dechunk Yes
                        mod_gzip_item_include file .(html?|txt|css|js|php|pl)$
                        mod_gzip_item_include handler ^cgi-script$
                        mod_gzip_item_include mime ^text/.*
                        mod_gzip_item_include mime ^application/x-javascript.*
                        mod_gzip_item_exclude mime ^image/.*
                        mod_gzip_item_exclude rspheader ^Content-Encoding:.gzip.
                        # Excluir URLs de Zadarma
                        mod_gzip_item_exclude reqheader ^Referer:.*zadarma\.com
                </IfModule>

                <IfModule mod_deflate.c>
                        SetOutputFilter DEFLATE
                        AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/x-javascript application/json
                        BrowserMatch ^Mozilla/4 gzip-only-text/html
                        BrowserMatch ^Mozilla/4\.0[678] no-gzip
                        BrowserMatch \bMSIE !no-gzip !gzip-only-text/html
                        DeflateCompressionLevel 9
                        SetEnvIfNoCase Request_URI \.(?:gif|jpe?g|png)$ no-gzip dont-vary
                        # Excluir cualquier request hacia Zadarma
                        SetEnvIfNoCase Request_URI "^https?://([a-z0-9-]+\.)?zadarma\.com/.*" no-gzip
                        Header append Vary User-Agent env=!dont-vary
                </IfModule>

                <IfModule mod_expires.c>
                        ExpiresActive On
                        ExpiresDefault "access plus 7 days"  # Por defecto, 7 días de cache

                        # HTML sin caché
                        ExpiresByType text/html "access plus 0 seconds"

                        # Imágenes con cache de 7 días
                        ExpiresByType image/gif "access plus 7 days"
                        ExpiresByType image/jpeg "access plus 7 days"
                        ExpiresByType image/png "access plus 7 days"

                        # CSS y JS propios con cache de 7 días, pero excluyendo Zadarma
                        ExpiresByType text/css "access plus 7 days"
                        ExpiresByType application/javascript "access plus 7 days"
                        ExpiresByType application/x-javascript "access plus 7 days"

                        # Fuentes
                        ExpiresByType application/font-woff "access plus 7 days"
                        ExpiresByType application/font-woff2 "access plus 7 days"
                        ExpiresByType application/vnd.ms-fontobject "access plus 7 days"
                        ExpiresByType application/font-sfnt "access plus 7 days"

                        # Excluir cualquier recurso de Zadarma para que siempre se cargue fresco
                        <FilesMatch ".*">
                                SetEnvIfNoCase Request_URI "^https?://([a-z0-9-]+\.)?zadarma\.com/.*" NO_CACHE
                                Header set Cache-Control "no-cache, no-store, must-revalidate" env=NO_CACHE
                                Header set Pragma "no-cache" env=NO_CACHE
                                Header set Expires "0" env=NO_CACHE
                        </FilesMatch>
                </IfModule>

                # Activar BandwidthModule
                BandwidthModule On
                ForceBandWidthModule On

                # Limitar archivos grandes solo en tu DocumentRoot
                <Directory /srv/services/hosting/cabgroupsrl.com.ar/http-web/>
                        LargeFileLimit .avi 125000 1250000
                        LargeFileLimit .mpg 125000 1250000
                        LargeFileLimit .pst 125000 1250000
                </Directory>

                <FilesMatch "\.(js|css|png|jpg|jpeg|gif|woff|woff2|ttf|eot|svg)$">
                        # Excluir cualquier recurso de Zadarma
                        SetEnvIfNoCase Request_URI "^https?://([a-z0-9-]+\.)?zadarma\.com/.*" NO_CACHE

                        # Cache para recursos propios
                        Header set Cache-Control "max-age=1296000, public" env=!NO_CACHE
                        Header set Pragma "public" env=!NO_CACHE
                        Header set Expires "Thu, 31 Dec 2100 23:55:55 GMT" env=!NO_CACHE
                        FileETag None
                        Header unset ETag env=!NO_CACHE
                        Header unset Last-Modified env=!NO_CACHE

                        # Para recursos de Zadarma → siempre fresco
                        Header set Cache-Control "no-cache, no-store, must-revalidate" env=NO_CACHE
                        Header set Pragma "no-cache" env=NO_CACHE
                        Header set Expires "0" env=NO_CACHE
                </FilesMatch>

 


Segunda parte: modificación y adecuación del widget de Zadarma (Click-to-Call)

Esta etapa nos llevó casi una semana de prueba y error. Hubo cientos de intercambios con soporte y, lamentablemente, muchas respuestas de manual que nos devolvían al punto de partida cada vez que cambiaba el técnico asignado. Aun así, documentamos todo y consolidamos una versión estable, personalizable y apta para producción.

Zadarma entrega dos fragmentos:

  • Segmento 1 (body): carga de librerías.
  • Segmento 2 (footer): integración/render del widget.

Nuestro objetivo fue endurecer el primer segmento (estabilidad y permisos) y mejorar el segundo (UI/UX, compatibilidad y conectividad) hasta lograr un comportamiento predecible en producción.

Segmento 1: estabilidad y permisos

1) cURL y tiempos de carga

Problema: dependiendo del tiempo que demoran los recursos, las llamadas cURL quedaban “fuera de tiempo” y el widget no iniciaba.

Qué hicimos:

  • Aumentamos y normalizamos timeouts (con valores realistas para producción).
  • Implementamos reintentos con backoff exponencial para las llamadas críticas.
  • Separación de responsabilidades: la carga del widget no bloquea la página (carga asíncrona + eventos DOMContentLoaded/load bien encadenados).
  • Manejo de errores explícito: si falla una etapa, registramos, reintentamos y mostramos un estado claro (sin “quedarse mudo”).

Resultado: arranque consistente incluso con redes lentas o latencia variable.

2) Permisos de micrófono al cargar la página

Problema: el widget fallaba silenciosamente cuando el navegador no tenía permisos de micrófono concedidos.

Qué hicimos:

  • Chequeo preventivo con navigator.mediaDevices y getUserMedia({ audio: true }) dentro de un flujo controlado (promesas/catch).
  • Mensajes guiados cuando el permiso fue denegado o “no determinado”, con instrucciones por navegador.
  • Estado persistente: guardamos la decisión del usuario (p. ej., en localStorage) para evitar pedir permiso innecesariamente.

Resultado: menos fallos silenciosos y mejor experiencia para el usuario final.

Codigo Body (Mejora)

/**
 * Funciones para integración segura con Zadarma
 */

// Permisos de micrófono solamente
add_action('send_headers', function() {
    $policy = 'microphone=(self "https://zadarma.com" "https://*.zadarma.com" "https://cabgroupsrl.com.ar" "https://*.cabgroupsrl.com.ar")';
    header("Permissions-Policy: $policy");
});

// Cargar widget de Zadarma (script solo)
add_action('wp_footer', function() {
    ?>
    <div id="zadarmaScripts"></div>
    <script>
    (function() {
        // Carga del script del widget Zadarma
        var script = document.createElement('script');
        script.src = 'https://my.zadarma.com/callmewidget/v2.0.9/loader.js';
        document.getElementById('zadarmaScripts').appendChild(script);
    }());
    </script>
    <?php
});

// Función cURL con timeout seguro
function zadarma_curl_request($url, $headers = [], $connectTimeout = 1, $execTimeout = 2) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $connectTimeout);
    curl_setopt($ch, CURLOPT_TIMEOUT, $execTimeout);

    if (!empty($headers)) {
        curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
    }

    $response = curl_exec($ch);
    $error = curl_error($ch);
    curl_close($ch);

    if ($response === false) {
        error_log("Zadarma cURL error: $error | URL: $url");
        return false;
    }
    return $response;
}


Segmento 2: UI/UX, compatibilidad y conectividad

1) Botón flotante, elegante y fijo

Problema: el botón original es estático, poco visible y se pierde con el scroll.

Qué hicimos:

  • Componente flotante con position: fixed, no se escrolea, y mantiene z-index alto.
  • Responsive (desktop/mobile), accesible (ARIA) y con foco/teclado.
  • Estilos personalizables (color, tamaño, iconografía) para integrarlo al branding del sitio.

Resultado: mejor tasa de interacción y un widget que “se ve y se entiende”.

2) Permisos de micrófono: estados y persistencia

Problema: estado de permisos inconsistente entre navegadores/sesiones.

Qué hicimos:

  • Verificación de estado al inicializar y solicitud proactiva únicamente cuando hace falta.
  • Persistencia del resultado (en el equipo) para no repetir prompts.
  • Mensajes claros si el permiso cambia (p. ej., el usuario lo revoca desde ajustes del navegador).

Resultado: menos fricción y menos tickets por “no me toma el micrófono”.

3) Presencia de hardware antes de iniciar la llamada

Problema: el widget intentaba iniciar llamada sin micrófono físico, y la sesión se cortaba.

Qué hicimos:

  • Chequeo de dispositivos con enumerateDevices() y validación de entradas de audio.
  • Si no hay micrófono, no renderizamos el botón de llamar y ofrecemos alternativas (teléfono, WhatsApp, formulario).
  • Reintentos suaves si el usuario conecta un dispositivo en caliente.

Resultado: cero llamadas fallidas por ausencia de hardware.

4) Códecs de audio y compatibilidad entre navegadores

Problema: diferencias de códecs según navegador rompían la experiencia.

Qué hicimos:

  • Preferencia por códecs ampliamente soportados (p. ej., OPUS como primera opción; fallback a PCMU/PCMA en navegadores más estrictos).
  • Ajustes en la negociación SDP/RTC para priorizar la compatibilidad.
  • Detección por navegador (feature detection, no user-agent) para aplicar tweaks mínimos y seguros.

Resultado: audio consistente en Chrome, Edge, Firefox y mejoría notable en Safari.

5) NAT, STUN y (cuando corresponde) TURN

Problema: en redes con NAT, la señalización no encontraba camino y la llamada no establecía.

Qué hicimos:

  • Forzamos servidores STUN confiables y configurables (RTCConfiguration/ICE).
  • TURN opcional para entornos restrictivos (symmetric NAT / firewalls estrictos).
  • Keep-alive y manejo de estados ICE (oniceconnectionstatechange) con reconexiones controladas.
  • Logs de depuración legibles: sabemos si falla STUN, TURN o la negociación.

Resultado: llamadas estables incluso detrás de NAT “complicadas”.

Experiencia con soporte (breve y honesta)

  • Tuvimos múltiples traspasos de ticket y repeticiones de respuestas básicas.
  • La documentación oficial cubre lo estándar, pero no los casos “de campo”.
  • Solución: medir, registrar y ajustar por nuestra cuenta. La ingeniería de observabilidad (logs/metricas) fue clave.

Lecciones aprendidas (lo que repetiríamos mañana)

  1. Cargar asíncrono + reintentos: la diferencia entre “funciona a veces” y “funciona siempre”.
  2. Pedir permisos en el momento correcto: antes de necesitar el audio, pero sin ser invasivos.
  3. No renderizar si falta hardware: mejor evitar el clic que defraudar al usuario.
  4. Compatibilidad de códecs: elegir bien los defaults evita dolores de cabeza.
  5. STUN/TURN configurables: imprescindible en producción.
  6. UI visible y accesible: si el botón no se ve o no se entiende, no existe.

Checklist rápido para producción

  • HTTPS activo y headers correctos (micrófono/orígenes cruzados).
  • Carga asíncrona + timeouts razonables + reintentos.
  • Chequeo de permisos y presencia de micrófono antes de exponer el botón.
  • Widget flotante, accesible, con estilos del sitio.
  • Preferencias de códecs compatibles y negociación robusta.
  • STUN/TURN propios y monitoreo de estados ICE.
  • Logs claros y métricas (intentos, fallos, causas, reconexiones).

Codigo Widget (Mejorado)

<!-- Widget flotante Zadarma solo si hay micrófono -->
<div id="callme-floating" style="display:none;">
  <p>📞 Soporte Técnico en un Click</p>
  <div id="myZadarmaCallmeWidget16344"></div>
  <p id="permiso-mensaje" style="color:#fff; background:rgba(255,0,0,0.6); padding:5px 8px; border-radius:6px; display:none;"></p>
</div>

<style>
#callme-floating {
  position: fixed;
  bottom: 20px;
  right: 20px;
  z-index: 99999;
  text-align: center;
}
#callme-floating p {
  font-size: 14px;
  font-weight: bold;
  margin: 0 0 5px 0;
  color: #fff;
  background: rgba(0,0,0,0.6);
  padding: 5px 8px;
  border-radius: 6px;
}

/* Estilo botón permisos */
#solicitarPermisosBtn {
  margin-top: 10px;
  display: block;
  background-color: #FFD700; /* amarillo */
  color: #fff; /* letras blancas */
  padding: 8px 12px;
  font-weight: bold;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
#solicitarPermisosBtn:hover {
  background-color: #FFC300;
}
</style>

<script>
(function() {
  const container = document.getElementById('myZadarmaCallmeWidget16344');
  const mensaje = document.getElementById('permiso-mensaje');
  const callmeFloating = document.getElementById('callme-floating');

  // Verifica si hay micrófono conectado
  async function checkMicrofono() {
    try {
      const devices = await navigator.mediaDevices.enumerateDevices();
      return devices.some(d => d.kind === 'audioinput');
    } catch (err) {
      console.error("Error al enumerar dispositivos:", err);
      return false;
    }
  }

  // Verifica estado del permiso
  async function checkMicPermission() {
    try {
      const status = await navigator.permissions.query({ name: 'microphone' });
      return status.state; // "granted", "denied", "prompt"
    } catch (err) {
      console.warn("Permissions API no soportada, se pedirá permiso normalmente");
      return "prompt";
    }
  }

  async function init() {
    const hasMic = await checkMicrofono();
    if (!hasMic) return; // No hay micrófono, no mostramos widget

    const permState = await checkMicPermission();

    callmeFloating.style.display = "block";

    if (permState === "granted") {
      // Permiso ya concedido, inicializa widget directamente
      initWidget();
    } else {
      // Mostrar botón para solicitar permiso
      const btn = document.createElement('button');
      btn.id = 'solicitarPermisosBtn';
      btn.textContent = 'Habilite Micrófono para comunicación con soporte directa';
      btn.onclick = solicitarPermisos;
      callmeFloating.appendChild(btn);
    }
  }

  async function solicitarPermisos() {
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      console.log("✅ Permiso de micrófono concedido", stream);
      document.getElementById('solicitarPermisosBtn').style.display = "none";
      initWidget();
    } catch (err) {
      if (err.name === "NotAllowedError") {
        mensaje.textContent = "⚠️ Permiso de micrófono denegado.";
      } else if (err.name === "NotFoundError") {
        mensaje.textContent = "⚠️ No se detectó micrófono.";
      } else {
        mensaje.textContent = "❌ Error al solicitar permiso: " + err.message;
      }
      mensaje.style.display = "block";
      console.error(err);
    }
  }

  function initWidget() {
    const script = document.createElement('script');
    script.src = 'https://my.zadarma.com/callmewidget/v2.0.9/loader.js';
    script.onload = function() {
      if (typeof ZadarmaCallmeWidget !== 'undefined') {
        window.myZadarmaCallmeWidget16344 = new ZadarmaCallmeWidget("myZadarmaCallmeWidget16344");
        myZadarmaCallmeWidget16344.create({
          widgetId: "XXXXXXXXXXXXXX",
          sipId: "XXXXX Tu ID ZZZ",
          domElement: "myZadarmaCallmeWidget16344",
          audioCodec: "OPUS"
        }, {
          shape:"square",
          language:"es",
          width:"0",
          dtmf:true,
          dtmf_position:"top",
          dtmf_time_to_disappear:"20",
          font:"'Trebuchet MS','Helvetica CY',sans-serif",
          color_call:"#fff",
          color_bg_call:"rgb(126, 211, 33)",
          color_border_call:"rgb(191, 233, 144)"
        });
      } else {
        console.error("⚠️ ZadarmaCallmeWidget no está definido");
      }
    };
    container.appendChild(script);
  }

  window.addEventListener('load', init);
})();
</script>
 


Cierre

Con estos cambios, el widget pasó de ser “usable en laboratorio” a confiable en producción.