Publicar en Instagram de forma programática es bastante más difícil de lo que parece, y casi toda la dificultad no está en el código. Está en un puñado de lugares donde la documentación de Meta es incorrecta, incompleta, o describe una integración distinta a la que tú estás construyendo.
Nosotros lo implementamos y publicamos posts reales con ello. Estas son las cosas que nos costaron tiempo, escritas para que a ti no te cuesten nada.
1. La propia guía de Meta tiene un typo en el nombre del permiso
Para publicar, pides el permiso que te deja publicar. La página de la guía «API setup with Facebook login» de Meta lo nombra así:
- instagram_content_publishing ← este no existe
El permiso real, confirmado contra el catálogo autoritativo de Permissions and Features, es:
- instagram_content_publish ← sin «-ing»
Este es especialmente feo porque la falla no es evidente. Estás siguiendo una guía oficial de Meta, en un dominio de Meta, y la cadena que te da está mal. Peor: el error no se rechaza limpiamente en el punto donde lo cometes. El diálogo de autorización no siempre te detiene para avisarte que ese permiso no existe; terminas con un token concedido al que le falta justo la capacidad que necesitabas, y te enteras al momento de publicar, con un error de permisos que nombra el scope correcto que creías haber pedido.
La lección va más allá de Meta. Verifica cada nombre de scope contra el catálogo de permisos legible por máquina, nunca contra el tutorial en prosa. Los tutoriales se escriben una vez y se desactualizan; el catálogo se genera de lo que la plataforma realmente aplica. Si una guía y un catálogo se contradicen, el catálogo tiene razón y la guía tiene un reporte de bug esperando ser escrito.
La defensa barata es poner tu lista de scopes en una sola constante con nombre, en un solo archivo, con un comentario que registre de dónde salió cada cadena y cuándo la revisaste por última vez. Los scopes dispersos dentro de un constructor de URL de autorización son cadenas que nadie va a volver a verificar jamás.
2. Hay dos APIs de Instagram distintas y sus scopes no son intercambiables
Esta es la razón de fondo por la que la gente termina con el permiso equivocado. Meta ofrece dos integraciones separadas para publicar en Instagram:
- Instagram API with Facebook Login: el camino viejo, ligado a una Página. Requiere una cuenta de Instagram Business o Creator conectada a una Página de Facebook. Usa instagram_basic, instagram_content_publish, pages_show_list y pages_read_engagement.
- Instagram API with Instagram Login: el camino nuevo e independiente. No requiere Página de Facebook. Usa los scopes renombrados instagram_business_*.
Los scopes renombrados instagram_business_* aplican solo al segundo. Si estás en el camino ligado a la Página y los pegas porque un blog te lo dijo, nada funciona y el error no te va a explicar por qué. Decide primero qué integración estás construyendo, y luego toma la lista de scopes de la página de esa integración y de ningún otro lado.
Los dos caminos difieren en mucho más que los nombres de los scopes, que es justamente por qué mezclar su documentación causa tanta confusión. El camino ligado a la Página enruta todo a través de la Página: el token que tienes es un token de Página, la cuenta que direccionas se descubre desde la Página, y un usuario sin Página simplemente no puede conectarse. El camino independiente saca a la Página del panorama por completo. Cualquier tutorial que mencione /me/accounts está describiendo el primero; cualquiera que nunca mencione una Página está describiendo el segundo.
Vale la pena anotarlo para quien esté eligiendo hoy: el camino de Instagram Login le pide menos permisos al usuario y no arrastra una Página de Facebook al proceso. Si empiezas de cero, es el camino más fácil, y una pantalla de consentimiento más corta es una diferencia medible en cuánta gente termina de conectarse.
3. Guarda el token de Página, no el token de usuario
El baile de OAuth te entrega un token de acceso de usuario. Es tentador guardarlo y seguir adelante. No va a funcionar para publicar.
La secuencia que sí funciona:
- Intercambia el código de autorización por un token de usuario de corta duración: viven del orden de una o dos horas.
- Inmediatamente intercámbialo por un token de usuario de larga duración usando el grant fb_exchange_token. Este es el de ~60 días.
- Llama a /me/accounts con una lista de fields que incluya access_token e instagram_business_account. Cada Página en la respuesta trae su propio token de acceso de Página.
- Encuentra la Página que tiene un instagram_business_account: una persona puede administrar varias Páginas y quizá solo una tenga Instagram vinculado.
- Guarda el token de PÁGINA como credencial, y el id de la cuenta de Instagram business como identificador de la cuenta, no el id de la Página.
Esa última distinción importa más de lo que suena. El token con el que te autenticas y el id que direccionas pertenecen a dos objetos distintos. Guardar el id de la Página como identificador de cuenta produce una integración que se autentica bien y después no encuentra nada donde publicar, con un error que se lee como problema de permisos, porque así se ve normalmente un error de id-de-objeto-equivocado en la Graph API.
La razón para guardar el token de Página en vez del de usuario es la vida útil. Un token de Página derivado de un token de usuario de larga duración no lleva su propia expiración corta: sigue válido mientras lo esté el permiso de usuario subyacente. Guardar el token de usuario y volver a derivar el token de Página en cada publicación agrega un salto de red y un modo de falla sin ningún beneficio.
Un filo cortante dentro del propio intercambio: el intercambio de larga duración no siempre devuelve expires_in. Lo vimos ausente en producción. Si calculas una expiración a partir de un campo que no existe obtienes una fecha inválida, y entonces tu driver de base de datos se niega a serializarla y todo el flujo de conexión falla con un error de valor temporal que no dice nada sobre Meta. Usa por defecto la vida útil documentada de 60 días cuando el campo falte, en vez de confiar en que esté ahí.
4. Publicar son dos llamadas, e Instagram va a rechazar el texto solo
No existe un endpoint único para crear un post. Creas un contenedor de medios y luego lo publicas:
- POST /{ig-user-id}/media con la URL de la imagen o el video y el caption: devuelve un id de contenedor. El video va como media_type REELS con un video_url; las imágenes van con image_url.
- POST /{ig-user-id}/media_publish con ese id de contenedor como creation_id: devuelve el id del medio publicado.
Y una restricción que sorprende a quien está construyendo un cross-poster: Instagram no acepta posts de solo texto por API, en absoluto. Un caption sin medios no es un post válido. Si estás repartiendo un mismo borrador a varias redes, este es el caso que tienes que manejar explícitamente: el mismo texto que se publica bien en LinkedIn o Bluesky simplemente no puede ir a Instagram sin una imagen o un video adjunto. Recházalo en tu propia capa de validación con un mensaje que explique por qué, en vez de dejarlo pasar para obtener un error de plataforma sobre el que tu usuario no puede actuar.
5. El contenedor es asíncrono, y fingir lo contrario rompe el video
La secuencia de dos llamadas de arriba es la versión de la documentación, y está incompleta. Entre las dos llamadas el contenedor tiene que terminar de procesarse. Para una imagen eso suele ser instantáneo. Para video no: Meta está transcodificando, y publicar un contenedor que no está listo falla.
Así que la secuencia real lleva un sondeo en el medio. Lee el objeto del contenedor con una lista de fields que incluya status_code y mira el resultado:
- IN_PROGRESS: sigue procesando. Espera y vuelve a preguntar.
- FINISHED: listo para publicar. Procede a media_publish.
- ERROR: el procesamiento falló. No reintentes la publicación; el contenedor está muerto y necesitas uno nuevo.
- EXPIRED: el contenedor se creó hace demasiado tiempo y nunca se publicó.
Sondea en un intervalo corto fijo con un tope duro de intentos, y trata el agotamiento del tope como un timeout y no como una falla: la distinción importa cuando decides si reintentar el job completo. Correr el mismo sondeo también para imágenes cuesta una petición extra y te quita una rama del código, lo cual es buen trato.
Relacionado y fácil de equivocar: la URL del medio que le entregas a Meta la descargan los servidores de Meta, no tú. Tiene que estar públicamente accesible durante todo el tiempo que dure el procesamiento. Las URLs prefirmadas con expiraciones cortas, las URLs detrás de autenticación y cualquier cosa en localhost van a fallar aquí, y el error llega como una falla de procesamiento sin explicación de la causa.
6. Los tokens de Página no se refrescan solos: diseña el flujo de reconexión ahora
En este flujo no hay refresh token. Cuando caduca el permiso de usuario de larga duración subyacente, el token de Página se va con él, y el único remedio es que la persona vuelva a pasar por la pantalla de consentimiento. Ningún job en segundo plano puede arreglárselo.
Eso no es un bug que haya que esquivar, así que constrúyelo asumiéndolo: guarda la expiración como una fecha límite real, pasa la cuenta a un estado explícito de «necesita reconectarse» antes de que expire y no después de que ya falló una publicación, y haz que reconectar reautorice sobre el registro de cuenta existente en vez de crear una fila duplicada. El modo de falla que estás evitando es el silencioso: una cuenta que se ve conectada en tu interfaz y deja caer sin ruido cada post programado.
7. Los callbacks de eliminación no harán absolutamente nada, en silencio
Este es un bug de cumplimiento más que de publicación, y es invisible hasta que alguien lo revisa. Meta envía callbacks de desautorización y de eliminación de datos que llevan un signed_request cuyo user_id es el id de usuario con alcance de app: el id de la persona, obtenido de /me con el token de usuario.
Pero si seguiste el consejo de arriba, el identificador que guardaste en la cuenta es un id de cuenta de Instagram business, o un id de Página para Facebook. Nunca un id de usuario. Así que llega el callback, buscas cuentas por el id que te dio, encuentras cero filas, y devuelves un código de confirmación exitoso sin haber borrado absolutamente nada. Todo se ve correcto en tus logs.
La solución es capturar el id de usuario con alcance de app al momento de conectar, con el token de usuario, antes de cambiar al token de Página (leer id de /me no requiere ningún permiso extra más allá del que toda app tiene) y guardarlo junto a la cuenta para que los callbacks tengan algo con qué hacer match. Haz que esa lectura sea no fatal: que falle debería degradar los callbacks, no romperle el login a alguien.
Lo que nadie te cuenta sobre App Review
No necesitas App Review para publicar en tus propias cuentas. Mientras la app de Meta siga en Development Mode y la cuenta objetivo esté agregada como Tester o Admin, todo el flujo funciona. App Review es para dejar que desconocidos conecten sus cuentas.
Una más, relacionada: no pidas permisos que tu código nunca llama. Teníamos un scope de Business Manager en nuestra cadena de scopes y ninguna ruta de código que lo usara; se había agregado especulativamente mientras depurábamos. Pedir un scope que nunca ejercitas es un motivo estándar de rechazo en App Review, así que lo quitamos. Todo lo que el flujo de Instagram necesita viene de /me/accounts, que pages_show_list ya cubre.
Una lista de verificación que funciona
- Elige tu integración: Facebook Login (ligada a Página) o Instagram Login (independiente). No mezcles su documentación.
- Toma los nombres de scope del catálogo de Permissions and Features, nunca de una guía de configuración.
- instagram_content_publish. Sin «-ing».
- Guarda el token de acceso de Página; guarda el id de la cuenta de Instagram business.
- Usa la vida útil por defecto del token cuando expires_in falte en la respuesta.
- Contenedor, sondear hasta FINISHED, y luego publicar. Tres llamadas, no dos.
- Sirve los medios desde una URL que Meta pueda descargar durante toda la ventana de procesamiento.
- Rechaza los posts de solo texto antes de enviarlos, con un mensaje que diga por qué.
- Captura el id de usuario con alcance de app para que los callbacks de desautorización y eliminación puedan hacer match.
- No pidas nada que no llames.
Por qué escribimos esto
Cada integración de plataforma trae un conjunto de rarezas como estas, y son individualmente pequeñas y colectivamente caras. LinkedIn retira sus versiones de API sin avisar y devuelve un 426 sin advertencia. Una app de TikTok en sandbox reporta éxito en posts que nadie puede ver. Instagram tiene el typo de arriba y un modelo de dos objetos para los tokens que se lee como un error hasta que lo entiendes. Cada una es un día de la vida de alguien, y ninguna está en el tutorial con el que empezaste.
La razón por la que seguimos pagando ese costo es lo que pasa después de publicar: seenpaid rastrea el enlace de cada post, lee los datos de pago del propio vendedor, y reporta qué posts realmente produjeron ingresos, que es la pregunta a la que todo este trabajo de API servía desde el principio.