<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Ivan Labasok</title>
    <description>The latest articles on DEV Community by Ivan Labasok (@ivanindiv).</description>
    <link>https://dev.to/ivanindiv</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3963265%2F35a1f278-a53c-4608-b3ce-3f0351c388c5.jpg</url>
      <title>DEV Community: Ivan Labasok</title>
      <link>https://dev.to/ivanindiv</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ivanindiv"/>
    <language>en</language>
    <item>
      <title>Validar números de teléfono en JavaScript: por qué tu expresión regular está mal</title>
      <dc:creator>Ivan Labasok</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:37:12 +0000</pubDate>
      <link>https://dev.to/ivanindiv/validar-numeros-de-telefono-en-javascript-por-que-tu-expresion-regular-esta-mal-26e2</link>
      <guid>https://dev.to/ivanindiv/validar-numeros-de-telefono-en-javascript-por-que-tu-expresion-regular-esta-mal-26e2</guid>
      <description>&lt;h2&gt;
  
  
  En corto
&lt;/h2&gt;

&lt;p&gt;Si en tu proyecto hay una expresión regular validando teléfonos, está mal. No «mejorable»: mal. Y lo peor es que falla en silencio — acepta basura y rechaza números perfectamente válidos de usuarios reales que se van sin registrarse.&lt;/p&gt;

&lt;p&gt;Te cuento por qué el problema es irresoluble con regex, qué es E.164, cómo usar &lt;code&gt;libphonenumber-js&lt;/code&gt; sin los tres errores habituales, y qué columna deberías tener realmente en la base de datos.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Por qué tu expresión regular para teléfonos está mal
&lt;/h2&gt;

&lt;p&gt;Esta es, con variaciones, la regex que aparece en casi todos los tutoriales en español:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;regex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\+?[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{9,15}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Parece razonable. Veamos qué hace con números reales:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entrada&lt;/th&gt;
&lt;th&gt;Qué es&lt;/th&gt;
&lt;th&gt;Tu regex dice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+34 612 345 678&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Móvil español válido&lt;/td&gt;
&lt;td&gt;❌ Rechazado (espacios)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+390212345678&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fijo de Milán, el cero &lt;strong&gt;es parte del número&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅ Aceptado, pero si «normalizas» quitando el cero lo rompes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+521234567890&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;México, con el prefijo móvil &lt;code&gt;1&lt;/code&gt; que &lt;strong&gt;ya no se usa&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;✅ Aceptado, y no se puede llamar&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+999999999999&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No existe ese código de país&lt;/td&gt;
&lt;td&gt;✅ Aceptado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+34612345678901&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Español con seis dígitos de más&lt;/td&gt;
&lt;td&gt;✅ Aceptado&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;+1 (213) 373-4253 ext. 42&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Número con extensión&lt;/td&gt;
&lt;td&gt;❌ Rechazado&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cuatro falsos positivos y dos falsos negativos en seis casos. Y el problema no es que la regex sea mala: &lt;strong&gt;es que la pregunta no tiene respuesta regular.&lt;/strong&gt; La longitud válida depende del país, dentro del país depende del prefijo, y dentro del prefijo depende de si es fijo, móvil o servicio. Son miles de reglas que además cambian: los países reasignan rangos, abren prefijos nuevos y deprecan otros.&lt;/p&gt;

&lt;p&gt;Google mantiene un documento entero dedicado a esto, &lt;a href="https://github.com/google/libphonenumber/blob/master/FALSEHOODS.md" rel="noopener noreferrer"&gt;FALSEHOODS.md&lt;/a&gt;, con 27 suposiciones falsas sobre teléfonos. Mis favoritas, porque casi todos las hemos cometido:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;«Los ceros iniciales del formato nacional siempre se pueden descartar en formato internacional.»&lt;/em&gt; Falso — Italia los conserva.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;«Ningún prefijo de un número válido puede ser a su vez un número válido.»&lt;/em&gt; Falso.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;«Un número inválido no llegará a ningún destino.»&lt;/em&gt; Falso.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;«Los números de teléfono son números.»&lt;/em&gt; Falso, y esta es la que más daño hace en producción.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  2. E.164: el único formato que deberías guardar
&lt;/h2&gt;

&lt;p&gt;E.164 es el estándar de la UIT y es una definición corta: un &lt;code&gt;+&lt;/code&gt;, el código de país, y hasta &lt;strong&gt;15 dígitos en total&lt;/strong&gt;. Sin espacios, sin guiones, sin paréntesis, sin prefijos de marcación nacional.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;+34612345678      ← esto
+34 612 34 56 78  ← esto no, es presentación
(612) 345 678     ← esto tampoco, ni siquiera dice de qué país es
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La regla mental es sencilla: &lt;strong&gt;E.164 es cómo se almacena, el formato bonito es cómo se muestra.&lt;/strong&gt; Son dos cosas distintas y confundirlas es el origen de la mitad de los bugs de esta categoría. El formato de presentación se calcula al renderizar; nunca se guarda.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. &lt;code&gt;libphonenumber-js&lt;/code&gt; en cuatro líneas
&lt;/h2&gt;

&lt;p&gt;La biblioteca de Google (&lt;code&gt;libphonenumber&lt;/code&gt;) es la fuente de verdad, y en JavaScript la versión práctica es &lt;a href="https://gitlab.com/catamphetamine/libphonenumber-js" rel="noopener noreferrer"&gt;&lt;code&gt;libphonenumber-js&lt;/code&gt;&lt;/a&gt; — una reescritura más pequeña que carga los metadatos reales de numeración.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;libphonenumber-js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;phone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;612 345 678&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;               &lt;span class="c1"&gt;// '+34612345678'   ← E.164, esto va a la BD&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;// 'ES'&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;countryCallingCode&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// '34'&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nationalNumber&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// '612345678'&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;            &lt;span class="c1"&gt;// true&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatInternational&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// '+34 612 34 56 78'  ← esto se muestra&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;formatNational&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;      &lt;span class="c1"&gt;// '612 34 56 78'&lt;/span&gt;
&lt;span class="nx"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getURI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;              &lt;span class="c1"&gt;// 'tel:+34612345678'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres cosas que conviene saber desde el primer día:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;parsePhoneNumberFromString&lt;/code&gt; devuelve &lt;code&gt;undefined&lt;/code&gt; si no puede parsear&lt;/strong&gt;, no lanza. Si prefieres excepciones tipadas, existe &lt;code&gt;parsePhoneNumberWithError&lt;/code&gt;, que lanza un &lt;code&gt;ParseError&lt;/code&gt; con un &lt;code&gt;message&lt;/code&gt; legible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El segundo argumento es el país por defecto&lt;/strong&gt;, y solo se usa cuando la entrada no viene en formato internacional. Si el usuario escribe &lt;code&gt;+34...&lt;/code&gt;, el país sale del número y el argumento se ignora. Si escribe &lt;code&gt;612345678&lt;/code&gt; a secas, sin ese &lt;code&gt;'ES'&lt;/code&gt; no hay forma de saber de dónde es.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hay tres paquetes de metadatos&lt;/strong&gt; y elegir mal te cuesta o peso o exactitud:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js/min&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// ~80 kB&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js/mobile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ~95 kB&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js/max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;// ~145 kB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El import por defecto usa &lt;code&gt;min&lt;/code&gt;. Si necesitas saber si un número es móvil o fijo, &lt;strong&gt;necesitas &lt;code&gt;max&lt;/code&gt;&lt;/strong&gt; — con &lt;code&gt;min&lt;/code&gt;, &lt;code&gt;getType()&lt;/code&gt; no te va a servir:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js/max&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+34612345678&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 'MOBILE'&lt;/span&gt;
&lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+34911234567&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// 'FIXED_LINE'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esto importa de verdad si vas a mandar un SMS: gastar el envío contra un fijo es tirar el dinero, y el error es invisible hasta que miras la factura.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. &lt;code&gt;isPossible&lt;/code&gt; vs &lt;code&gt;isValid&lt;/code&gt;: cuál usar y cuándo
&lt;/h2&gt;

&lt;p&gt;Aquí es donde casi todo el mundo elige mal, porque los nombres no ayudan.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;isValidPhoneNumber&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isPossiblePhoneNumber&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;isPossiblePhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+34 999 999 999&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// true  — la longitud cuadra&lt;/span&gt;
&lt;span class="nf"&gt;isValidPhoneNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+34 999 999 999&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// false — ese rango no existe en España&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isPossible&lt;/code&gt;&lt;/strong&gt; mira solo la &lt;strong&gt;longitud&lt;/strong&gt; para ese país. Es rápido, tolerante y sobrevive mejor al paso del tiempo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;isValid&lt;/code&gt;&lt;/strong&gt; comprueba longitud &lt;strong&gt;y prefijo&lt;/strong&gt; contra el plan de numeración. Es estricto y mucho más exacto.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;La elección no es de gusto, depende de qué estés protegiendo:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Usa &lt;code&gt;isValid&lt;/code&gt; en el formulario de registro&lt;/strong&gt;, donde quieres frenar erratas antes de gastar un SMS de verificación. &lt;strong&gt;Usa &lt;code&gt;isPossible&lt;/code&gt; al importar datos existentes o al validar algo que ya está en tu base de datos&lt;/strong&gt;, porque &lt;code&gt;isValid&lt;/code&gt; rechaza números legítimos que se asignaron después de la última actualización de metadatos. Los planes de numeración se amplían constantemente, y tu &lt;code&gt;package-lock.json&lt;/code&gt; no.&lt;/p&gt;

&lt;p&gt;Ese matiz tiene una consecuencia operativa que conviene asumir: &lt;strong&gt;&lt;code&gt;libphonenumber&lt;/code&gt; es una dependencia que caduca.&lt;/strong&gt; Si la fijaste hace tres años, hoy está rechazando clientes reales. Actualízala como actualizarías una base de datos de zonas horarias.&lt;/p&gt;

&lt;p&gt;Para mensajes de error útiles existe además:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;validatePhoneNumberLength&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;validatePhoneNumberLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;612&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;        &lt;span class="c1"&gt;// 'TOO_SHORT'&lt;/span&gt;
&lt;span class="nf"&gt;validatePhoneNumberLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;6123456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// 'TOO_LONG'&lt;/span&gt;
&lt;span class="nf"&gt;validatePhoneNumberLength&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;612345678&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// undefined → longitud correcta&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decir «te faltan dígitos» convierte mucho mejor que «número inválido».&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Formatear mientras el usuario escribe
&lt;/h2&gt;

&lt;p&gt;El campo que se va formateando solo mientras tecleas no es cosmética: reduce erratas de forma medible, porque el usuario ve los grupos de dígitos y detecta él mismo el que falta.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AsYouType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AsYouType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;612345&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// '612 345'&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AsYouType&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+3461234&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;// '+34 61 23 4'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un input de React completo, con validación en vivo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AsYouType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsePhoneNumberFromString&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;libphonenumber-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TelefonoInput&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;pais&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setTexto&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleInput&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;formateado&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AsYouType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pais&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setTexto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formateado&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formateado&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pais&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Al padre le entregamos SIEMPRE E.164, nunca el texto formateado&lt;/span&gt;
    &lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pais&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;isValid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;input&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;
        &lt;span class="na"&gt;inputMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;
        &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"tel"&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;texto&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;handleInput&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;aria-invalid&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alert"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Revisa el número&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles de accesibilidad que se olvidan siempre: &lt;code&gt;type="tel"&lt;/code&gt; con &lt;code&gt;inputMode="tel"&lt;/code&gt; saca el teclado numérico en móvil, y &lt;code&gt;autoComplete="tel"&lt;/code&gt; permite que el navegador rellene el campo. Cada uno de los dos vale más que cualquier validación que escribas.&lt;/p&gt;

&lt;p&gt;Y una advertencia sobre &lt;code&gt;AsYouType&lt;/code&gt;: &lt;strong&gt;está pensado para escritura hacia delante.&lt;/strong&gt; Si el usuario borra por el medio o pega un número entero, el formateo se comporta de forma extraña. La solución habitual es reiniciar el formateador cuando detectas un pegado o un borrado no terminal, en lugar de pelearte con el estado.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. México, Argentina e Italia: los tres casos que rompen todo
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;México.&lt;/strong&gt; Durante años, para llamar a un móvil mexicano desde el extranjero había que intercalar un &lt;code&gt;1&lt;/code&gt; tras el código de país: &lt;code&gt;+52 1 55 ...&lt;/code&gt;. Esos prefijos de marcación &lt;strong&gt;se deprecaron y se eliminaron&lt;/strong&gt; del plan. Si tu base de datos se llenó antes del cambio, tienes números guardados que ya no se pueden marcar. Y como pasan el filtro de cualquier regex, nadie se entera hasta que un cliente se queja de que no le llegan los SMS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Argentina.&lt;/strong&gt; El caso inverso y aún vivo: los móviles llevan un &lt;code&gt;9&lt;/code&gt; después del &lt;code&gt;54&lt;/code&gt; en formato internacional, pero &lt;strong&gt;domésticamente ese &lt;code&gt;9&lt;/code&gt; desaparece y se marca &lt;code&gt;15&lt;/code&gt; entre el prefijo de zona y el número&lt;/strong&gt;. &lt;code&gt;+54 9 2982 123456&lt;/code&gt; se marca localmente como &lt;code&gt;02982 15 123456&lt;/code&gt;. Son el mismo teléfono con dos representaciones que no se parecen. Si comparas cadenas para deduplicar contactos, te salen dos personas donde hay una.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Italia.&lt;/strong&gt; El cero inicial del fijo &lt;strong&gt;no se descarta&lt;/strong&gt; al internacionalizar: es parte del número. &lt;code&gt;+39 02 1234567&lt;/code&gt; es correcto. Si aplicas la regla «quitar el cero inicial» que funciona en España, Francia o Reino Unido, rompes Italia entera.&lt;/p&gt;

&lt;p&gt;La conclusión práctica no es memorizar estos tres. Es que &lt;strong&gt;existen decenas de casos así y ninguno cabe en tu cabeza ni en tu regex&lt;/strong&gt; — por eso se delega en una biblioteca con metadatos mantenidos.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// La forma correcta de normalizar es no normalizar a mano:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;e164&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePhoneNumberFromString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entrada&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paisPorDefecto&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;number&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Validar no es lo mismo que poder llamar
&lt;/h2&gt;

&lt;p&gt;Este es el límite del artículo y conviene decirlo claro: &lt;strong&gt;&lt;code&gt;isValid()&lt;/code&gt; responde «este número encaja en el plan de numeración», no «este número existe y suena en el bolsillo de alguien».&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Un número puede ser perfectamente válido y estar sin asignar, dado de baja, portado a un operador que lo tiene desconectado, o pertenecer a un rango que no acepta SMS. La lista de falsedades de Google lo recoge en dos entradas distintas — &lt;em&gt;«se puede llamar a cualquier número»&lt;/em&gt; y &lt;em&gt;«un número inválido no llegará a ningún destino»&lt;/em&gt;, ambas falsas.&lt;/p&gt;

&lt;p&gt;Lo único que demuestra que un número es alcanzable es mandarle algo y ver qué pasa. Si tu producto depende de ello, la verificación real es un código por SMS o una llamada; la validación de formato solo sirve para no desperdiciar esos envíos. Son dos capas y hacen falta las dos.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Qué guardar en la base de datos
&lt;/h2&gt;

&lt;p&gt;Tres reglas y una columna que no debes crear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guarda E.164 como texto.&lt;/strong&gt; Nunca como entero: los ceros iniciales desaparecen, el &lt;code&gt;+&lt;/code&gt; no cabe, y quince dígitos se salen de un &lt;code&gt;INT&lt;/code&gt;. Un &lt;code&gt;VARCHAR(16)&lt;/code&gt; sobra.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Guarda el país por separado.&lt;/strong&gt; Sí, se puede deducir del número, pero es una operación con casos ambiguos —hay códigos de país compartidos por varios territorios, otra de las falsedades de la lista— y lo vas a necesitar para formatear, para elegir idioma y para tarificar. Deducirlo en cada consulta es trabajo repetido.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No guardes el formato de presentación.&lt;/strong&gt; Se calcula al renderizar con &lt;code&gt;formatInternational()&lt;/code&gt;. Si lo guardas, tendrás dos fuentes de verdad que divergirán.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;contactos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;            &lt;span class="n"&gt;BIGSERIAL&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;telefono_e164&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- '+34612345678'&lt;/span&gt;
  &lt;span class="n"&gt;pais_iso&lt;/span&gt;      &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 'ES'&lt;/span&gt;
  &lt;span class="n"&gt;verificado_en&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;-- NULL = formato válido, alcance NO probado&lt;/span&gt;
  &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;telefono_e164_formato&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;telefono_e164&lt;/span&gt; &lt;span class="o"&gt;~&lt;/span&gt; &lt;span class="s1"&gt;'^&lt;/span&gt;&lt;span class="se"&gt;\+&lt;/span&gt;&lt;span class="s1"&gt;[1-9][0-9]{6,14}$'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;contactos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;telefono_e164&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fíjate en &lt;code&gt;verificado_en&lt;/code&gt;. Esa columna es la que separa «pasó la validación de formato» de «le llegó un SMS y contestó», y tenerlas separadas te ahorra la conversación de soporte más incómoda que existe.&lt;/p&gt;

&lt;p&gt;Y sí, ahí arriba hay una expresión regular. Es el &lt;code&gt;CHECK&lt;/code&gt; de integridad de E.164 — comprueba que la cadena tiene forma de E.164, no que el número exista. Ese es exactamente el trabajo que una regex sí sabe hacer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checklist para llevarte
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Guarda &lt;strong&gt;E.164&lt;/strong&gt; como texto, muestra el formato bonito, y no confundas las dos cosas.&lt;/li&gt;
&lt;li&gt;Nunca una columna numérica para teléfonos.&lt;/li&gt;
&lt;li&gt;Guarda el país en su propia columna.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;isValid&lt;/code&gt; en el registro; &lt;code&gt;isPossible&lt;/code&gt; al importar y al releer datos antiguos.&lt;/li&gt;
&lt;li&gt;Necesitas el paquete &lt;code&gt;/max&lt;/code&gt; si vas a usar &lt;code&gt;getType()&lt;/code&gt; para distinguir móvil de fijo.&lt;/li&gt;
&lt;li&gt;Actualiza &lt;code&gt;libphonenumber&lt;/code&gt; periódicamente: los planes de numeración cambian y una versión vieja rechaza clientes reales.&lt;/li&gt;
&lt;li&gt;No inventes reglas de normalización por país. México, Argentina e Italia ya demuestran por qué.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;type="tel"&lt;/code&gt; + &lt;code&gt;inputMode="tel"&lt;/code&gt; + &lt;code&gt;autoComplete="tel"&lt;/code&gt;. Tres atributos, mucho más impacto que la validación.&lt;/li&gt;
&lt;li&gt;Validar el formato &lt;strong&gt;no&lt;/strong&gt; es verificar que el número existe. Para eso hace falta mandar algo.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Si has llegado hasta aquí es probable que estés montando algo que además de validar necesita &lt;strong&gt;llamar o mandar SMS&lt;/strong&gt; a esos números, que es donde empieza otra categoría entera de problemas. Escribí sobre esa parte —WebRTC hasta la red telefónica, CDR y el redondeo de facturación— en otro artículo. Y si prefieres no montarlo, &lt;a href="https://www.twin-phone.com/es" rel="noopener noreferrer"&gt;Twin Phone&lt;/a&gt; resuelve la parte de llamadas y números virtuales; es en lo que trabajo, así que ya sabes con qué descuento leerme.&lt;/p&gt;

&lt;p&gt;¿Qué caso raro de numeración te ha roto un formulario? Los de Argentina y México me costaron una tarde cada uno — cuéntame el tuyo en los comentarios.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>spanish</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
    <item>
      <title>Llamar a un teléfono real desde el navegador: WebRTC, SIP y lo que nadie te cuenta de la facturación</title>
      <dc:creator>Ivan Labasok</dc:creator>
      <pubDate>Sun, 02 Aug 2026 23:24:42 +0000</pubDate>
      <link>https://dev.to/ivanindiv/llamar-a-un-telefono-real-desde-el-navegador-webrtc-sip-y-lo-que-nadie-te-cuenta-de-la-facturacion-3nmp</link>
      <guid>https://dev.to/ivanindiv/llamar-a-un-telefono-real-desde-el-navegador-webrtc-sip-y-lo-que-nadie-te-cuenta-de-la-facturacion-3nmp</guid>
      <description>&lt;h2&gt;
  
  
  En corto
&lt;/h2&gt;

&lt;p&gt;Conectar WebRTC con la red telefónica de verdad es engañosamente fácil de empezar y sorprendentemente difícil de terminar. El demo sale en una tarde. Lo que se lleva las semanas es lo otro: llamadas que se establecen y no se oyen, llamadas de cero segundos que sí aparecen facturadas, y un redondeo en el CDR que no coincide con lo que tú calculaste.&lt;/p&gt;

&lt;p&gt;Este artículo es el mapa que me habría gustado tener. Todo el código va contra APIs públicas y documentadas, con enlaces a la fuente en cada paso.&lt;/p&gt;




&lt;h2&gt;
  
  
  El stack en una frase
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Navegador (WebRTC) → credencial SIP del proveedor → PSTN, la red telefónica real.&lt;/strong&gt; En paralelo, la compra y configuración de números virtuales se hace por API REST.&lt;/p&gt;

&lt;p&gt;Tres piezas y ni una más:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;WebRTC&lt;/strong&gt; captura el micrófono y transporta el audio desde el navegador.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Un proveedor de telefonía&lt;/strong&gt; hace de puente entre WebRTC y la red telefónica tradicional. Aquí uso Telnyx para los ejemplos y Twilio donde el contraste ayuda.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tu backend&lt;/strong&gt;, que emite las credenciales efímeras. El navegador nunca ve tu API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Esa última frase es la única regla de seguridad de este artículo, y es innegociable: &lt;strong&gt;la API key vive en el servidor, siempre.&lt;/strong&gt; El navegador recibe un JWT de corta duración y nada más.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Autenticar el navegador sin filtrar tu API key
&lt;/h2&gt;

&lt;p&gt;En Telnyx son dos pasos, y conviene entender por qué son dos. Primero se crea una &lt;em&gt;telephony credential&lt;/em&gt; asociada a una Credential Connection — esto se hace una vez, no en cada llamada:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Telnyx&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telnyx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Telnyx&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Una vez por usuario (o una compartida, según tu modelo)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;credential&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telephonyCredentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX_CONNECTION_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;browser-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Y después, en cada sesión, se acuña el JWT que sí viaja al navegador:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// POST /api/voice-token — detrás de tu autenticación&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telephonyCredentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jwt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dos detalles que cuestan una tarde si no los sabes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;createToken&lt;/code&gt; devuelve &lt;code&gt;text/plain&lt;/code&gt;, no JSON.&lt;/strong&gt; El valor de retorno &lt;em&gt;es&lt;/em&gt; la cadena del JWT. Si escribes &lt;code&gt;response.data.token&lt;/code&gt;, obtienes &lt;code&gt;undefined&lt;/code&gt; y un error de autenticación cuatro capas más abajo, donde ya no se parece a la causa.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;El JWT vive 24 horas&lt;/strong&gt; — o menos, si la credencial padre tiene &lt;code&gt;expires_at&lt;/code&gt; y caduca antes. No hay parámetro documentado para acortar el TTL del token en sí; el único control real de caducidad está en &lt;code&gt;expires_at&lt;/code&gt; de la credencial. Si necesitas ventanas más cortas, tu palanca es la credencial, no el token.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developers.telnyx.com/development/webrtc/auth/telephony-credentials" rel="noopener noreferrer"&gt;telephony credentials&lt;/a&gt; · &lt;a href="https://developers.telnyx.com/api-reference/access-tokens/create-an-access-token" rel="noopener noreferrer"&gt;create an access token&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  2. La primera llamada
&lt;/h2&gt;

&lt;p&gt;Con el JWT en mano, el cliente es corto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;TelnyxRTC&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@telnyx/webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TelnyxRTC&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;login_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchTokenFromMyBackend&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// nunca hardcodeado&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telnyx.ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;listo para llamar&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telnyx.error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;telnyx.notification&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;callUpdate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;estado:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Los eventos van en el cliente, no en el objeto &lt;code&gt;call&lt;/code&gt;.&lt;/strong&gt; Es el error más repetido en los ejemplos que circulan por ahí: se ve mucho &lt;code&gt;call.on('telnyx.notification', ...)&lt;/code&gt;, y eso no existe. El SDK emite las notificaciones a nivel de sesión y te entrega el &lt;code&gt;call&lt;/code&gt; &lt;em&gt;dentro&lt;/em&gt; de la notificación. Si quieres un callback por llamada, el SDK sí ofrece la opción &lt;code&gt;onNotification&lt;/code&gt; dentro de &lt;code&gt;newCall()&lt;/code&gt; — pero es otra cosa distinta del listener de sesión.&lt;/p&gt;

&lt;p&gt;Los estados que verás en &lt;code&gt;notification.call.state&lt;/code&gt; son cadenas en minúscula, no un enum: &lt;code&gt;new&lt;/code&gt;, &lt;code&gt;trying&lt;/code&gt;, &lt;code&gt;requesting&lt;/code&gt;, &lt;code&gt;recovering&lt;/code&gt;, &lt;code&gt;ringing&lt;/code&gt;, &lt;code&gt;answering&lt;/code&gt;, &lt;code&gt;early&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;held&lt;/code&gt;, &lt;code&gt;hangup&lt;/code&gt;, &lt;code&gt;destroy&lt;/code&gt;, &lt;code&gt;purge&lt;/code&gt;. Los tres que de verdad usarás son &lt;code&gt;ringing&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt; y &lt;code&gt;hangup&lt;/code&gt;. Y ojo con &lt;code&gt;early&lt;/code&gt;: significa que hay media antes de que descuelguen — es el tono de llamada, no una conversación.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://github.com/team-telnyx/webrtc/blob/main/packages/js/docs/ts/classes/TelnyxRTC.md" rel="noopener noreferrer"&gt;TelnyxRTC&lt;/a&gt; · &lt;a href="https://developers.telnyx.com/development/webrtc/js-sdk/interfaces/inotification" rel="noopener noreferrer"&gt;INotification&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  3. «La llamada se establece pero no hay audio»
&lt;/h2&gt;

&lt;p&gt;Este es el bug que más gente busca y del que menos se escribe. El síntoma es desconcertante: el estado llega a &lt;code&gt;active&lt;/code&gt;, el contador de duración corre, la otra persona te oye perfectamente… y tú no oyes absolutamente nada.&lt;/p&gt;

&lt;p&gt;La causa, en el caso de Telnyx, es prosaica: &lt;strong&gt;el SDK no engancha el stream remoto a ningún elemento del DOM por su cuenta.&lt;/strong&gt; La negociación va bien de principio a fin —&lt;code&gt;getUserMedia&lt;/code&gt; concede el micrófono, el &lt;code&gt;RTCPeerConnection&lt;/code&gt; llega a &lt;code&gt;connected&lt;/code&gt;, el SDP se intercambia sin errores— y el &lt;code&gt;remoteStream&lt;/code&gt; acaba en un &lt;code&gt;MediaStream&lt;/code&gt; que no está conectado a ningún reproductor. No hay salida de audio. No hay error. No hay nada en consola.&lt;/p&gt;

&lt;p&gt;La solución es una línea, y hay que ponerla en dos sitios:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- En tu página --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;audio&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"remote-audio"&lt;/span&gt; &lt;span class="na"&gt;autoPlay&lt;/span&gt; &lt;span class="na"&gt;playsInline&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/audio&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newCall&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;destinationNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;+34911234567&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// E.164, siempre&lt;/span&gt;
  &lt;span class="na"&gt;callerNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;miNumeroVirtual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;remoteElement&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remote-audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;      &lt;span class="c1"&gt;// ← sin esto: silencio&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hay un &lt;code&gt;remoteElement&lt;/code&gt; por defecto a nivel de sesión (&lt;code&gt;client.remoteElement&lt;/code&gt;), pero si manejas más de una llamada a la vez &lt;strong&gt;necesitas el &lt;code&gt;remoteElement&lt;/code&gt; por llamada&lt;/strong&gt;: el de sesión es compartido y la última llamada gana. Lo mismo aplica al contestar una entrante — &lt;code&gt;call.answer({ remoteElement: ... })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Contraste útil: el SDK de Twilio reproduce su propia salida y no necesita esta fontanería. Si vienes de Twilio, esta es exactamente la diferencia que te va a morder.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://github.com/team-telnyx/webrtc/blob/main/packages/js/docs/ts/interfaces/ICallOptions.md" rel="noopener noreferrer"&gt;ICallOptions&lt;/a&gt; · &lt;a href="https://github.com/team-telnyx/webrtc/blob/main/packages/js/README.md" rel="noopener noreferrer"&gt;README con el ejemplo de llamadas concurrentes&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Audio unidireccional: cuando el problema sí es la red
&lt;/h2&gt;

&lt;p&gt;Si el &lt;code&gt;remoteElement&lt;/code&gt; está puesto y aun así hay silencio —o peor, silencio en un solo sentido— ya estás en territorio de ICE, y el diagnóstico cambia por completo.&lt;/p&gt;

&lt;p&gt;El caso clásico: alguien detrás de un NAT simétrico, o en una red corporativa que bloquea UDP. La señalización funciona (va por WebSocket, puerto 443, nadie la bloquea), la llamada aparece como establecida, y el RTP no llega a ningún lado. Sin TURN, esa llamada no tiene arreglo.&lt;/p&gt;

&lt;p&gt;La trampa concreta: &lt;strong&gt;si sobrescribes &lt;code&gt;iceServers&lt;/code&gt;, en la mayoría de SDKs estás &lt;em&gt;reemplazando&lt;/em&gt; la lista por defecto, no añadiéndote a ella.&lt;/strong&gt; Y esa lista por defecto suele traer los servidores TURN con credenciales del proveedor. Poner un &lt;code&gt;iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]&lt;/code&gt; que copiaste de un tutorial deja tu aplicación con STUN y sin TURN — perfecta en tu portátil, rota para todo el que esté detrás de un NAT simétrico. Si no vas a aportar tu propio TURN con credenciales, no toques &lt;code&gt;iceServers&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Cómo distinguir un caso del otro en treinta segundos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;peer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;instance&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// RTCPeerConnection subyacente&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;iceconnectionstatechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ICE:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;iceConnectionState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Y si quieres saber si de verdad entra audio:&lt;/span&gt;
&lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getStats&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;inbound-rtp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paquetes recibidos:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;packetsReceived&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;| bytes:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bytesReceived&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La lectura es directa: si &lt;code&gt;iceConnectionState&lt;/code&gt; se queda en &lt;code&gt;checking&lt;/code&gt; y no pasa a &lt;code&gt;connected&lt;/code&gt;, es un problema de red y necesitas TURN. Si llega a &lt;code&gt;connected&lt;/code&gt; pero &lt;code&gt;packetsReceived&lt;/code&gt; se queda clavado en cero, el media no fluye. Y si &lt;code&gt;packetsReceived&lt;/code&gt; sube alegremente pero el usuario no oye nada — vuelve a la sección 3, es el elemento de audio.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Llamadas de 0 segundos que sí te facturan
&lt;/h2&gt;

&lt;p&gt;Ahora la parte que duele en la cuenta de resultados.&lt;/p&gt;

&lt;p&gt;El primer descubrimiento incómodo: &lt;strong&gt;tu base de datos y el CDR del proveedor cuentan cosas distintas.&lt;/strong&gt; Un intento fallido puede quedar registrado como llamada. Un &lt;code&gt;duration&lt;/code&gt; de 0 no significa gratis. Y en telefonía tradicional existe una distinción que en las APIs modernas queda medio escondida: &lt;code&gt;duration&lt;/code&gt; es el tiempo total desde que se inició el intento, mientras que &lt;code&gt;billsec&lt;/code&gt; es solo el tiempo desde que alguien descolgó. Facturar contra el campo equivocado es un error clásico y sistemático.&lt;/p&gt;

&lt;p&gt;Twilio, para su honra, lo documenta con todas las letras: desde julio de 2021, &lt;strong&gt;cualquier llamada completada de menos de un segundo se redondea a un segundo y genera evento de facturación&lt;/strong&gt; (&lt;a href="https://www.twilio.com/en-us/changelog/upcoming-changes-to-programmable-voice-billing-for-outbound-call" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;). No existe la llamada de cero segundos gratuita.&lt;/p&gt;

&lt;p&gt;Hay un fenómeno con nombre propio que conviene conocer aunque no lo sufras: &lt;strong&gt;FAS, *false answer supervision&lt;/strong&gt;* — el operador marca la llamada como contestada antes de que nadie descuelgue, y el contador empieza a correr contra un teléfono que sigue sonando. Está bien documentado en el mundo SIP y es una de las razones por las que auditar tus propios CDR contra los del proveedor no es paranoia.&lt;/p&gt;

&lt;p&gt;La regla práctica: &lt;strong&gt;no te fíes de tu tabla, pregúntale al proveedor.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Telnyx: CDRs reales, filtrados por tipo de registro&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;detailRecords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;record_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;date_range&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;yesterday&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-created_at&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Twilio: registros de llamada con filtros&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;startTimeAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-08-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sospechosas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sospechosas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; llamadas "completadas" con duración 0`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ese &lt;code&gt;console.log&lt;/code&gt; es el que te va a estropear la tarde la primera vez que lo ejecutes.&lt;/p&gt;

&lt;p&gt;Docs: &lt;a href="https://developers.telnyx.com/api-reference/detail-records/search-detail-records" rel="noopener noreferrer"&gt;Telnyx detail records&lt;/a&gt; · &lt;a href="https://www.twilio.com/docs/voice/api/call-resource" rel="noopener noreferrer"&gt;Twilio Call resource&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Facturación por minuto: el redondeo que te cuesta dinero
&lt;/h2&gt;

&lt;p&gt;Aquí está el error de cálculo más común, y es de una línea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// MAL: asume prorrateo perfecto al segundo&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;coste&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duracionSeg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tarifaPorMinuto&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Casi ningún operador factura así. Lo normal es un &lt;strong&gt;incremento mínimo&lt;/strong&gt; (bloques de 6, 30 o 60 segundos) y a menudo también una &lt;strong&gt;duración mínima facturable&lt;/strong&gt;. Una llamada de 1 minuto y 5 segundos no cuesta 65 segundos: cuesta dos minutos.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BIEN: respeta el incremento y el mínimo de TU contrato&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;costeReal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duracionSeg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tarifaPorMinuto&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;incrementoSeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minimoSeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bloques&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duracionSeg&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;incrementoSeg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;incrementoSeg&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;facturables&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bloques&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;minimoSeg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;facturables&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;tarifaPorMinuto&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Los dos valores de esa función salen de tu contrato con el operador, no de este artículo. Búscalos, ponlos en configuración, y &lt;strong&gt;haz que tu estimación en cliente y tu cálculo en servidor usen la misma fórmula&lt;/strong&gt; — si divergen, el usuario ve un precio durante la llamada y otro en su historial, y esa es una incidencia de soporte que se repite eternamente.&lt;/p&gt;

&lt;p&gt;Y el detalle que menos gente comprueba: &lt;strong&gt;las tarifas se resuelven por prefijo, no por país.&lt;/strong&gt; El mismo indicador puede cubrir varios destinos con precios muy distintos —fijo contra móvil, o territorios con recargo que comparten código con la metrópoli—, así que un &lt;em&gt;longest-prefix match&lt;/em&gt; mal implementado te coloca en la tarifa equivocada de forma silenciosa. Audita tu tabla de tarifas contra la del operador prefijo a prefijo, no país a país.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. Comprar números por API: fácil comprar, difícil configurar
&lt;/h2&gt;

&lt;p&gt;Buscar y comprar es trivial:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;disponibles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availablePhoneNumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;country_code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phone_number_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;local&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;numberOrders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;phone_numbers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;phone_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;disponibles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;phone_number&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="na"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX_CONNECTION_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messaging_profile_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TELNYX_MESSAGING_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fíjate en que &lt;strong&gt;en el momento del pedido sí puedes mandar &lt;code&gt;connection_id&lt;/code&gt; y &lt;code&gt;messaging_profile_id&lt;/code&gt; juntos.&lt;/strong&gt; Es después cuando se separan, y esto sorprende a casi todo el mundo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Voz — endpoint base&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phoneNumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;connection_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CONNECTION_ID&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// SMS — endpoint DISTINTO&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;telnyx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phoneNumbers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messaging&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;phoneId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;messaging_profile_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MESSAGING_PROFILE_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;messaging_profile_id&lt;/code&gt; &lt;strong&gt;no está entre los campos aceptados&lt;/strong&gt; por &lt;code&gt;PATCH /phone_numbers/{id}&lt;/code&gt; — el cuerpo de esa petición admite &lt;code&gt;address_id&lt;/code&gt;, &lt;code&gt;billing_group_id&lt;/code&gt;, &lt;code&gt;connection_id&lt;/code&gt;, &lt;code&gt;customer_reference&lt;/code&gt;, &lt;code&gt;external_pin&lt;/code&gt;, &lt;code&gt;hd_voice_enabled&lt;/code&gt; y &lt;code&gt;tags&lt;/code&gt;, y nada más. Si lo mandas ahí, en el mejor de los casos se ignora en silencio y te quedas con un número que hace llamadas y no recibe SMS. Es un fallo particularmente cruel porque el número &lt;em&gt;funciona&lt;/em&gt;: solo falla la mitad que el usuario probará tres días después, cuando espere un código de verificación que nunca llega.&lt;/p&gt;

&lt;p&gt;De ahí salen las dos reglas que de verdad importan al aprovisionar:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Comprar cuesta dinero; configurar, no.&lt;/strong&gt; El pedido es la operación irreversible. Si la configuración posterior falla, reintenta con backoff, y si sigue fallando, deja el número marcado como degradado y avisa — pero no conviertas un fallo de configuración en un fallo de compra, porque acabas con un número comprado y facturado que nadie liberó.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verifica leyendo, no asumiendo.&lt;/strong&gt; Después de asignar el perfil de mensajería, vuelve a leerlo del proveedor y compáralo. Un &lt;code&gt;PATCH&lt;/code&gt; que devuelve 200 no garantiza el estado final que esperas.&lt;/p&gt;

&lt;p&gt;Y si algo se tuerce de verdad, libera el número explícitamente — en Twilio, borrar el recurso &lt;em&gt;es&lt;/em&gt; liberarlo, y el operador puede reasignarlo a otro cliente:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incomingPhoneNumbers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docs: &lt;a href="https://developers.telnyx.com/api-reference/phone-number-search/list-available-phone-numbers" rel="noopener noreferrer"&gt;buscar números&lt;/a&gt; · &lt;a href="https://developers.telnyx.com/api-reference/phone-number-orders/create-a-number-order" rel="noopener noreferrer"&gt;crear pedido&lt;/a&gt; · &lt;a href="https://developers.telnyx.com/api-reference/phone-number-configurations/update-a-phone-number" rel="noopener noreferrer"&gt;actualizar número&lt;/a&gt; · &lt;a href="https://developers.telnyx.com/api-reference/number-settings/update-the-messaging-profile-andor-messaging-product-of-a-phone-number" rel="noopener noreferrer"&gt;perfil de mensajería&lt;/a&gt; · &lt;a href="https://www.twilio.com/docs/phone-numbers/api/incomingphonenumber-resource" rel="noopener noreferrer"&gt;liberar número en Twilio&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Entonces… ¿lo construyo o no?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Constrúyelo&lt;/strong&gt; si la telefonía es tu producto, necesitas control del enrutado, o tienes volumen suficiente para que la complejidad se pague sola.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No lo construyas&lt;/strong&gt; si lo que quieres es «que mis usuarios puedan llamar» como una función más del producto. El coste oculto nunca es el código del demo: es el ciclo de vida. CDR, reintentos, cumplimiento normativo por país, tarifas por prefijo, el número que se compró y no se configuró. Eso es lo que se come los meses.&lt;/p&gt;

&lt;p&gt;Si estás en el segundo grupo y lo que necesitas es simplemente un segundo número o llamadas internacionales sin montar nada, hay productos que ya resolvieron todo esto — &lt;a href="https://www.twin-phone.com/es" rel="noopener noreferrer"&gt;Twin Phone&lt;/a&gt; es uno de ellos, y es en lo que trabajo, así que tómalo con el descuento que corresponda.&lt;/p&gt;




&lt;h2&gt;
  
  
  Checklist para llevarte
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;La API key vive en el servidor. El navegador recibe un JWT efímero y nada más.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;createToken&lt;/code&gt; devuelve &lt;strong&gt;texto plano&lt;/strong&gt;, no JSON.&lt;/li&gt;
&lt;li&gt;Los eventos de Telnyx se escuchan en el &lt;strong&gt;cliente&lt;/strong&gt;, no en el objeto &lt;code&gt;call&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sin &lt;code&gt;remoteElement&lt;/code&gt;, la llamada conecta y no se oye nada. Uno por llamada si hay concurrencia.&lt;/li&gt;
&lt;li&gt;No sobrescribas &lt;code&gt;iceServers&lt;/code&gt; salvo que aportes tu propio TURN: reemplaza, no añade.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iceConnectionState&lt;/code&gt; + &lt;code&gt;packetsReceived&lt;/code&gt; distinguen «problema de red» de «problema de DOM» en treinta segundos.&lt;/li&gt;
&lt;li&gt;Factura contra el tiempo de conversación, no contra el tiempo de intento.&lt;/li&gt;
&lt;li&gt;Respeta el incremento y el mínimo facturable de tu contrato. Misma fórmula en cliente y servidor.&lt;/li&gt;
&lt;li&gt;Resuelve tarifas por &lt;strong&gt;prefijo&lt;/strong&gt;, no por país.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;connection_id&lt;/code&gt; y &lt;code&gt;messaging_profile_id&lt;/code&gt; van juntos al pedir, &lt;strong&gt;separados&lt;/strong&gt; al reconfigurar.&lt;/li&gt;
&lt;li&gt;Después de configurar, vuelve a leer del proveedor y compara.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;¿Has peleado con VoIP y te has topado con otros fantasmas? Cuéntamelo en los comentarios — colecciono formas creativas en las que la telefonía se rompe.&lt;/p&gt;

</description>
      <category>webrtc</category>
      <category>spanish</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Calling Abroad Without an App: A Practical Guide to Browser-Based International Calls</title>
      <dc:creator>Ivan Labasok</dc:creator>
      <pubDate>Mon, 01 Jun 2026 20:55:15 +0000</pubDate>
      <link>https://dev.to/ivanindiv/calling-abroad-without-an-app-a-practical-guide-to-browser-based-international-calls-31h4</link>
      <guid>https://dev.to/ivanindiv/calling-abroad-without-an-app-a-practical-guide-to-browser-based-international-calls-31h4</guid>
      <description>&lt;p&gt;Staying in touch across borders used to come down to two bad options: a phone bill that made you wince, or yet another messaging app that only works if the person on the other end installs it too. Neither helps much when you just need to reach a relative, a client, or a supplier sitting next to an ordinary phone in another country.&lt;/p&gt;

&lt;p&gt;There is a third option that has quietly become practical: calling straight from your web browser. No SIM card, no downloads, no contract. You open a page, sign in, and dial. Here is a plain look at how it works, when it makes sense, and what to check before you depend on it.&lt;/p&gt;

&lt;p&gt;What "calling from the browser" actually means&lt;/p&gt;

&lt;p&gt;Modern browsers can carry voice over the internet using a built-in technology called WebRTC — the same thing that powers in-browser video meetings. On its own, that only connects two browsers. The useful part is when a service bridges that internet leg onto the real telephone network, so the person you are calling receives a normal call on their landline or mobile. They do not need an app, an account, or anything installed. To them, the phone simply rings.&lt;/p&gt;

&lt;p&gt;That distinction matters more than it sounds. App-to-app calls — the kind most messengers offer — only work if both sides use the same app. Browser-to-phone calling reaches anyone who has a phone number, which is still almost everyone.&lt;/p&gt;

&lt;p&gt;Why it tends to be &lt;em&gt;cheaper&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Traditional plans charge you every month whether you make international calls or not, and roaming rates can be brutal. The browser-based model usually flips that to pay-as-you-go: you top up a small balance and pay per minute only for the calls you actually make, often from a few cents a minute.&lt;/p&gt;

&lt;p&gt;Services like &lt;a href="https://www.twin-phone.com" rel="noopener noreferrer"&gt;Twin Phone&lt;/a&gt;, for example, start from around 0.02 USD per minute with no monthly fee, and show the exact per-country rate before you dial — so there is no bill shock after the fact. For someone who makes a handful of international calls a month, paying only for those minutes is dramatically cheaper than a flat subscription.&lt;/p&gt;

&lt;p&gt;It works on whatever you already own&lt;/p&gt;

&lt;p&gt;Because everything runs in the browser, the same setup works on Windows, macOS, Android, and iPhone. There is nothing to install and nothing taking up storage on your phone, and you are always on the latest version because there is no app to update. If you switch from your laptop to your phone, you just open the page again and sign in.&lt;/p&gt;

&lt;p&gt;A typical first call, step by step&lt;/p&gt;

&lt;p&gt;Create a free account — it takes seconds and does not require a card up front.&lt;br&gt;
Add a small amount of credit to your balance.&lt;br&gt;
Check the rate for the country you are calling so you know the price before you dial.&lt;br&gt;
Enter the full international number and talk.&lt;/p&gt;

&lt;p&gt;That is the whole flow. The friction that used to come with international calling — buying a calling card, topping up a SIM, configuring a VoIP client — mostly disappears.&lt;/p&gt;

&lt;p&gt;What to check before you rely on it&lt;/p&gt;

&lt;p&gt;A few practical things separate a service you can trust from one that frustrates you:&lt;/p&gt;

&lt;p&gt;Transparent per-country pricing, shown before the call rather than buried in a statement afterward.&lt;/p&gt;

&lt;p&gt;Real termination to the phone network, so the recipient gets an ordinary call and does not need to install anything.&lt;/p&gt;

&lt;p&gt;Clear audio on a stable connection. Browser calling is only as good as your internet at that moment, so a decent Wi-Fi or mobile-data connection matters.&lt;/p&gt;

&lt;p&gt;No lock-in. Pay-as-you-go credit you control beats a contract you have to cancel, especially if your calling needs are seasonal.&lt;/p&gt;

&lt;p&gt;When this is the right tool&lt;/p&gt;

&lt;p&gt;Browser-based calling fits best when you make occasional international calls, when you need to reach someone who will not install an app, when you work across time zones, or when you are traveling and do not want roaming charges. It is not meant to replace a full business phone system with queues and extensions, but for reaching a real phone number cheaply and quickly, it is hard to beat.&lt;/p&gt;

&lt;p&gt;The short version: if you have a browser and a few dollars of credit, you can call almost any phone in the world today, without installing a thing. For anyone tired of monthly plans and app-only calls, it is worth trying once — the first call usually answers the question better than any explanation can.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>voip</category>
      <category>tutorial</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
