<?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: Javier Sivack</title>
    <description>The latest articles on DEV Community by Javier Sivack (@javiersivack).</description>
    <link>https://dev.to/javiersivack</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%2F604095%2F7d6d7a35-1a64-42b1-be86-f032caa85566.jpeg</url>
      <title>DEV Community: Javier Sivack</title>
      <link>https://dev.to/javiersivack</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/javiersivack"/>
    <language>en</language>
    <item>
      <title>El refresh token de Mercado Libre es de un solo uso: cómo no desconectar a tus vendedores</title>
      <dc:creator>Javier Sivack</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:34:41 +0000</pubDate>
      <link>https://dev.to/javiersivack/el-refresh-token-de-mercado-libre-es-de-un-solo-uso-como-no-desconectar-a-tus-vendedores-137g</link>
      <guid>https://dev.to/javiersivack/el-refresh-token-de-mercado-libre-es-de-un-solo-uso-como-no-desconectar-a-tus-vendedores-137g</guid>
      <description>&lt;p&gt;&lt;em&gt;Para devs que conectan cuentas de vendedores con OAuth y ven &lt;code&gt;invalid_grant&lt;/code&gt; en los logs.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Conectar una cuenta de Mercado Libre por OAuth lleva una tarde. El problema aparece días&lt;br&gt;
después: un vendedor que "se desconectó solo", una sincronización que dejó de andar a las 3 de&lt;br&gt;
la mañana, un &lt;code&gt;invalid_grant&lt;/code&gt; que nadie entiende. Muchas veces la causa es la misma: el refresh&lt;br&gt;
token se usó dos veces.&lt;/p&gt;
&lt;h2&gt;
  
  
  Las dos reglas
&lt;/h2&gt;

&lt;p&gt;Según la documentación de autenticación de Mercado Libre:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;El &lt;strong&gt;access token dura 6 horas&lt;/strong&gt;. Después hay que renovarlo.&lt;/li&gt;
&lt;li&gt;El &lt;strong&gt;refresh token es de un solo uso&lt;/strong&gt;. Cada renovación te devuelve uno nuevo y el anterior
deja de servir.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(Para recibir refresh token, tu aplicación del DevCenter tiene que tenerlo activado.)&lt;/p&gt;

&lt;p&gt;La renovación es un POST a &lt;code&gt;https://api.mercadolibre.com/oauth/token&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;grant_type=refresh_token
client_id=...
client_secret=...
refresh_token=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;La respuesta trae un &lt;code&gt;access_token&lt;/code&gt; nuevo &lt;strong&gt;y un &lt;code&gt;refresh_token&lt;/code&gt; nuevo&lt;/strong&gt;. Si no guardás el&lt;br&gt;
nuevo, la próxima renovación va a fallar. Ese es el primer error, y el más fácil de ver.&lt;/p&gt;

&lt;p&gt;El segundo es más traicionero.&lt;/p&gt;
&lt;h2&gt;
  
  
  La carrera
&lt;/h2&gt;

&lt;p&gt;Imaginá dos pedidos del mismo vendedor que salen casi juntos (un aviso de orden y un cron, por&lt;br&gt;
ejemplo) justo cuando el token venció:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pedido A: token vencido → renueva con R1 → recibe R2 → guarda R2
Pedido B: token vencido → renueva con R1 → invalid_grant
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;R1 ya se usó. Según cómo esté escrito tu código, el pedido B:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;marca la cuenta como desconectada, aunque R2 quedó bien guardado,&lt;/li&gt;
&lt;li&gt;o guarda datos viejos encima de R2 y ahí sí no queda ningún token que sirva,&lt;/li&gt;
&lt;li&gt;o reintenta para siempre con un token que nunca va a andar.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;En cualquiera de los tres casos el vendedor queda afuera, aunque nadie hizo nada mal. Y casi&lt;br&gt;
nunca pasa en tus pruebas, porque ahí hay un pedido a la vez. Pasa en producción, con tráfico.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. Renovar antes de que venza
&lt;/h2&gt;

&lt;p&gt;No esperes al 401. Guardá cuándo vence el token (&lt;code&gt;expires_in&lt;/code&gt; viene en segundos) y renová con&lt;br&gt;
margen, antes de cada pedido:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MARGIN_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 1 hora&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;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&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;MARGIN_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Con una hora de margen, un pedido largo o un reloj un poco desfasado no usan un token vencido.&lt;br&gt;
Si igual llega un 401, renová una sola vez y repetí el pedido. Si vuelve a dar 401, no insistas.&lt;/p&gt;
&lt;h2&gt;
  
  
  2. Una sola renovación a la vez por vendedor
&lt;/h2&gt;

&lt;p&gt;Dentro de un proceso alcanza con guardar la renovación en curso y que los demás la esperen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;inFlight&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Tokens&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;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;refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&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;running&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inFlight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&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;running&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;running&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// ya hay una: esperala&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;doRefresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;finally&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;inFlight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;inFlight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;job&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 dentro de &lt;code&gt;doRefresh&lt;/code&gt;, &lt;strong&gt;releé el token antes de renovar&lt;/strong&gt;: otra renovación pudo terminar&lt;br&gt;
mientras esperabas. Si el que está guardado ya es nuevo, usalo y no gastes el refresh token.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Con varias instancias, el lock va en la base
&lt;/h2&gt;

&lt;p&gt;El &lt;code&gt;Map&lt;/code&gt; de arriba vive en la memoria de un proceso. En Vercel, en Lambda o con dos réplicas&lt;br&gt;
detrás de un balanceador, cada instancia tiene el suyo y la carrera vuelve.&lt;/p&gt;

&lt;p&gt;Si guardás los tokens en Postgres, un advisory lock por vendedor lo resuelve sin tablas extra:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transaction&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="nx"&gt;tx&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="c1"&gt;// Bloquea solo a este vendedor, hasta el final de la transacción.&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SELECT pg_advisory_xact_lock($1::bigint)&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;userId&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// releer dentro del lock&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isFresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;        &lt;span class="c1"&gt;// otra instancia ya renovó&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;refreshWithMeli&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&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;saveTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&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;La segunda instancia espera el lock, relee, ve el token nuevo y no llama a Mercado Libre. La&lt;br&gt;
transacción queda abierta durante la llamada a &lt;code&gt;/oauth/token&lt;/code&gt;, pero es una sola por vendedor&lt;br&gt;
cada varias horas.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;invalid_grant&lt;/code&gt; no se arregla reintentando
&lt;/h2&gt;

&lt;p&gt;Si la renovación devuelve &lt;code&gt;invalid_grant&lt;/code&gt;, el refresh token ya no sirve: se usó, venció o el&lt;br&gt;
vendedor le quitó el permiso a tu aplicación. Reintentar no sirve. Lo correcto es:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Marcar la cuenta como "hay que volver a conectar" y dejar de llamar a la API con ella.&lt;/li&gt;
&lt;li&gt;Avisarle al vendedor (o a tu equipo) con el link para conectar de nuevo.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Si no lo marcás, cada cron y cada aviso vuelven a intentar, llenan los logs y nadie se entera&lt;br&gt;
de que hay una cuenta caída.&lt;/p&gt;

&lt;p&gt;Ojo: esto es seguro solo si ya evitaste la carrera (puntos 2 y 3). Si no, el &lt;code&gt;invalid_grant&lt;/code&gt; del&lt;br&gt;
pedido B de arriba te haría marcar como caída una cuenta que estaba bien.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resumen
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problema&lt;/th&gt;
&lt;th&gt;Qué hacer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;El token vence a las 6 h&lt;/td&gt;
&lt;td&gt;Renovar antes, con margen, no después del 401&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;El refresh token nuevo se pierde&lt;/td&gt;
&lt;td&gt;Guardar siempre el que vuelve en cada renovación&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dos renovaciones en el mismo proceso&lt;/td&gt;
&lt;td&gt;Una sola en curso por vendedor; el resto la espera&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dos renovaciones en instancias distintas&lt;/td&gt;
&lt;td&gt;Lock por vendedor en la base y releer adentro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;invalid_grant&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Marcar la cuenta para reconectar; no reintentar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;Armé todo esto en &lt;strong&gt;VentaKit&lt;/strong&gt;, un starter de Next.js + TypeScript para la API de Mercado&lt;br&gt;
Libre, con tests para cada caso: &lt;a href="https://github.com/javiersivack/ventakit" rel="noopener noreferrer"&gt;https://github.com/javiersivack/ventakit&lt;/a&gt; (gratis, MIT).&lt;br&gt;
Además de los tokens, trae los avisos de órdenes a prueba de duplicados y desorden. La versión&lt;br&gt;
Pro suma Postgres con el lock entre instancias, una conciliación por cron que recupera avisos&lt;br&gt;
perdidos y un panel que muestra qué vendedores hay que reconectar:&lt;br&gt;
&lt;a href="https://ventakit-site.vercel.app/?utm_source=devto&amp;amp;utm_campaign=art1" rel="noopener noreferrer"&gt;https://ventakit-site.vercel.app/?utm_source=devto&amp;amp;utm_campaign=art1&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Lo probé contra la API real de Mercado Libre con usuarios de prueba: en la demo, el token&lt;br&gt;
venció sin que nadie lo usara y la siguiente corrida del cron lo renovó sola, sin desconectar&lt;br&gt;
a nadie.&lt;/p&gt;

&lt;p&gt;VentaKit está hecho con Claude, una IA, como compañero de programación: yo lo dirijo, lo pruebo&lt;br&gt;
contra Mercado Libre real y respondo por él.&lt;/p&gt;

&lt;p&gt;¿Preferís no integrarlo vos? Hago integraciones con Mercado Libre, Mercado Pago y otras APIs&lt;br&gt;
para equipos y agencias: &lt;a href="https://javiersivack.com/es?utm_source=devto&amp;amp;utm_campaign=ventakit-art1#contact" rel="noopener noreferrer"&gt;https://javiersivack.com/es?utm_source=devto&amp;amp;utm_campaign=ventakit-art1#contact&lt;/a&gt;&lt;/p&gt;




</description>
      <category>mercadolibre</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>oauth</category>
    </item>
    <item>
      <title>Webhooks de Mercado Pago que no pierden pagos (ni regalan accesos)</title>
      <dc:creator>Javier Sivack</dc:creator>
      <pubDate>Wed, 28 Oct 2026 13:59:38 +0000</pubDate>
      <link>https://dev.to/javiersivack/webhooks-de-mercado-pago-que-no-pierden-pagos-ni-regalan-accesos-1ae7</link>
      <guid>https://dev.to/javiersivack/webhooks-de-mercado-pago-que-no-pierden-pagos-ni-regalan-accesos-1ae7</guid>
      <description>&lt;p&gt;&lt;em&gt;Para devs que ya integraron Checkout Pro y quieren dormir tranquilos.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Integrar el botón de pago de Mercado Pago lleva una tarde. Los problemas aparecen después,&lt;br&gt;
en producción: un pago aprobado que nunca liberó el producto, un cliente al que le llegaron&lt;br&gt;
dos emails de bienvenida, una orden que volvió a "pendiente" sola. Casi siempre la causa está&lt;br&gt;
en el endpoint que recibe los webhooks.&lt;/p&gt;

&lt;p&gt;Estos son los seis errores que más veo y cómo evitarlos.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. No verificar la firma
&lt;/h2&gt;

&lt;p&gt;Tu endpoint de webhooks es público: cualquiera puede mandarle un POST que diga "el pago 123&lt;br&gt;
está aprobado". Mercado Pago firma cada notificación con el header &lt;code&gt;x-signature&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;x-signature: ts=1704908010,v1=618c85345248dd820d5fd456117c2ab2...
x-request-id: 3b6d3d0e-...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;v1&lt;/code&gt; es un HMAC-SHA256, calculado con la clave secreta de tu aplicación, de este texto:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;id:&amp;lt;data.id&amp;gt;;request-id:&amp;lt;x-request-id&amp;gt;;ts:&amp;lt;ts&amp;gt;;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tres detalles que suelen fallar:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;data.id&lt;/code&gt; sale de la &lt;strong&gt;query string&lt;/strong&gt; (&lt;code&gt;?data.id=123&amp;amp;type=payment&lt;/code&gt;) y va en minúsculas.&lt;/li&gt;
&lt;li&gt;Si falta alguno de los valores, esa parte se omite entera (incluido su &lt;code&gt;;&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Compará con una función de tiempo constante (&lt;code&gt;crypto.timingSafeEqual&lt;/code&gt;), no con &lt;code&gt;===&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;manifest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`id:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;dataId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;;request-id:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;requestId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;;ts:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ts&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;secret&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;manifest&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&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;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Si la firma no coincide, respondé 401.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Confiar en el cuerpo del webhook
&lt;/h2&gt;

&lt;p&gt;Aun con la firma verificada, el cuerpo solo te dice &lt;strong&gt;qué&lt;/strong&gt; cambió, no &lt;strong&gt;cómo quedó&lt;/strong&gt;. Tomá el&lt;br&gt;
&lt;code&gt;data.id&lt;/code&gt; y consultá el pago:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET https://api.mercadopago.com/v1/payments/{id}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Esa respuesta es la fuente de verdad: estado, monto, moneda y &lt;code&gt;external_reference&lt;/code&gt;. Como&lt;br&gt;
efecto secundario, un webhook repetido o viejo se vuelve inofensivo, porque siempre leés el&lt;br&gt;
estado actual.&lt;/p&gt;
&lt;h2&gt;
  
  
  3. Confiar en la URL de retorno
&lt;/h2&gt;

&lt;p&gt;Cuando el comprador vuelve a tu sitio, Mercado Pago agrega parámetros como&lt;br&gt;
&lt;code&gt;?status=approved&amp;amp;payment_id=...&lt;/code&gt;. Cualquiera puede escribir esa URL a mano. La página de&lt;br&gt;
retorno tiene que preguntarle el estado a &lt;strong&gt;tu&lt;/strong&gt; servidor (que lo actualizó el webhook) y&lt;br&gt;
mostrar "esperando confirmación" mientras siga pendiente.&lt;/p&gt;
&lt;h2&gt;
  
  
  4. Disparar efectos por cada notificación
&lt;/h2&gt;

&lt;p&gt;Mercado Pago reintenta si no respondés a tiempo (tiene un timeout de 22 segundos), y a veces&lt;br&gt;
manda dos notificaciones casi juntas. Si tu código hace "llegó un webhook de pago aprobado →&lt;br&gt;
mandar email", vas a mandar emails duplicados.&lt;/p&gt;

&lt;p&gt;La regla es disparar efectos por &lt;strong&gt;cambio de estado&lt;/strong&gt;, no por notificación. En la base de&lt;br&gt;
datos se hace con un compare-and-set:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'paid'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payment_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- rowCount = 1 → este proceso hizo el cambio: dispará el efecto&lt;/span&gt;
&lt;span class="c1"&gt;-- rowCount = 0 → otro webhook ya lo hizo: no hagas nada&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Aceptar cualquier transición
&lt;/h2&gt;

&lt;p&gt;Las notificaciones no llegan necesariamente en orden. Si procesás un &lt;code&gt;pending&lt;/code&gt; que llega&lt;br&gt;
después del &lt;code&gt;approved&lt;/code&gt;, la orden vuelve a pendiente y le cortás el acceso a alguien que pagó.&lt;br&gt;
Definí qué transiciones son válidas y descartá las demás:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Desde&lt;/th&gt;
&lt;th&gt;Puede pasar a&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;pending&lt;/td&gt;
&lt;td&gt;paid, failed, in_dispute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;failed&lt;/td&gt;
&lt;td&gt;paid (el comprador reintentó con otra tarjeta)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;paid&lt;/td&gt;
&lt;td&gt;in_dispute, refunded, charged_back&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;refunded, charged_back&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  6. No validar el monto
&lt;/h2&gt;

&lt;p&gt;Que el pago esté aprobado no significa que sea el pago que esperabas. Pasa más de lo que&lt;br&gt;
parece: el precio llegó desde el navegador, cambiaste el precio y quedó una preferencia vieja&lt;br&gt;
dando vueltas, o un link de pago se reutilizó para otra orden. A mí me pasó probando: la orden&lt;br&gt;
era de 1.500 pesos argentinos y la cuenta de prueba era de Uruguay, así que el pago aprobado&lt;br&gt;
llegó por 1.500 pesos uruguayos. Antes de marcar la orden como&lt;br&gt;
pagada, compará &lt;code&gt;transaction_amount&lt;/code&gt; y &lt;code&gt;currency_id&lt;/code&gt; con lo que esperabas. Si no coinciden,&lt;br&gt;
dejá la orden en revisión en vez de dar acceso.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: qué responder
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situación&lt;/th&gt;
&lt;th&gt;Respuesta&lt;/th&gt;
&lt;th&gt;Por qué&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Firma inválida&lt;/td&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;No es de Mercado Pago&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pago inexistente (404 de la API)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;La "notificación de prueba" del panel usa un id inventado; reintentar no sirve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;La API falla o tu token es inválido&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;Mercado Pago reintenta y no perdés la notificación&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Procesado (cambie o no el estado)&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;Listo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ojo con el último caso: si tu access token está mal configurado y respondés 200, perdés&lt;br&gt;
todas las notificaciones sin enterarte.&lt;/p&gt;




&lt;p&gt;Armé todo esto en &lt;strong&gt;PagoKit&lt;/strong&gt;, un starter de Next.js + TypeScript con tests para cada caso:&lt;br&gt;
&lt;a href="https://github.com/javiersivack/pagokit" rel="noopener noreferrer"&gt;https://github.com/javiersivack/pagokit&lt;/a&gt; (gratis, MIT). La versión Pro suma suscripciones,&lt;br&gt;
reembolsos, Postgres y un outbox con reintentos para que los efectos tampoco se pierdan:&lt;br&gt;
&lt;a href="https://pagokit-site.vercel.app" rel="noopener noreferrer"&gt;https://pagokit-site.vercel.app&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;¿Preferís no integrarlo vos? Hago integraciones con Mercado Pago y otras APIs para equipos y agencias: &lt;a href="https://javiersivack.com/es#contact" rel="noopener noreferrer"&gt;https://javiersivack.com/es#contact&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
