Nuestra integración con LinkedIn funcionó durante semanas. Después, sin ningún deploy de nuestro lado y sin un solo correo de LinkedIn, cada publicación empezó a fallar con un HTTP 426 y un cuerpo que contenía NONEXISTENT_VERSION. Nada había cambiado en nuestro código. Esa es la parte confusa, y es la razón por la que este error le cuesta una tarde a mucha gente.
Este es un relato corto de lo que está pasando en realidad, escrito desde la experiencia de arreglarlo en producción y no desde la documentación.
Qué significa realmente el 426 NONEXISTENT_VERSION
La API REST de LinkedIn se versiona con un header obligatorio en absolutamente cada request. No es un segmento de la URL, ni un parámetro de query, ni un header Accept: es un header propio:
- LinkedIn-Version: 202606
- X-Restli-Protocol-Version: 2.0.0
La versión es una cadena YYYYMM. LinkedIn publica una nueva aproximadamente cada mes y da soporte a cada una durante unos doce meses desde su lanzamiento. Cuando tu versión sale de esa ventana no queda deprecada con un aviso: deja de existir. A partir de ahí, cada request devuelve un 426 Upgrade Required con NONEXISTENT_VERSION en el cuerpo.
El segundo header no tiene nada que ver con el versionado y atrapa a la gente por separado. X-Restli-Protocol-Version identifica la serialización del protocolo Rest.li, no la versión de la API, y su valor es un 2.0.0 fijo que no cambia con los releases. Omitirlo produce fallos distintos y menos obvios: normalmente un error de request mal formada sobre cómo codificaste tus parámetros, lo que te manda a revisar el cuerpo JSON en lugar de los headers.
Así que el fallo lo dispara el tiempo, no un deploy. Tu código es idéntico byte a byte al del día en que funcionaba. Ese desajuste entre «no cambié nada» y «todo está roto» es la razón entera por la que vale la pena escribir esto: derrota el primer instinto de depuración que todos tenemos, que es mirar el diff.
Por qué no recibes ningún aviso
No hay ningún aviso de deprecación en tiempo de ejecución dentro de la respuesta mientras tu versión sigue siendo válida. No hay periodo de degradación suave, ni header Sunset con cuenta atrás, ni una tasa de error que suba poco a poco antes del precipicio. El header está dentro de la ventana soportada o no lo está, y la transición entre esos dos estados ocurre en una fecha, en silencio, del lado de LinkedIn.
El resultado es una función escalón. Tu tasa de error en esa plataforma pasa de cero a cien por ciento entre dos requests consecutivas. Si tus alertas se basan en un umbral sobre el total de jobs fallidos en todas las plataformas, una integración que se apaga por completo puede no llegar a cruzar ese umbral: el resto de plataformas siguen funcionando y el agregado solo se ve algo peor, no roto.
Si tu integración es una funcionalidad secundaria que usan unos pocos usuarios, lo más probable es que te enteres por un usuario y no por tu monitoreo. Así fue como nos enteramos nosotros.
La solución, y la solución que importa
La solución inmediata es subir el header a una versión vigente. Eso toma un minuto y no es lo interesante. La solución que importa es asegurarte de que la próxima subida sea un cambio de una línea y no una búsqueda por todo tu código.
La versión estaba hardcodeada en línea en cada punto de llamada. Ese es el bug de verdad: la cadena obsoleta era solo el síntoma. Es una forma fácil de terminar programando, porque cada punto de llamada se escribe en un momento distinto y copiar el bloque de headers del de arriba es el camino de menor resistencia. Para cuando importa, tienes la misma cadena mágica en el inicializador de subida de imágenes, en la subida binaria, en la llamada de publicación y en lo que agregaste el mes pasado, y se te va a escapar alguna.
- Define la versión una sola vez, cerca del inicio del adaptador, en una constante con nombre y un comentario que diga que caduca y más o menos cuándo.
- Referencia esa constante desde cada request que hable con /rest/*. Después haz un grep del literal y asegúrate de que el único resultado sea la constante.
- Trata cualquier 426 en tus logs como «sube la constante», no como una caída que hay que depurar. Haz match por el nombre del error, no solo por el código de estado: un 426 de cualquier otra cosa significa algo distinto.
- Pon un recordatorio en el calendario a unos nueve meses vista. Es poco glamoroso y es el único mecanismo que funciona de verdad, porque no hay ninguna señal que alertar hasta que ya se rompió.
Una versión más fuerte de la misma idea, si la integración es crítica para ti: corre un sintético programado que haga un GET autenticado barato contra un endpoint /rest/* y alerte ante un 426. Convierte una bomba de tiempo silenciosa en una alerta real, y cuesta una request al día.
Un detalle que conviene saber ya que estás aquí: no todos los endpoints de LinkedIn quieren el header. Los endpoints antiguos /v2/*, incluido /v2/userinfo que probablemente uses para traer el perfil del miembro conectado, son anteriores al esquema de versionado y no aceptan ningún header de versión. Solo los endpoints /rest/* lo hacen. Enviarlo donde no se espera es inofensivo; olvidarlo donde es obligatorio es un 426. Esto también explica por qué un token que funciona perfecto para leer un perfil puede fallar en cada publicación: las dos llamadas van a endpoints con reglas distintas.
Las otras trampas con forma de versión en la misma API
Una vez que envías el header correctamente, hay dos cosas más de esta API con el mismo carácter: código que se ve correcto y falla ante una condición que no probaste.
La primera es el identificador del post creado. Una publicación exitosa devuelve la URN del nuevo post en un header de respuesta x-restli-id, no en el cuerpo. Si parseas el cuerpo buscando un id no vas a encontrar nada útil, vas a concluir que la llamada falló a medias y quizá reintentes un post que ya salió. Lee el header.
La segunda es la cantidad de imágenes. LinkedIn tiene dos formas de contenido distintas para el media adjunto y la frontera entre ellas no está donde imaginarías. Una sola imagen va por el tipo de contenido singular. Dos o más van por multiImage, que está documentado como aceptar entre 2 y 20 imágenes. Manda una sola imagen por multiImage y recibes un 422 diciéndote que el número de imágenes encontradas está fuera de rango, que es un buen mensaje de error y aun así sorprende la primera vez, porque un array de un elemento es la forma obvia de escribirlo. Ramifica según la cantidad.
La subida de imágenes en sí es un baile de tres pasos fácil de hacer a medias: POST a /rest/images con una acción initializeUpload para registrar la subida, que devuelve tanto una URL de subida como una URN de asset; PUT de los bytes crudos a esa URL de subida; y después referenciar la URN del asset en el cuerpo del post. Lo que va en el post es la URN, no la URL, y la URL de subida es de un solo uso.
La segunda sorpresa: no hay refresh token
Hay un problema relacionado que atrapa a la gente más tarde y conviene diseñar pensando en él desde ahora. El flujo estándar de authorization code de LinkedIn no emite un refresh token para la mayoría de las aplicaciones. El refresco programático está detrás de una aprobación aparte que la mayoría de los productos pequeños no tienen.
La consecuencia práctica: los access tokens caducan a los sesenta días aproximadamente y la única forma de obtener uno nuevo es que la persona vuelva a pasar por la pantalla de consentimiento. No hay ningún job en segundo plano que pueda arreglarlo por ellos, ni esquema ingenioso de rotación de tokens que lo esquive.
Eso no es un bug que puedas sortear con código, así que constrúyelo asumiéndolo:
- Guarda la fecha de expiración del token al recibirlo y trátala como una fecha límite real, no como un caso borde. La respuesta del token te da un expires_in; conviértelo a un timestamp absoluto en el momento en que lo recibes.
- Pasa la cuenta a un estado explícito de «necesita reconexión» antes de que caduque, no después de que ya falló una publicación. Un barrido que mire unos días hacia adelante es suficiente.
- Haz que reconectar reautorice sobre el registro de cuenta existente en lugar de crear un duplicado; si no, cada reconexión deja atrás una fila muerta, y los posts programados que quedaron encolados contra la fila vieja apuntan en silencio a una credencial obsoleta.
- Dile al usuario por qué. «LinkedIn te obliga a reconectar cada 60 días» es una queja que se puede responder; un fallo silencioso no.
- Asegúrate de que un intento de refresco que es estructuralmente imposible falle de forma ruidosa en la capa correcta. Un adaptador que no puede refrescar debería decirlo explícitamente, para que la cuenta se marque para reconexión en vez de reintentarse en bucle para siempre.
El modo de fallo que estás evitando es el silencioso: una cuenta que en tu interfaz se ve conectada y que en realidad tira al piso cada post programado. Entre el precipicio de versión y la expiración del token, LinkedIn te da dos formas independientes de llegar ahí, ambas disparadas por el tiempo y ninguna anunciada.
Una checklist para una integración de publicación en LinkedIn
- Envía LinkedIn-Version en cada request a /rest/*, desde una única constante.
- Envía X-Restli-Protocol-Version: 2.0.0 junto a él: es protocolo, no versión, y su valor nunca cambia.
- No envíes ninguno de los dos a endpoints /v2/*. Son anteriores al esquema de versionado.
- Espera un 426 NONEXISTENT_VERSION más o menos una vez al año. Alerta por el nombre del error y deja un recordatorio de calendario como respaldo real.
- Lee la URN del post creado desde el header de respuesta x-restli-id, no desde el cuerpo.
- Ramifica el manejo de media según la cantidad de imágenes: una imagen usa una forma de contenido distinta a dos o más.
- Registra, haz el PUT y después referencia la URN del asset: lo que va en el post no es la URL de subida.
- Asume que no hay refresh token. Diseña el flujo de reconexión antes de necesitarlo, y haz que reconectar sea un upsert y no un insert.
- Para publicar en un perfil personal, w_member_social en una app en modo desarrollo con el usuario que publica como tester es suficiente: sin revisión de app.
Por qué esto nos importa más que a la mayoría
seenpaid publica en 21 plataformas, lo que significa mantener 23 de estas relaciones y heredar todas sus rarezas. El header versionado de LinkedIn es una. Meta tiene un typo en su propia guía de configuración que manda a la gente a un scope que no existe. Bluesky acepta posts cuyos enlaces quedan silenciosamente sin poder clicarse. Una app de TikTok en sandbox reporta éxito en posts que nadie puede ver. Cada una es un día que alguien pierde, y ninguna aparece en la guía de primeros pasos.
Esa es la mayor parte del argumento para no construir esto tú mismo. La otra parte es lo que pasa después de que el post sale: el enlace de cada post se rastrea, se leen los datos de pago del propio vendedor, y te dicen qué posts produjeron ingresos de verdad, que es la pregunta a la que todo el trabajo de API servía desde el principio.